Sfoglia il codice sorgente

Diagnostics: eliminate false positives against real-world code

Validated against the six m2compiler-V3 modules (all gm2-clean):
FileIO went from 222 false positives to zero, all six now clean.
- implementation modules see their own .def; local-module EXPORT
- FORWARD headings are not duplicates; FORWARD not an unknown identifier
- field access after ^/[] (f^.x), flattened multi-index (a[i, j])
- numeric suffixes (0C is CHAR), no backslash escapes in strings
- type arguments to builtins incl. SYSTEM.X, typed set constructors
- opaque types, procedure variables (calls and P := Q), LENGTH/ORDL
- arithmetic preserves named types (CARDINAL + 1 stays CARDINAL)
Eric Streit 1 settimana fa
parent
commit
c6a80f0bf4

+ 19 - 0
extensions/modula2-language/DIAGNOSTICS.md

@@ -60,3 +60,22 @@ calibrated case-by-case against `gm2`:
 
 Still out of scope: use-before-declaration order, range checking,
 builtin-procedure signatures, record-field types, `WITH`-block internals.
+
+## Real-file validation
+
+The checker is validated against a real codebase — the six modules of the
+`m2compiler-V3` compiler (`FileIO`, `SymTab`, `QbeGen`, `M2S`, `M2P`, `M2`),
+all of which `gm2 -fiso` accepts with zero errors. The analyser reports zero
+issues on all six. Getting there exposed and fixed:
+
+- implementation modules see their own `.def` (`FileIO.mod` + `FileIO.def`);
+- `FORWARD` procedure headings (not duplicates of the later definition);
+- field access after `^`/`[]` (`f^.x`, `a[i].x`) and flattened multi-index
+  (`a[i, j]` over nested arrays);
+- octal/hex/char literal suffixes (`0C` is `CHAR`, `177B`/`0FFH` whole);
+- no backslash escapes in strings (`'\'` is one character);
+- type arguments to builtins (`SIZE(T)`, `VAL(T, x)`, `MAX(T)`,
+  `SYSTEM.TSIZE(T)`) and typed set constructors (`BITSET{...}`);
+- opaque types (`TYPE File;`), local-module `EXPORT` visibility,
+  procedure variables (calls and `P := Q` assignment), the pervasive
+  `LENGTH`/`ORDL`, arithmetic that preserves `CARDINAL` (`c + 1`).

+ 17 - 10
extensions/modula2-language/src/m2/analyse.ts

@@ -36,10 +36,10 @@ const BUILTINS = new Set([
   'OCTET',
   // Constants
   'NIL', 'TRUE', 'FALSE',
-  // Procedures and functions
+  // Procedures and functions (including the pervasive LENGTH and ORDL).
   'ABS', 'ADR', 'CAP', 'CHR', 'DEC', 'DISPOSE', 'EXCL', 'FLOAT', 'HALT',
   'HIGH', 'INC', 'INCL', 'LFLOAT', 'MAX', 'MIN', 'NEW', 'ODD', 'ORD',
-  'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE',
+  'ORDL', 'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE', 'LENGTH',
 ]);
 
 const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
@@ -71,16 +71,21 @@ function duplicateDeclarations(unit: M2Unit): M2Issue[] {
     let seen = scopes.get(scope);
     if (!seen) { seen = new Map(); scopes.set(scope, seen); }
     const first = seen.get(sym.name);
-    if (first) {
-      issues.push({
-        line: sym.nameRange.startLine, ch: sym.nameRange.startCh,
-        endLine: sym.nameRange.endLine, endCh: sym.nameRange.endCh,
-        message: `Duplicate declaration "${sym.name}" (first declared at line ${first.nameRange.startLine + 1})`,
-        severity: 'error',
-      });
-    } else {
+    if (!first) {
       seen.set(sym.name, sym);
+      continue;
+    }
+    // A `FORWARD` heading and its later definition are one declaration.
+    if (first.forward || sym.forward) {
+      if (!first.forward) seen.set(sym.name, sym);
+      continue;
     }
+    issues.push({
+      line: sym.nameRange.startLine, ch: sym.nameRange.startCh,
+      endLine: sym.nameRange.endLine, endCh: sym.nameRange.endCh,
+      message: `Duplicate declaration "${sym.name}" (first declared at line ${first.nameRange.startLine + 1})`,
+      severity: 'error',
+    });
   }
   return issues;
 }
@@ -98,6 +103,8 @@ function unknownIdentifiers(tokens: Token[], unit: M2Unit, docDir: string): M2Is
   for (let k = 0; k < tokens.length; k++) {
     const t = tokens[k];
     if (t.kind !== 'ident') continue;
+    // Reserved words the lexer leaves as identifiers (`FORWARD` headings).
+    if (t.text === 'FORWARD') continue;
     const prev = tokens[k - 1];
     const next = tokens[k + 1];
     // Qualified access (`M.X`, `r.field`): unverifiable without deeper analysis.

+ 102 - 44
extensions/modula2-language/src/m2/checker.ts

@@ -16,8 +16,8 @@ import { Token } from './lexer';
 import { M2Symbol, M2Unit } from './parser';
 import { resolveName } from './resolve';
 import {
-  M2Type, UNKNOWN, arithKind, displayType, isArrayArgCompatible, isAssignable,
-  isComparable, isOrdinal, parseTypeText, resolveDeclaredType, resolveQualified,
+  M2Type, UNKNOWN, arithKind, arithResultType, displayType, isArrayArgCompatible, isAssignable,
+  isComparable, isOrdinal, numberLiteralType, parseTypeText, resolveDeclaredType, resolveQualified,
   resolveType, returnTypeOf, unitOfFile,
 } from './types';
 import * as path from 'path';
@@ -30,7 +30,7 @@ const TYPE_BUILTINS = new Set([
 const PROC_BUILTINS = new Set([
   'ABS', 'ADR', 'CAP', 'CHR', 'DEC', 'DISPOSE', 'EXCL', 'FLOAT', 'HALT',
   'HIGH', 'INC', 'INCL', 'LFLOAT', 'MAX', 'MIN', 'NEW', 'ODD', 'ORD',
-  'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE', 'COPY', 'FILL',
+  'ORDL', 'SIZE', 'TRUNC', 'TSIZE', 'VAL', 'CODE', 'COPY', 'FILL', 'LENGTH',
 ]);
 
 const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
@@ -95,6 +95,10 @@ function bodyIndexes(tokens: Token[], sl: number, sc: number, el: number, ec: nu
 class Walker {
   private pos: number;
   private returnType: M2Type | null | undefined;
+  /** Depth of nested builtin-procedure argument lists (type args allowed). */
+  private builtinArgs = 0;
+  /** Parsing the right-hand side of an assignment to a procedure variable. */
+  private expectingProc = false;
   constructor(
     private ctx: Ctx,
     private toks: Token[],
@@ -212,7 +216,8 @@ class Walker {
     }
     const ret = this.procReturn();
     const t = this.peek();
-    if (!t || t.text === ';' || t.kind === 'keyword') {
+    // `;`, `|` (CASE separator), `ELSE`/`END`/etc. terminate a bare RETURN.
+    if (!t || t.text === ';' || t.text === '|' || t.kind === 'keyword') {
       if (ret) this.issue(kw, `RETURN without a value in a function returning ${displayType(ret)}`);
       return;
     }
@@ -445,7 +450,14 @@ class Walker {
       tok = nm;
       if (info.kind === 'module') {
         const q = resolveQualified(this.ctx.unit, this.ctx.docDir, info.name, nm.text);
-        if (!q) { info = { kind: 'unknown' }; continue; }
+        if (!q) {
+          // Qualified builtin (`SYSTEM.TSIZE`, `SYSTEM.ADR`, ...): the defining
+          // `.def` is usually unavailable, but the name is still a builtin call.
+          info = PROC_BUILTINS.has(nm.text)
+            ? { kind: 'builtinProc', name: nm.text }
+            : { kind: 'unknown' };
+          continue;
+        }
         switch (q.sym.kind) {
           case 'variable': case 'parameter': info = { kind: 'var', sym: q.sym, host: q.host, dir: q.dir }; break;
           case 'procedure': info = { kind: 'proc', sym: q.sym, host: q.host, dir: q.dir }; break;
@@ -464,17 +476,21 @@ class Walker {
         const open = this.next()!;
         const idxTypes = this.parseExprList();
         if (this.atSym(']')) this.next();
-        if (type.kind === 'array') {
-          for (const it of idxTypes) {
-            if (it.kind !== 'unknown' && !isOrdinal(it)) {
+        // Consume one dimension per index: gm2 flattens `a[i, j]` over
+        // nested arrays (`symSet[0, i]` means `symSet[0][i]`).
+        let badIndex = false;
+        for (const it of idxTypes) {
+          if (type.kind === 'array') {
+            if (!badIndex && it.kind !== 'unknown' && !isOrdinal(it)) {
               this.issue(open, `Array index must have an ordinal type, found ${displayType(it)}`);
-              break;
+              badIndex = true;
             }
+            type = type.element;
+          } else if (type.kind !== 'unknown') {
+            this.issue(open, `Cannot index ${displayType(type)}`);
+            type = UNKNOWN;
+            break;
           }
-          type = type.element;
-        } else if (type.kind !== 'unknown') {
-          this.issue(open, `Cannot index ${displayType(type)}`);
-          type = UNKNOWN;
         }
       } else if (this.atSym('^')) {
         const op = this.next()!;
@@ -484,10 +500,25 @@ class Walker {
           this.issue(op, `Cannot dereference ${displayType(type)}`);
           type = UNKNOWN;
         }
+      } else if (this.atSym('.')) {
+        // Field selection after indexing or dereferencing (`f^.x`, `a[i].x`).
+        // (Direct `r.x` is consumed by the qualifier chain above.)
+        this.next(); // .
+        const nm = this.peek();
+        if (nm?.kind === 'ident') this.next();
+        // Field types are untracked: any structured value yields unknown.
+        type = UNKNOWN;
+        info = { kind: 'field' };
       } else {
         break;
       }
     }
+    // Set constructor with explicit type (`BITSET{...}`, `MySet{...}`).
+    if ((info.kind === 'type' || info.kind === 'builtinType') && this.atSym('{')) {
+      this.next();
+      this.parseSetConstructor();
+      return { type: { kind: 'setLit' }, info: { kind: 'unknown' }, tok };
+    }
     // Optional call.
     if (this.atSym('(')) {
       const call = this.parseCall(info, tok);
@@ -504,11 +535,18 @@ class Walker {
       type = call.type;
     } else if (mode === 'expr') {
       if (info.kind === 'proc') {
+        // A bare procedure name on the right of a `P := Q` assignment
+        // denotes the procedure itself, not a call.
+        if (this.expectingProc) return { type: { kind: 'proc' }, info, tok };
         const checked = this.checkCall(info, [], tok);
         if (checked.isVoid) this.issue(tok, `Procedure "${info.sym.name}" does not return a value`);
         type = checked.type;
       } else if (info.kind === 'type' || info.kind === 'builtinType') {
-        this.issue(tok, `"${tok.text}" is a type and cannot be used as a value`);
+        // Type names are legal arguments to builtin procedures
+        // (`SIZE(T)`, `TSIZE(T)`, `VAL(T, x)`, `MAX(T)`).
+        if (this.builtinArgs === 0) {
+          this.issue(tok, `"${tok.text}" is a type and cannot be used as a value`);
+        }
         type = UNKNOWN;
       } else if (info.kind === 'module') {
         this.issue(tok, `"${tok.text}" is a module and cannot be used as a value`);
@@ -535,12 +573,31 @@ class Walker {
   }
 
   private parseCall(info: HeadInfo, tok: Token): { type: M2Type; isVoid: boolean; returnsValue: boolean } {
+    // Builtin procedures (`SIZE`, `VAL`, `MAX`, ...) accept type names and
+    // are otherwise unchecked: parse arguments in lenient mode.
+    if (info.kind === 'builtinProc') {
+      this.next(); // (
+      this.builtinArgs++;
+      try {
+        this.parseExprList();
+        if (this.atSym(')')) this.next();
+      } finally {
+        this.builtinArgs--;
+      }
+      return { type: UNKNOWN, isVoid: false, returnsValue: false };
+    }
     this.next(); // (
     const args = this.parseExprList();
     if (this.atSym(')')) this.next();
     if (info.kind === 'proc') return this.checkCall(info, args, tok);
+    // Calls through procedure variables (`handler(x)`): signatures are
+    // untracked, so such calls are unchecked but legal.
+    if (info.kind === 'var' || info.kind === 'const') {
+      const vt = this.valueType(info, tok);
+      if (vt.kind === 'proc') return { type: UNKNOWN, isVoid: false, returnsValue: false };
+    }
     // Builtin procedures, casts through type names and unknown callees: unchecked.
-    if (info.kind === 'builtinProc' || info.kind === 'type' || info.kind === 'builtinType' ||
+    if (info.kind === 'type' || info.kind === 'builtinType' ||
         info.kind === 'unknown' || info.kind === 'field') {
       return { type: UNKNOWN, isVoid: false, returnsValue: false };
     }
@@ -583,8 +640,22 @@ class Walker {
   }
 
   private checkAssignment(d: Value, op: Token): void {
-    const rhs = this.parseExpr(0);
     const info = d.info;
+    // Assignments to procedure variables accept bare procedure designators.
+    const lhsProc = info.kind === 'var' && d.type.kind === 'proc';
+    let rhs: Value;
+    if (lhsProc) {
+      this.expectingProc = true;
+      try {
+        rhs = this.parseExpr(0);
+      } finally {
+        this.expectingProc = false;
+      }
+      if (rhs.type.kind === 'proc' || rhs.type.kind === 'unknown') return;
+      this.issue(op, `Cannot assign ${displayType(rhs.type)} to procedure variable "${d.tok.text}"`);
+      return;
+    }
+    rhs = this.parseExpr(0);
     if (info.kind === 'const' || info.kind === 'literal') {
       this.issue(d.tok, `Cannot assign to constant "${d.tok.text}"`);
       return;
@@ -704,36 +775,25 @@ class Walker {
   }
 
   private arithResult(op: Token, a: M2Type, b: M2Type): M2Type {
-    const ka = arithKind(a);
-    const kb = arithKind(b);
-    if (ka === 'unknown' || kb === 'unknown') return UNKNOWN;
-    if (ka === 'set' && kb === 'set') return { kind: 'bitset' };
-    if ((ka === 'whole' || ka === 'real') && (kb === 'whole' || kb === 'real')) {
-      if (ka !== kb) {
-        this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
-        return UNKNOWN;
-      }
-      if (ka === 'real') {
-        const lit = a.kind === 'real' && b.kind === 'real' && a.literal && b.literal;
-        return { kind: 'real', id: 'REAL', literal: lit };
-      }
-      const lit = a.kind === 'whole' && b.kind === 'whole' && a.literal && b.literal;
-      return { kind: 'whole', id: 'INTEGER', literal: lit };
+    const t = arithResultType(a, b);
+    if (!t) {
+      this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
+      return UNKNOWN;
     }
-    this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
-    return UNKNOWN;
+    return t;
   }
 
   private divResult(op: Token, a: M2Type, b: M2Type): M2Type {
-    const ka = arithKind(a);
-    const kb = arithKind(b);
-    if (ka === 'unknown' || kb === 'unknown') return UNKNOWN;
-    if (ka === 'whole' && kb === 'whole') {
-      const lit = a.kind === 'whole' && b.kind === 'whole' && a.literal && b.literal;
-      return { kind: 'whole', id: 'INTEGER', literal: lit };
+    const t = arithResultType(a, b);
+    if (!t) {
+      this.issue(op, `Operator "${op.text}" requires whole-number operands`);
+      return UNKNOWN;
     }
-    this.issue(op, `Operator "${op.text}" requires whole-number operands`);
-    return UNKNOWN;
+    if (t.kind !== 'unknown' && arithKind(t) !== 'whole') {
+      this.issue(op, `Operator "${op.text}" requires whole-number operands`);
+      return UNKNOWN;
+    }
+    return t;
   }
 
   private checkBoolOperands(op: Token, a: M2Type, b: M2Type): void {
@@ -782,9 +842,7 @@ class Walker {
     if (t.kind === 'number') {
       this.next();
       return {
-        type: /[.eE]/.test(t.text)
-          ? { kind: 'real', id: 'REAL', literal: true }
-          : { kind: 'whole', id: 'INTEGER', literal: true },
+        type: numberLiteralType(t.text),
         info: { kind: 'unknown' }, tok: t,
       };
     }

+ 3 - 2
extensions/modula2-language/src/m2/lexer.ts

@@ -77,13 +77,14 @@ export function lex(text: string): Token[] {
       continue;
     }
 
-    // String literal (single/double quoted, terminated at end of line)
+    // String literal (single/double quoted, terminated at end of line).
+    // Modula-2 has no backslash escapes: backslash is an ordinary character
+    // (e.g. '\' is a one-character string), so only the quote terminates.
     if (c === "'" || c === '"') {
       const s = i, sl = line, sc = col;
       const quote = c;
       advance(1);
       while (i < text.length && text[i] !== '\n' && text[i] !== '\r') {
-        if (text[i] === '\\' && i + 1 < text.length && text[i + 1] !== '\n') { advance(2); continue; }
         if (text[i] === quote) { advance(1); break; }
         advance(1);
       }

+ 48 - 1
extensions/modula2-language/src/m2/parser.ts

@@ -29,6 +29,10 @@ export interface M2Symbol {
   extent: M2Range;
   /** Statement body range (BEGIN..END) for modules/procedures with bodies. */
   body?: M2Range;
+  /** True for `PROCEDURE ... FORWARD` headings (definition follows later). */
+  forward?: boolean;
+  /** Names this local module exports to its enclosing scope (bare-visible). */
+  exports?: string[];
   children: M2Symbol[];
 }
 
@@ -253,6 +257,12 @@ class Parser {
           });
         }
         parent.children.push(typeSym);
+      } else {
+        // Opaque type (`TYPE File;`): no expansion, recorded for visibility.
+        parent.children.push({
+          name: nm.text, kind: 'type', detail: nm.text,
+          nameRange: rangeOf(nm), extent: rangeOf(nm), children: [],
+        });
       }
       if (!this.eatSym(';')) this.syncDecl();
     }
@@ -300,6 +310,20 @@ class Parser {
     let enumOk = true;
     let firstTok: Token | null = null;
     let lastTok: Token | null = null;
+    // A leading PROCEDURE introduces a procedure type (`P: PROCEDURE (...): T`,
+    // `GetCH = PROCEDURE (...): CHAR`): consume it with its signature instead
+    // of mistaking it for the next declaration section.
+    if (this.atKw('PROCEDURE')) {
+      this.next();
+      if (this.atSym('(')) this.skipBalanced('(', ')');
+      if (this.atSym(':')) {
+        this.next();
+        if (this.peek().kind === 'ident') {
+          this.next();
+          while (this.atSym('.') && this.peek(1)?.kind === 'ident') { this.next(); this.next(); }
+        }
+      }
+    }
     while (!this.atEof()) {
       const t = this.peek();
       if (t.kind === 'symbol') {
@@ -398,6 +422,18 @@ class Parser {
     this.skipAlignment();
     if (this.atSym(';')) this.next();
     else { this.syncDecl(); }
+    // Forward declaration: `PROCEDURE P (...); FORWARD;` — no body follows.
+    if (this.peek().kind === 'ident' && this.peek().text === 'FORWARD') {
+      this.next();
+      this.eatSym(';');
+      proc.forward = true;
+      const endTok = this.prevTok();
+      proc.extent = {
+        startLine: proc.extent.startLine, startCh: proc.extent.startCh,
+        endLine: endTok.endLine, endCh: endTok.endCh,
+      };
+      return proc;
+    }
     if (!withBody) {
       const endTok = this.prevTok();
       proc.extent = {
@@ -500,7 +536,18 @@ class Parser {
     if (this.atSym('[')) this.skipBalanced('[', ']');
     this.eatSym(';');
     this.parseImportSeq(unit);
-    if (this.atKw('EXPORT')) this.skipToSym(';');
+    // A local module's EXPORT list makes those names visible bare in the
+    // enclosing scope (`EXPORT QUALIFIED` names stay qualification-only).
+    if (this.atKw('EXPORT')) {
+      this.next();
+      if (this.atKw('QUALIFIED')) {
+        this.next();
+        this.skipToSym(';');
+      } else {
+        mod.exports = this.parseIdentList();
+        this.eatSym(';');
+      }
+    }
     this.parseBlock(mod, unit, true);
     if (this.peek().kind === 'ident') this.next();
     const endTok = this.prevTok();

+ 52 - 2
extensions/modula2-language/src/m2/resolve.ts

@@ -65,6 +65,22 @@ export function visibleSymbols(unit: M2Unit, line: number, ch: number): M2Symbol
           if (g.kind === 'constant' && !seen.has(g.name)) { seen.add(g.name); out.push(g); }
         }
       }
+      // Names exported bare by directly-nested local modules.
+      if (c.kind === 'module' && c.exports) {
+        for (const n of c.exports) {
+          const e = c.children.find(g => g.name === n);
+          if (e && !seen.has(e.name)) { seen.add(e.name); out.push(e); }
+        }
+      }
+    }
+  }
+  // The unit's own definition module (implicit scope for implementations).
+  const own = ownDefUnit(unit);
+  if (own) {
+    for (const s of own.symbols) {
+      for (const c of s.children) {
+        if (c.kind !== 'module' && !seen.has(c.name)) { seen.add(c.name); out.push(c); }
+      }
     }
   }
   // Imported names.
@@ -141,18 +157,52 @@ function findInScope(children: M2Symbol[], name: string): M2Symbol | null {
   return null;
 }
 
+/** The unit's own definition module, if any.
+ *
+ *  An implementation module (`MODULE M` in `X.mod`) implicitly sees the
+ *  declarations of its own definition module (`M.def` in the same
+ *  directory). Returns null for definition modules and when no sibling
+ *  `.def` exists. Never returns the unit itself. */
+export function ownDefUnit(unit: M2Unit): M2Unit | null {
+  if (unit.definition || !unit.moduleName) return null;
+  const def = loadDefModule(path.dirname(unit.filePath), unit.moduleName);
+  if (!def || def.filePath === unit.filePath) return null;
+  return def;
+}
+
+/** A name exported bare by a directly-nested local module (`EXPORT a` in `MODULE M`). */
+function exportedIn(mods: M2Symbol[], name: string): M2Symbol | null {
+  for (const m of mods) {
+    if (m.kind === 'module' && m.exports?.includes(name)) {
+      const found = m.children.find(c => c.name === name);
+      if (found) return found;
+    }
+  }
+  return null;
+}
+
 /** Resolve a same-file visible name to its declaration. */
 export function resolveLocal(unit: M2Unit, line: number, ch: number, name: string): M2Symbol | null {
   const chain = scopeChainAt(unit, line, ch);
   for (let k = chain.length - 1; k >= 0; k--) {
-    const found = findInScope(chain[k].children, name);
+    const found = findInScope(chain[k].children, name)
+      ?? exportedIn(chain[k].children, name);
     if (found) return found;
   }
   // Module-level fallback.
   for (const s of unit.symbols) {
-    const found = findInScope(s.children, name);
+    const found = findInScope(s.children, name)
+      ?? exportedIn(s.children, name);
     if (found) return found;
   }
+  // The unit's own definition module (implicit scope for implementations).
+  const own = ownDefUnit(unit);
+  if (own) {
+    for (const s of own.symbols) {
+      const found = findInScope(s.children, name);
+      if (found) return found;
+    }
+  }
   return null;
 }
 

+ 71 - 5
extensions/modula2-language/src/m2/types.ts

@@ -97,9 +97,7 @@ class TypeParser {
     }
     if (t.kind === 'number') {
       this.next();
-      return /[.eE]/.test(t.text)
-        ? { kind: 'real', id: 'REAL', literal: true }
-        : { kind: 'whole', id: 'INTEGER', literal: true };
+      return numberLiteralType(t.text);
     }
     if (t.kind === 'string') {
       this.next();
@@ -308,7 +306,14 @@ function resolveT(t: M2Type, unit: M2Unit, dir: string, line: number, ch: number
   const host = resolved.filePath === unit.filePath ? unit : unitOfFile(resolved.filePath);
   const detail = resolved.sym.detail;
   const eq = detail.indexOf('=');
-  if (eq < 0 || !host) return UNKNOWN;
+  if (!host) return UNKNOWN;
+  if (eq < 0) {
+    // Opaque type (`TYPE File;`): model as a distinct named pointer so that
+    // NIL assignment, same-type comparison and mismatch detection work.
+    // (Approximation: an opaque type need not be pointer-implemented, but
+    // treating it as unknown would silence all checking on it.)
+    return { kind: 'pointer', target: UNKNOWN, name: t.qualifier ? `${t.qualifier}.${t.name}` : t.name };
+  }
   seen.add(key);
   const inner = resolveT(
     parseTypeText(detail.slice(eq + 1)), host, path.dirname(resolved.filePath),
@@ -429,7 +434,68 @@ function isSetish(t: M2Type): boolean {
   return t.kind === 'bitset' || t.kind === 'setLit' || t.kind === 'setNamed';
 }
 
-/** Numeric (whole/real) or set type for arithmetic operators. */
+/** Classify a numeric literal token (`12C` is CHAR, `177B`/`0FFH` are whole). */
+export function numberLiteralType(text: string): M2Type {
+  if (/[.eE]/.test(text)) return { kind: 'real', id: 'REAL', literal: true };
+  if (/C$/i.test(text)) return { kind: 'char' };
+  return { kind: 'whole', id: 'INTEGER', literal: true };
+}
+
+/** Result of `+ - * / DIV MOD` over two operand types.
+ *
+ *  Calibrated against gm2: literals adapt to the other side's type
+ *  (`CARDINAL + 1` stays CARDINAL); two different named types are
+ *  incompatible (`CARDINAL + INTEGER`, `REAL + LONGREAL`); mixed
+ *  whole/real is incompatible. Returns null on incompatibility
+ *  (the caller reports) and `unknown` for unknowable operands.
+ */
+export function arithResultType(a: M2Type, b: M2Type): M2Type | null {
+  const sa = stripSub(a);
+  const sb = stripSub(b);
+  const ka = famOf(sa);
+  const kb = famOf(sb);
+  if (ka === 'unknown' || kb === 'unknown') return UNKNOWN;
+  if (ka === 'set' && kb === 'set') return { kind: 'bitset' };
+  if ((ka === 'whole' || ka === 'real') && ka === kb) {
+    const la = isLit(sa);
+    const lb = isLit(sb);
+    if (la && lb) {
+      return ka === 'whole'
+        ? { kind: 'whole', id: 'INTEGER', literal: true }
+        : { kind: 'real', id: 'REAL', literal: true };
+    }
+    if (la) return unlit(sb);
+    if (lb) return unlit(sa);
+    if (sa.kind === sb.kind && namedId(sa) === namedId(sb)) return unlit(sa);
+    return null;
+  }
+  return null;
+}
+
+function famOf(t: M2Type): 'whole' | 'real' | 'set' | 'other' | 'unknown' {
+  if (t.kind === 'unknown') return 'unknown';
+  if (t.kind === 'whole') return 'whole';
+  if (t.kind === 'real') return 'real';
+  if (t.kind === 'bitset' || t.kind === 'setLit' || t.kind === 'setNamed') return 'set';
+  return 'other';
+}
+
+function isLit(t: M2Type): boolean {
+  return (t.kind === 'whole' || t.kind === 'real') && t.literal;
+}
+
+/** The named type id for identity comparison (`INTEGER`, `REAL`, ...). */
+function namedId(t: M2Type): string | null {
+  if (t.kind === 'whole' || t.kind === 'real') return t.literal ? null : t.id;
+  return null;
+}
+
+/** Non-literal variant of a numeric type (literals already handled). */
+function unlit(t: M2Type): M2Type {
+  if (t.kind === 'whole') return { kind: 'whole', id: t.id, literal: false };
+  if (t.kind === 'real') return { kind: 'real', id: t.id, literal: false };
+  return t;
+}
 export function arithKind(t: M2Type): 'whole' | 'real' | 'set' | 'other' | 'unknown' {
   const b = stripSub(t);
   if (b.kind === 'unknown') return 'unknown';