Przeglądaj źródła

Type checker: assignments, calls, conditions and operands in bodies

- new m2/types.ts: gm2-calibrated type model (whole/real families,
  transparent aliases, adaptive subranges, sets, pointers, enums, NIL)
- new m2/checker.ts: statement/expression walker over BEGIN..END bodies
  (arity, argument types, ignored results, void-as-value, BOOLEAN
  conditions, ordinal CASE/FOR, RETURN checks, illegal targets/operands)
- parser records body ranges and exports topLevel; analyse gains
  FROM-membership checks and runs the body checker
Eric Streit 1 tydzień temu
rodzic
commit
cc304df00b

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

@@ -36,3 +36,27 @@ configured:
 Not covered (needs statement/expression parsing): assignment type
 compatibility, call arity, use-before-declaration order, FROM-list membership
 against the defining `.def`.
+
+## Statement checker (`src/m2/checker.ts` + `src/m2/types.ts`, source `modula2`)
+
+Bodies (`BEGIN..END`, recorded by the parser) are walked with a recursive
+statement/expression analyser. Operator and compatibility rules were
+calibrated case-by-case against `gm2`:
+
+- assignment compatibility, including whole-number family, `REAL`↔`LONGREAL`,
+  `CHAR`/string rules with `ARRAY OF CHAR` length checks, sets, pointers,
+  enumerations, `NIL`;
+- call arity (GNU optional parameters supported) and argument types,
+  including open-array formals; ignored function results and void procedures
+  used as values;
+- `BOOLEAN` conditions (`IF`, `WHILE`, `ELSIF`, `UNTIL`), ordinal `CASE`
+  selectors and `FOR` variables/bounds/`BY` steps;
+- illegal targets (`CONST`, procedure, type, module), illegal operands
+  (`NOT`/`AND`/`OR`, mixed arithmetic, `DIV`/`MOD` on reals, indexing,
+  dereferencing, comparisons of arrays/records, `CARDINAL` vs `INTEGER`
+  comparisons);
+- `RETURN` without a value, value-returning `RETURN`s, `RETURN` outside
+  procedures and values returned from void procedures.
+
+Still out of scope: use-before-declaration order, range checking,
+builtin-procedure signatures, record-field types, `WITH`-block internals.

+ 34 - 6
extensions/modula2-language/src/m2/analyse.ts

@@ -7,15 +7,20 @@
  *  - qualified names (`M.X`, `r.field`) are never flagged;
  *  - identifiers inside `WITH` regions are skipped (unqualified field access);
  *  - FROM-imported names are skipped when the defining `.def` is unavailable;
+ *  - FROM-imported names missing from a resolvable `.def` are reported
+ *    (silent when the `.def` is unavailable);
  *  - predefined identifiers (`INTEGER`, `INC`, `NIL`, ...) are always accepted.
  *
- *  Full type checking (assignment compatibility, call arity) needs statement
- *  and expression parsing and is deliberately out of scope here.
+ *  Statement bodies are checked by `checker.ts` (assignment compatibility,
+ *  call arity and arguments, `BOOLEAN` conditions, `RETURN`s, operands).
+ *  Still out of scope: use-before-declaration order, range checking,
+ *  builtin-procedure signatures, record-field types.
  */
 
 import { lex, Token } from './lexer';
 import { M2Symbol, M2Unit, parseUnitText, rangeContains } from './parser';
-import { flattenUnit, resolveName } from './resolve';
+import { flattenUnit, loadDefModule, resolveName, topLevel } from './resolve';
+import { checkBodies } from './checker';
 
 export interface M2Issue {
   line: number; ch: number; endLine: number; endCh: number;
@@ -41,9 +46,12 @@ const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH',
 
 export function analyseUnit(text: string, filePath: string, docDir: string): M2Issue[] {
   const unit = parseUnitText(text, filePath);
+  const tokens = lex(text);
   return [
     ...duplicateDeclarations(unit),
-    ...unknownIdentifiers(text, unit, docDir),
+    ...unknownIdentifiers(tokens, unit, docDir),
+    ...fromMembership(unit, docDir),
+    ...checkBodies(tokens, unit, docDir),
   ];
 }
 
@@ -78,9 +86,8 @@ function duplicateDeclarations(unit: M2Unit): M2Issue[] {
 }
 
 /** Identifier uses that resolve to no visible declaration. */
-function unknownIdentifiers(text: string, unit: M2Unit, docDir: string): M2Issue[] {
+function unknownIdentifiers(tokens: Token[], unit: M2Unit, docDir: string): M2Issue[] {
   const issues: M2Issue[] = [];
-  const tokens = lex(text);
   const declared = new Set<string>();
   for (const { sym } of flattenUnit(unit)) {
     declared.add(`${sym.nameRange.startLine}:${sym.nameRange.startCh}`);
@@ -118,6 +125,27 @@ function unknownIdentifiers(text: string, unit: M2Unit, docDir: string): M2Issue
   return issues;
 }
 
+/** FROM-imported names missing from a resolvable `.def` (silent when unavailable). */
+function fromMembership(unit: M2Unit, docDir: string): M2Issue[] {
+  const issues: M2Issue[] = [];
+  for (const imp of unit.imports) {
+    if (imp.all || imp.names.length === 0) continue;
+    const def = loadDefModule(docDir, imp.module);
+    if (!def) continue;
+    for (const n of imp.names) {
+      if (!topLevel(def, n)) {
+        issues.push({
+          line: imp.range.startLine, ch: imp.range.startCh,
+          endLine: imp.range.endLine, endCh: imp.range.endCh,
+          message: `"${n}" is not exported by module "${imp.module}"`,
+          severity: 'error',
+        });
+      }
+    }
+  }
+  return issues;
+}
+
 /** Offset spans of `WITH ... DO ... END` blocks (conservative bracket matching). */
 function withRegionsOf(tokens: Token[]): Array<[number, number]> {
   const regions: Array<[number, number]> = [];

+ 836 - 0
extensions/modula2-language/src/m2/checker.ts

@@ -0,0 +1,836 @@
+/** Statement-level semantic checking for Modula-2 bodies.
+ *
+ *  Walks `BEGIN..END` bodies (recorded by the parser) with a small recursive
+ *  statement/expression analyser and reports type errors: assignment
+ *  incompatibility, call arity and argument mismatches, non-BOOLEAN
+ *  conditions, bad `RETURN`s, illegal targets and operands. All operator and
+ *  compatibility rules live in `types.ts` and were calibrated against gm2.
+ *
+ *  Recovery-first: the walker always makes progress and never throws, so
+ *  broken code under editing yields fewer diagnostics, never a crash.
+ *  Uncertain cases resolve to `unknown` and stay silent.
+ */
+
+import type { M2Issue } from './analyse';
+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,
+  resolveType, returnTypeOf, unitOfFile,
+} from './types';
+import * as path from 'path';
+
+const TYPE_BUILTINS = new Set([
+  'INTEGER', 'CARDINAL', 'LONGINT', 'SHORTINT', 'LONGCARD', 'SHORTCARD',
+  'REAL', 'LONGREAL', 'CHAR', 'BOOLEAN', 'BITSET', 'ADDRESS', 'WORD', 'BYTE',
+  'OCTET', 'LOC',
+]);
+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',
+]);
+
+const OPENERS = new Set(['BEGIN', 'IF', 'CASE', 'LOOP', 'WHILE', 'FOR', 'WITH', 'RECORD', 'MODULE']);
+const MAX_DEPTH = 80;
+const MAX_EXPR_DEPTH = 50;
+
+interface Ctx {
+  unit: M2Unit;
+  docDir: string;
+  issues: M2Issue[];
+}
+
+type HeadInfo =
+  | { kind: 'var'; sym: M2Symbol; host: M2Unit; dir: string }
+  | { kind: 'proc'; sym: M2Symbol; host: M2Unit; dir: string }
+  | { kind: 'const'; sym: M2Symbol; host: M2Unit; dir: string }
+  | { kind: 'literal'; type: M2Type }
+  | { kind: 'type' }
+  | { kind: 'module'; name: string }
+  | { kind: 'builtinType'; name: string }
+  | { kind: 'builtinProc'; name: string }
+  | { kind: 'field' }
+  | { kind: 'unknown' };
+
+interface Value {
+  type: M2Type;
+  info: HeadInfo;
+  tok: Token;
+}
+
+export function checkBodies(tokens: Token[], unit: M2Unit, docDir: string): M2Issue[] {
+  const ctx: Ctx = { unit, docDir, issues: [] };
+  for (const s of unit.symbols) checkContainer(ctx, tokens, s);
+  return ctx.issues;
+}
+
+function checkContainer(ctx: Ctx, tokens: Token[], sym: M2Symbol): void {
+  for (const c of sym.children) {
+    if (c.kind === 'procedure' || c.kind === 'module') checkContainer(ctx, tokens, c);
+  }
+  if (!sym.body) return;
+  const range = bodyIndexes(tokens, sym.body.startLine, sym.body.startCh, sym.body.endLine, sym.body.endCh);
+  if (!range) return;
+  new Walker(ctx, tokens, sym, range.from, range.to).parseSeq(TOP_STOPS, 0);
+}
+
+const TOP_STOPS = new Set(['END']);
+
+function bodyIndexes(tokens: Token[], sl: number, sc: number, el: number, ec: number): { from: number; to: number } | null {
+  let from = -1;
+  let to = -1;
+  for (let k = 0; k < tokens.length; k++) {
+    const t = tokens[k];
+    if (t.kind === 'eof') break;
+    if (from < 0 && t.line === sl && t.ch === sc && t.kind === 'keyword' && t.text === 'BEGIN') from = k + 1;
+    if (t.kind === 'keyword' && t.text === 'END' && t.endLine === el && t.endCh === ec) { to = k; break; }
+  }
+  if (from < 0 || to < 0 || from > to) return null;
+  return { from, to };
+}
+
+class Walker {
+  private pos: number;
+  private returnType: M2Type | null | undefined;
+  constructor(
+    private ctx: Ctx,
+    private toks: Token[],
+    private scope: M2Symbol,
+    pos: number,
+    private end: number,
+  ) { this.pos = pos; }
+
+  private issue(t: Token, message: string): void {
+    this.ctx.issues.push({
+      line: t.line, ch: t.ch, endLine: t.endLine, endCh: t.endCh,
+      message, severity: 'error',
+    });
+  }
+
+  private peek(off = 0): Token | null {
+    const i = this.pos + off;
+    if (i < 0 || i >= this.end) return null;
+    const t = this.toks[i];
+    return t.kind === 'eof' ? null : t;
+  }
+
+  private atKw(word: string, off = 0): boolean {
+    const t = this.peek(off);
+    return !!t && t.kind === 'keyword' && t.text === word;
+  }
+
+  private atSym(sym: string, off = 0): boolean {
+    const t = this.peek(off);
+    return !!t && t.kind === 'symbol' && t.text === sym;
+  }
+
+  private next(): Token | null {
+    const t = this.peek();
+    if (t) this.pos++;
+    return t;
+  }
+
+  private expectKw(word: string): void {
+    if (this.atKw(word)) this.next();
+  }
+
+  parseSeq(stops: Set<string>, depth: number): void {
+    if (depth > MAX_DEPTH) { this.pos = this.end; return; }
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (t.kind === 'symbol' && t.text === ';') { this.next(); continue; }
+      if (t.kind === 'keyword' && stops.has(t.text)) return;
+      const before = this.pos;
+      this.parseStatement(stops, depth);
+      if (this.pos === before) this.next();
+    }
+  }
+
+  private parseStatement(stops: Set<string>, depth: number): void {
+    const t = this.peek();
+    if (!t) return;
+    if (t.kind === 'keyword') {
+      switch (t.text) {
+        case 'RETURN': this.parseReturn(); return;
+        case 'IF': this.parseIf(depth); return;
+        case 'WHILE': this.parseWhile(depth); return;
+        case 'REPEAT': this.parseRepeat(depth); return;
+        case 'LOOP': this.next(); this.parseSeq(new Set(['END']), depth + 1); this.expectKw('END'); return;
+        case 'FOR': this.parseFor(depth); return;
+        case 'WITH': this.parseWith(); return;
+        case 'CASE': this.parseCase(depth); return;
+        case 'EXIT': this.next(); return;
+        default:
+          if (stops.has(t.text)) return;
+          this.next(); return;
+      }
+    }
+    if (t.kind === 'ident') { this.parseDesignatorStatement(); return; }
+    this.skipToBoundary();
+  }
+
+  /** Skip garbage to the next plausible statement boundary (always progresses). */
+  private skipToBoundary(): void {
+    let depth = 0;
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (t.kind === 'symbol') {
+        if (t.text === '(' || t.text === '[' || t.text === '{') depth++;
+        else if (t.text === ')' || t.text === ']' || t.text === '}') {
+          if (depth === 0) return;
+          depth--;
+        } else if (t.text === ';' && depth === 0) return;
+        this.next();
+      } else if (t.kind === 'keyword' && depth === 0) {
+        return;
+      } else {
+        this.next();
+      }
+    }
+  }
+
+  private procReturn(): M2Type | null {
+    if (this.returnType === undefined) {
+      this.returnType = this.scope.kind === 'procedure'
+        ? returnTypeOf(this.scope, this.ctx.unit, this.ctx.docDir)
+        : null;
+    }
+    return this.returnType;
+  }
+
+  private parseReturn(): void {
+    const kw = this.next()!;
+    if (this.scope.kind !== 'procedure') {
+      this.issue(kw, 'RETURN outside a procedure');
+      this.skipToBoundary();
+      return;
+    }
+    const ret = this.procReturn();
+    const t = this.peek();
+    if (!t || t.text === ';' || t.kind === 'keyword') {
+      if (ret) this.issue(kw, `RETURN without a value in a function returning ${displayType(ret)}`);
+      return;
+    }
+    const e = this.parseExpr(0);
+    if (!ret) {
+      this.issue(kw, 'Cannot return a value from a procedure without a result type');
+    } else if (e.type.kind !== 'unknown' && !isAssignable(ret, e.type)) {
+      this.issue(kw, `Cannot return ${displayType(e.type)} as ${displayType(ret)}`);
+    }
+  }
+
+  private parseCondition(word: string): void {
+    const start = this.peek();
+    const e = this.parseExpr(0);
+    if (e.type.kind !== 'unknown' && e.type.kind !== 'bool') {
+      this.issue(start ?? this.toks[this.pos - 1], `Condition of ${word} must be BOOLEAN, found ${displayType(e.type)}`);
+    }
+  }
+
+  private parseIf(depth: number): void {
+    this.next(); // IF
+    this.parseCondition('IF');
+    this.expectKw('THEN');
+    const stops = new Set(['ELSIF', 'ELSE', 'END']);
+    this.parseSeq(stops, depth + 1);
+    while (this.atKw('ELSIF')) {
+      this.next();
+      this.parseCondition('ELSIF');
+      this.expectKw('THEN');
+      this.parseSeq(stops, depth + 1);
+    }
+    if (this.atKw('ELSE')) {
+      this.next();
+      this.parseSeq(new Set(['END']), depth + 1);
+    }
+    this.expectKw('END');
+  }
+
+  private parseWhile(depth: number): void {
+    this.next(); // WHILE
+    this.parseCondition('WHILE');
+    this.expectKw('DO');
+    this.parseSeq(new Set(['END']), depth + 1);
+    this.expectKw('END');
+  }
+
+  private parseRepeat(depth: number): void {
+    this.next(); // REPEAT
+    this.parseSeq(new Set(['UNTIL']), depth + 1);
+    if (this.atKw('UNTIL')) {
+      this.next();
+      this.parseCondition('REPEAT..UNTIL');
+    }
+  }
+
+  private parseFor(depth: number): void {
+    this.next(); // FOR
+    const vt = this.peek();
+    let varType: M2Type = UNKNOWN;
+    let varOk = false;
+    if (vt?.kind === 'ident') {
+      this.next();
+      const info = this.resolveHead(vt);
+      if (info.kind === 'var') {
+        varType = resolveDeclaredType(info.sym, info.host, info.dir, vt.line, vt.ch);
+        if (varType.kind !== 'unknown' && !isOrdinal(varType)) {
+          this.issue(vt, `FOR loop variable must have an ordinal type, found ${displayType(varType)}`);
+        } else {
+          varOk = true;
+        }
+      } else if (info.kind === 'const' || info.kind === 'literal') {
+        this.issue(vt, `Cannot assign to constant "${vt.text}"`);
+      } else if (info.kind === 'proc') {
+        this.issue(vt, `Cannot assign to procedure "${vt.text}"`);
+      } else if (info.kind === 'type' || info.kind === 'builtinType') {
+        this.issue(vt, `"${vt.text}" is a type and cannot be used as a variable`);
+      } else if (info.kind === 'module') {
+        this.issue(vt, `"${vt.text}" is a module and cannot be used as a variable`);
+      }
+    }
+    if (this.atSym(':=')) {
+      const op = this.next()!;
+      const a = this.parseExpr(0);
+      if (varOk && varType.kind !== 'unknown' && a.type.kind !== 'unknown' && !isAssignable(varType, a.type)) {
+        this.issue(op, `Cannot assign ${displayType(a.type)} to ${displayType(varType)}`);
+      }
+    }
+    this.expectKw('TO');
+    const b = this.parseExpr(0);
+    if (varOk && varType.kind !== 'unknown' && b.type.kind !== 'unknown' && !isAssignable(varType, b.type)) {
+      this.issue(vt!, `Cannot assign ${displayType(b.type)} to ${displayType(varType)}`);
+    }
+    if (this.atKw('BY')) {
+      const by = this.next()!;
+      const c = this.parseExpr(0);
+      if (c.type.kind !== 'unknown' && c.type.kind !== 'whole') {
+        this.issue(by, `BY step must be a whole number, found ${displayType(c.type)}`);
+      }
+    }
+    this.expectKw('DO');
+    this.parseSeq(new Set(['END']), depth + 1);
+    this.expectKw('END');
+  }
+
+  /** WITH blocks use unqualified field access: skip without checks (conservative). */
+  private parseWith(): void {
+    this.next(); // WITH
+    let depth = 0;
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (t.kind === 'symbol') {
+        if (t.text === ';' && depth === 0) return;
+        if (t.text === '[' || t.text === '(' || t.text === '{') depth++;
+        else if (t.text === ']' || t.text === ')' || t.text === '}') depth = Math.max(0, depth - 1);
+        this.next();
+      } else if (t.kind === 'keyword' && t.text === 'DO' && depth === 0) {
+        this.next();
+        break;
+      } else if (t.kind === 'keyword') {
+        return;
+      } else {
+        this.next();
+      }
+    }
+    let nest = 1;
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (t.kind === 'keyword' && OPENERS.has(t.text)) nest++;
+      else if (t.kind === 'keyword' && t.text === 'END') {
+        nest--;
+        this.next();
+        if (nest === 0) return;
+        continue;
+      }
+      this.next();
+    }
+  }
+
+  private parseCase(depth: number): void {
+    this.next(); // CASE
+    const start = this.peek();
+    const sel = this.parseExpr(0);
+    if (sel.type.kind !== 'unknown' && !isOrdinal(sel.type)) {
+      this.issue(start ?? this.toks[this.pos - 1], `CASE selector must have an ordinal type, found ${displayType(sel.type)}`);
+    }
+    this.expectKw('OF');
+    for (;;) {
+      const t = this.peek();
+      if (!t) return;
+      if (t.kind === 'keyword' && t.text === 'END') { this.next(); return; }
+      if (t.kind === 'keyword' && t.text === 'ELSE') {
+        this.next();
+        this.parseSeq(new Set(['END']), depth + 1);
+        this.expectKw('END');
+        return;
+      }
+      // Skip case labels up to ':'.
+      let bdepth = 0;
+      for (;;) {
+        const u = this.peek();
+        if (!u) return;
+        if (u.kind === 'symbol') {
+          if (u.text === '[' || u.text === '(' || u.text === '{') bdepth++;
+          else if (u.text === ']' || u.text === ')' || u.text === '}') {
+            if (bdepth === 0) break;
+            bdepth--;
+          } else if (u.text === ':' && bdepth === 0) break;
+          this.next();
+        } else if (u.kind === 'keyword') {
+          break;
+        } else {
+          this.next();
+        }
+      }
+      if (this.atSym(':')) this.next();
+      this.parseSeq(new Set(['|', 'ELSE', 'END']), depth + 1);
+      if (this.atSym('|')) { this.next(); continue; }
+      const v = this.peek();
+      if (v?.kind === 'keyword' && v.text === 'ELSE') continue;
+      if (v?.kind === 'keyword' && v.text === 'END') { this.next(); return; }
+      return;
+    }
+  }
+
+  // ---- Designators, calls, expressions ----
+
+  private resolveHead(t: Token): HeadInfo {
+    if (t.text === 'TRUE' || t.text === 'FALSE') return { kind: 'literal', type: { kind: 'bool' } };
+    if (t.text === 'NIL') return { kind: 'literal', type: { kind: 'nil' } };
+    const r = resolveName(this.ctx.unit, this.ctx.docDir, t.line, t.ch, null, t.text);
+    if (r) {
+      const sameFile = r.filePath === this.ctx.unit.filePath;
+      const host = sameFile ? this.ctx.unit : unitOfFile(r.filePath) ?? this.ctx.unit;
+      const dir = sameFile ? this.ctx.docDir : path.dirname(r.filePath);
+      switch (r.sym.kind) {
+        case 'variable': case 'parameter': return { kind: 'var', sym: r.sym, host, dir };
+        case 'procedure': return { kind: 'proc', sym: r.sym, host, dir };
+        case 'constant': return { kind: 'const', sym: r.sym, host, dir };
+        case 'type': return { kind: 'type' };
+        case 'module': return { kind: 'module', name: r.sym.name };
+        case 'field': return { kind: 'field' };
+      }
+    }
+    if (TYPE_BUILTINS.has(t.text)) return { kind: 'builtinType', name: t.text };
+    if (PROC_BUILTINS.has(t.text)) return { kind: 'builtinProc', name: t.text };
+    return { kind: 'unknown' };
+  }
+
+  private parseDesignatorStatement(): void {
+    const head = this.next()!;
+    const d = this.parseDesignatorRest(head, 'stmt');
+    if (this.atSym(':=')) {
+      const op = this.next()!;
+      this.checkAssignment(d, op);
+    }
+  }
+
+  /** Parse qualifier chain, postfixes and an optional call. Emits call/operand issues. */
+  private parseDesignatorRest(head: Token, mode: 'expr' | 'stmt'): Value {
+    let info = this.resolveHead(head);
+    let tok = head;
+    // Qualifier chain: M.X or r.field (unknown-typed beyond the head).
+    while (this.atSym('.')) {
+      this.next(); // .
+      const nm = this.peek();
+      if (!nm || nm.kind !== 'ident') break;
+      this.next();
+      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; }
+        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;
+          case 'constant': info = { kind: 'const', sym: q.sym, host: q.host, dir: q.dir }; break;
+          case 'type': info = { kind: 'type' }; break;
+          default: info = { kind: 'unknown' }; break;
+        }
+      } else {
+        info = { kind: 'field' };
+      }
+    }
+    // Postfix: indexing and dereferencing.
+    let type = this.valueType(info, tok);
+    for (;;) {
+      if (this.atSym('[')) {
+        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)) {
+              this.issue(open, `Array index must have an ordinal type, found ${displayType(it)}`);
+              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()!;
+        if (type.kind === 'pointer') {
+          type = type.target;
+        } else if (type.kind !== 'unknown') {
+          this.issue(op, `Cannot dereference ${displayType(type)}`);
+          type = UNKNOWN;
+        }
+      } else {
+        break;
+      }
+    }
+    // Optional call.
+    if (this.atSym('(')) {
+      const call = this.parseCall(info, tok);
+      if (mode === 'stmt') {
+        if (call.returnsValue) {
+          this.issue(tok, `Return value of function "${nameOf(info, tok)}" is ignored`);
+        }
+        return { type: UNKNOWN, info, tok };
+      }
+      if (call.isVoid) {
+        this.issue(tok, `Procedure "${nameOf(info, tok)}" does not return a value`);
+        return { type: UNKNOWN, info, tok };
+      }
+      type = call.type;
+    } else if (mode === 'expr') {
+      if (info.kind === 'proc') {
+        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 = UNKNOWN;
+      } else if (info.kind === 'module') {
+        this.issue(tok, `"${tok.text}" is a module and cannot be used as a value`);
+        type = UNKNOWN;
+      }
+    } else {
+      // Statement without call or assignment: bare procedure call `P;`.
+      // (When ':=' follows, the assignment path reports the error instead.)
+      if (info.kind === 'proc' && !this.atSym(':=')) {
+        const checked = this.checkCall(info, [], tok);
+        if (!checked.isVoid) this.issue(tok, `Return value of function "${info.sym.name}" is ignored`);
+      }
+    }
+    return { type, info, tok };
+  }
+
+  private valueType(info: HeadInfo, tok: Token): M2Type {
+    switch (info.kind) {
+      case 'var': return resolveDeclaredType(info.sym, info.host, info.dir, tok.line, tok.ch);
+      case 'const': return resolveDeclaredType(info.sym, info.host, info.dir, tok.line, tok.ch);
+      case 'literal': return info.type;
+      default: return UNKNOWN;
+    }
+  }
+
+  private parseCall(info: HeadInfo, tok: Token): { type: M2Type; isVoid: boolean; returnsValue: boolean } {
+    this.next(); // (
+    const args = this.parseExprList();
+    if (this.atSym(')')) this.next();
+    if (info.kind === 'proc') return this.checkCall(info, args, tok);
+    // Builtin procedures, casts through type names and unknown callees: unchecked.
+    if (info.kind === 'builtinProc' || info.kind === 'type' || info.kind === 'builtinType' ||
+        info.kind === 'unknown' || info.kind === 'field') {
+      return { type: UNKNOWN, isVoid: false, returnsValue: false };
+    }
+    this.issue(tok, `"${tok.text}" is not a procedure`);
+    return { type: UNKNOWN, isVoid: false, returnsValue: false };
+  }
+
+  private checkCall(
+    info: { kind: 'proc'; sym: M2Symbol; host: M2Unit; dir: string },
+    args: M2Type[], tok: Token,
+  ): { type: M2Type; isVoid: boolean; returnsValue: boolean } {
+    const params = info.sym.children.filter(c => c.kind === 'parameter');
+    const required = params.filter(p => !/\(optional\)\s*$/.test(p.detail)).length;
+    if (args.length < required || args.length > params.length) {
+      const want = required === params.length
+        ? `${params.length} argument${params.length === 1 ? '' : 's'}`
+        : `${required}..${params.length} arguments`;
+      this.issue(tok, `Procedure "${info.sym.name}" expects ${want} but ${args.length} given`);
+    }
+    for (let k = 0; k < Math.min(args.length, params.length); k++) {
+      const p = params[k];
+      const pt = this.paramType(p);
+      if (pt.kind !== 'unknown' && args[k].kind !== 'unknown' && !isArrayArgCompatible(pt, args[k])) {
+        this.issue(tok, `Argument ${k + 1} of "${info.sym.name}": cannot assign ${displayType(args[k])} to ${displayType(pt)}`);
+      }
+    }
+    const ret = returnTypeOf(info.sym, info.host, info.dir);
+    if (!ret) return { type: UNKNOWN, isVoid: true, returnsValue: false };
+    return { type: ret, isVoid: false, returnsValue: true };
+  }
+
+  private paramType(p: M2Symbol): M2Type {
+    const clean = p.detail.replace(/\(optional\)\s*$/, '').trim();
+    const colon = clean.indexOf(':');
+    if (colon < 0) return UNKNOWN;
+    return resolveType(
+      parseTypeText(clean.slice(colon + 1)),
+      this.ctx.unit, this.ctx.docDir, p.nameRange.startLine, p.nameRange.startCh,
+    );
+  }
+
+  private checkAssignment(d: Value, op: Token): void {
+    const rhs = this.parseExpr(0);
+    const info = d.info;
+    if (info.kind === 'const' || info.kind === 'literal') {
+      this.issue(d.tok, `Cannot assign to constant "${d.tok.text}"`);
+      return;
+    }
+    if (info.kind === 'proc') {
+      this.issue(d.tok, `Cannot assign to procedure "${d.tok.text}"`);
+      return;
+    }
+    if (info.kind === 'type' || info.kind === 'builtinType') {
+      this.issue(d.tok, `"${d.tok.text}" is a type and cannot be assigned to`);
+      return;
+    }
+    if (info.kind === 'module') {
+      this.issue(d.tok, `"${d.tok.text}" is a module and cannot be assigned to`);
+      return;
+    }
+    if (info.kind === 'unknown' || info.kind === 'field' || info.kind === 'builtinProc') return;
+    const lhs = d.type;
+    if (lhs.kind !== 'unknown' && rhs.type.kind !== 'unknown' && !isAssignable(lhs, rhs.type)) {
+      this.issue(op, `Cannot assign ${displayType(rhs.type)} to ${displayType(lhs)}`);
+    }
+  }
+
+  private parseExprList(): M2Type[] {
+    const out: M2Type[] = [];
+    if (this.atSym(')') || this.atSym(']')) return out;
+    for (;;) {
+      const t = this.peek();
+      // NOT can open an expression; any other keyword ends the list.
+      if (!t || t.kind === 'eof' || (t.kind === 'keyword' && t.text !== 'NOT') ||
+          (t.kind === 'symbol' && (t.text === ')' || t.text === ']' || t.text === ';'))) break;
+      out.push(this.parseExpr(0).type);
+      if (this.atSym(',')) { this.next(); continue; }
+      break;
+    }
+    return out;
+  }
+
+  // ---- Expressions (precedence climbing) ----
+
+  private parseExpr(depth: number): Value {
+    let left = this.parseSimple(depth);
+    for (;;) {
+      const op = this.peek();
+      if (!op || op.kind !== 'symbol' || !['=', '#', '<', '<=', '>', '>='].includes(op.text)) break;
+      const ordering = op.text !== '=' && op.text !== '#';
+      this.next();
+      const right = this.parseSimple(depth);
+      if (left.type.kind !== 'unknown' && right.type.kind !== 'unknown' &&
+          !isComparable(left.type, right.type, ordering)) {
+        this.issue(op, `Cannot compare ${displayType(left.type)} and ${displayType(right.type)}`);
+      }
+      left = { type: { kind: 'bool' }, info: { kind: 'unknown' }, tok: op };
+    }
+    // Membership: result BOOLEAN, operands unchecked (set element types are unknown).
+    if (this.atKw('IN')) {
+      this.next();
+      this.parseSimple(depth);
+      left = { type: { kind: 'bool' }, info: { kind: 'unknown' }, tok: left.tok };
+    }
+    return left;
+  }
+
+  private parseSimple(depth: number): Value {
+    const t = this.peek();
+    if (t?.kind === 'symbol' && (t.text === '+' || t.text === '-')) {
+      const op = this.next()!;
+      const e = this.parseSimple(depth);
+      if (e.type.kind !== 'unknown' && arithKind(e.type) !== 'whole' && arithKind(e.type) !== 'real') {
+        this.issue(op, `Cannot apply "${op.text}" to ${displayType(e.type)}`);
+      }
+      return { type: e.type, info: e.info, tok: op };
+    }
+    let left = this.parseTerm(depth);
+    for (;;) {
+      const op = this.peek();
+      if (!op) break;
+      if (op.kind === 'keyword' && op.text === 'OR') {
+        this.next();
+        const right = this.parseTerm(depth);
+        this.checkBoolOperands(op, left.type, right.type);
+        left = { type: { kind: 'bool' }, info: { kind: 'unknown' }, tok: op };
+      } else if (op.kind === 'symbol' && (op.text === '+' || op.text === '-')) {
+        this.next();
+        const right = this.parseTerm(depth);
+        left = { type: this.arithResult(op, left.type, right.type), info: { kind: 'unknown' }, tok: op };
+      } else {
+        break;
+      }
+    }
+    return left;
+  }
+
+  private parseTerm(depth: number): Value {
+    let left = this.parseFactor(depth);
+    for (;;) {
+      const op = this.peek();
+      if (!op) break;
+      if (op.kind === 'keyword' && op.text === 'AND') {
+        this.next();
+        const right = this.parseFactor(depth);
+        this.checkBoolOperands(op, left.type, right.type);
+        left = { type: { kind: 'bool' }, info: { kind: 'unknown' }, tok: op };
+      } else if (op.kind === 'symbol' && (op.text === '*' || op.text === '/')) {
+        this.next();
+        const right = this.parseFactor(depth);
+        left = { type: this.arithResult(op, left.type, right.type), info: { kind: 'unknown' }, tok: op };
+      } else if (op.kind === 'keyword' && (op.text === 'DIV' || op.text === 'MOD' || op.text === 'REM')) {
+        this.next();
+        const right = this.parseFactor(depth);
+        left = { type: this.divResult(op, left.type, right.type), info: { kind: 'unknown' }, tok: op };
+      } else {
+        break;
+      }
+    }
+    return left;
+  }
+
+  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 };
+    }
+    this.issue(op, `Cannot apply "${op.text}" to ${displayType(a)} and ${displayType(b)}`);
+    return UNKNOWN;
+  }
+
+  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 };
+    }
+    this.issue(op, `Operator "${op.text}" requires whole-number operands`);
+    return UNKNOWN;
+  }
+
+  private checkBoolOperands(op: Token, a: M2Type, b: M2Type): void {
+    for (const t of [a, b]) {
+      if (t.kind !== 'unknown' && t.kind !== 'bool') {
+        this.issue(op, `Operator "${op.text}" requires BOOLEAN operands, found ${displayType(t)}`);
+        return;
+      }
+    }
+  }
+
+  private parseFactor(depth: number): Value {
+    const t = this.peek();
+    if (!t) return { type: UNKNOWN, info: { kind: 'unknown' }, tok: this.toks[this.pos - 1] };
+    if (t.kind === 'keyword' && t.text === 'NOT') {
+      this.next();
+      const e = this.parseFactor(depth);
+      if (e.type.kind !== 'unknown' && e.type.kind !== 'bool') {
+        this.issue(t, `Operator "NOT" requires a BOOLEAN operand, found ${displayType(e.type)}`);
+      }
+      return { type: { kind: 'bool' }, info: { kind: 'unknown' }, tok: t };
+    }
+    if (t.kind === 'symbol' && (t.text === '+' || t.text === '-')) {
+      const op = this.next()!;
+      const e = this.parseFactor(depth);
+      if (e.type.kind !== 'unknown' && arithKind(e.type) !== 'whole' && arithKind(e.type) !== 'real') {
+        this.issue(op, `Cannot apply "${op.text}" to ${displayType(e.type)}`);
+      }
+      return { type: e.type, info: e.info, tok: op };
+    }
+    if (t.kind === 'symbol' && t.text === '(') {
+      this.next();
+      if (depth > MAX_EXPR_DEPTH) {
+        this.skipBalancedRest();
+        return { type: UNKNOWN, info: { kind: 'unknown' }, tok: t };
+      }
+      const e = this.parseExpr(depth + 1);
+      if (this.atSym(')')) this.next();
+      return { type: e.type, info: e.info, tok: t };
+    }
+    if (t.kind === 'symbol' && t.text === '{') {
+      this.next();
+      this.parseSetConstructor();
+      return { type: { kind: 'setLit' }, info: { kind: 'unknown' }, tok: t };
+    }
+    if (t.kind === 'number') {
+      this.next();
+      return {
+        type: /[.eE]/.test(t.text)
+          ? { kind: 'real', id: 'REAL', literal: true }
+          : { kind: 'whole', id: 'INTEGER', literal: true },
+        info: { kind: 'unknown' }, tok: t,
+      };
+    }
+    if (t.kind === 'string') {
+      this.next();
+      const inner = t.text.length >= 2 ? t.text.slice(1, -1) : '';
+      return { type: { kind: 'stringLit', length: inner.length }, info: { kind: 'unknown' }, tok: t };
+    }
+    if (t.kind === 'ident') {
+      const head = this.next()!;
+      return this.parseDesignatorRest(head, 'expr');
+    }
+    // Anything else cannot start an operand: stop without consuming.
+    return { type: UNKNOWN, info: { kind: 'unknown' }, tok: t };
+  }
+
+  private parseSetConstructor(): void {
+    for (;;) {
+      const t = this.peek();
+      if (!t || (t.kind === 'symbol' && t.text === '}')) break;
+      this.parseExpr(0);
+      if (this.atSym('..')) {
+        this.next();
+        this.parseExpr(0);
+      }
+      if (this.atSym(',')) { this.next(); continue; }
+      break;
+    }
+    if (this.atSym('}')) this.next();
+  }
+
+  private skipBalancedRest(): void {
+    let depth = 1;
+    while (this.pos < this.end) {
+      const t = this.next();
+      if (!t || t.kind === 'eof') return;
+      if (t.kind === 'symbol' && t.text === '(') depth++;
+      else if (t.kind === 'symbol' && t.text === ')') {
+        depth--;
+        if (depth === 0) return;
+      }
+    }
+  }
+}
+
+function nameOf(info: HeadInfo, tok: Token): string {
+  if (info.kind === 'proc') return info.sym.name;
+  return tok.text;
+}

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

@@ -27,6 +27,8 @@ export interface M2Symbol {
   nameRange: M2Range;
   /** Full extent (for containers: up to the closing END). */
   extent: M2Range;
+  /** Statement body range (BEGIN..END) for modules/procedures with bodies. */
+  body?: M2Range;
   children: M2Symbol[];
 }
 
@@ -513,8 +515,10 @@ class Parser {
   private parseBlock(parent: M2Symbol, unit: M2Unit, _isModule: boolean): void {
     this.parseDeclarations(parent, unit);
     if (this.atKw('BEGIN')) {
-      this.next();
+      const open = this.next();
       this.skipToMatchingEnd();
+      const close = this.prevTok();
+      parent.body = this.span(open, close);
     }
     if (this.atKw('FINALLY')) {
       this.next();

+ 1 - 1
extensions/modula2-language/src/m2/resolve.ts

@@ -117,7 +117,7 @@ export function loadDefModule(dir: string, moduleName: string): M2Unit | null {
   }
 }
 
-function topLevel(unit: M2Unit, name: string): M2Symbol | null {
+export function topLevel(unit: M2Unit, name: string): M2Symbol | null {
   for (const s of unit.symbols) {
     for (const c of s.children) {
       if (c.name === name && c.kind !== 'module') return c;

+ 506 - 0
extensions/modula2-language/src/m2/types.ts

@@ -0,0 +1,506 @@
+/** Modula-2 type model for semantic checking, calibrated against gm2.
+ *
+ *  Rules encoded here were verified against the GNU Modula-2 compiler:
+ *  - whole-number types assign freely (`INTEGER` ↔ `CARDINAL`, literals);
+ *  - comparisons are stricter: `CARDINAL` ≠ `INTEGER`, but literals and
+ *    subranges adapt, and aliases are fully transparent;
+ *  - `REAL` ↔ `LONGREAL` assign freely but do not compare;
+ *  - no implicit whole ↔ real conversion, no mixed whole/real arithmetic;
+ *  - `/` on whole operands yields a whole type (as gm2 reports it);
+ *  - set operators (`+ - * /`) work on `BITSET`/set constructors;
+ *  - arrays and records cannot be compared; strings compare and order;
+ *  - a length-1 string is a `CHAR`; longer strings assign to fitting
+ *    `ARRAY OF CHAR` only.
+ *
+ *  Anything unresolvable is `unknown`, which is compatible with everything:
+ *  the checker reports errors, never guesses.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { lex, Token } from './lexer';
+import { M2Symbol, M2Unit, parseUnitText } from './parser';
+import { loadDefModule, resolveName, topLevel } from './resolve';
+
+export type M2Type =
+  | { kind: 'unknown' }
+  | { kind: 'named', name: string, qualifier: string | null }
+  | { kind: 'whole', id: string, literal: boolean }
+  | { kind: 'real', id: string, literal: boolean }
+  | { kind: 'char' }
+  | { kind: 'stringLit', length: number }
+  | { kind: 'bool' }
+  | { kind: 'bitset' }
+  | { kind: 'setNamed', name: string }
+  | { kind: 'setLit' }
+  | { kind: 'array', element: M2Type, size: number | null, name: string | null }
+  | { kind: 'record', name: string | null, textKey: string }
+  | { kind: 'pointer', target: M2Type, name: string | null }
+  | { kind: 'enum', name: string }
+  | { kind: 'subrange', base: M2Type }
+  | { kind: 'proc' }
+  | { kind: 'nil' };
+
+export const UNKNOWN: M2Type = { kind: 'unknown' };
+
+const WHOLE_TYPES = new Set([
+  'INTEGER', 'CARDINAL', 'LONGINT', 'SHORTINT', 'LONGCARD', 'SHORTCARD',
+  'WORD', 'BYTE', 'ADDRESS', 'OCTET', 'LOC',
+]);
+const REAL_TYPES = new Set(['REAL', 'LONGREAL']);
+
+/** Parse a type/value fragment (declaration detail text) into a possibly-named type. */
+export function parseTypeText(fragment: string): M2Type {
+  const clean = fragment.replace(/<\*.*?\*>/g, ' ');
+  const tokens = lex(clean).filter(t => t.kind !== 'eof');
+  const p = new TypeParser(tokens);
+  const t = p.parseType();
+  return t ?? UNKNOWN;
+}
+
+class TypeParser {
+  private pos = 0;
+  constructor(private tokens: Token[]) {}
+
+  private peek(off = 0): Token | null {
+    return this.tokens[this.pos + off] ?? null;
+  }
+
+  private next(): Token | null {
+    const t = this.peek();
+    if (t) this.pos++;
+    return t;
+  }
+
+  private atKw(word: string): boolean {
+    const t = this.peek();
+    return !!t && t.kind === 'keyword' && t.text === word;
+  }
+
+  private atSym(sym: string): boolean {
+    const t = this.peek();
+    return !!t && t.kind === 'symbol' && t.text === sym;
+  }
+
+  parseType(): M2Type | null {
+    const t = this.peek();
+    if (!t) return null;
+    if (t.kind === 'keyword') {
+      switch (t.text) {
+        case 'ARRAY': return this.parseArray();
+        case 'RECORD': return this.parseRecord();
+        case 'POINTER': return this.parsePointer();
+        case 'SET': return this.parseSet();
+        case 'PROCEDURE': return this.parseProcType();
+      }
+      return null;
+    }
+    if (t.kind === 'number') {
+      this.next();
+      return /[.eE]/.test(t.text)
+        ? { kind: 'real', id: 'REAL', literal: true }
+        : { kind: 'whole', id: 'INTEGER', literal: true };
+    }
+    if (t.kind === 'string') {
+      this.next();
+      const inner = t.text.length >= 2 ? t.text.slice(1, -1) : '';
+      return { kind: 'stringLit', length: inner.length };
+    }
+    if (t.kind === 'symbol' && t.text === '(') return this.parseEnum();
+    if (t.kind === 'symbol' && t.text === '[') return this.parseSubrange();
+    if (t.kind === 'ident') return this.parseNamed();
+    return null;
+  }
+
+  private parseNamed(): M2Type {
+    const name = this.next()!.text;
+    if (name === 'TRUE' || name === 'FALSE') return { kind: 'bool' };
+    if (name === 'NIL') return { kind: 'nil' };
+    if (WHOLE_TYPES.has(name)) return { kind: 'whole', id: name, literal: false };
+    if (REAL_TYPES.has(name)) return { kind: 'real', id: name, literal: false };
+    if (name === 'CHAR') return { kind: 'char' };
+    if (name === 'BOOLEAN') return { kind: 'bool' };
+    if (name === 'BITSET') return { kind: 'bitset' };
+    let qualifier: string | null = null;
+    let id = name;
+    if (this.atSym('.')) {
+      this.next();
+      const n = this.peek();
+      if (n?.kind === 'ident') { qualifier = name; id = this.next()!.text; }
+    }
+    return { kind: 'named', name: id, qualifier };
+  }
+
+  private parseArray(): M2Type {
+    this.next(); // ARRAY
+    let size: number | null = null;
+    if (this.atSym('[')) {
+      size = this.parseBounds();
+      if (!this.atKw('OF')) return UNKNOWN;
+      this.next();
+    } else if (this.atKw('OF')) {
+      this.next(); // open array: ARRAY OF T
+    } else {
+      return UNKNOWN;
+    }
+    const element = this.parseType() ?? UNKNOWN;
+    return { kind: 'array', element, size, name: null };
+  }
+
+  /** Consume one or more `[...]` bound lists; size only for a single numeric range. */
+  private parseBounds(): number | null {
+    let size: number | null = null;
+    let count = 0;
+    while (this.atSym('[')) {
+      this.next(); // [
+      // Try a simple numeric [lo..hi].
+      const lo = this.peek();
+      const mid = this.peek(1);
+      const hi = this.peek(2);
+      const close = this.peek(3);
+      if (lo?.kind === 'number' && mid?.text === '..' && hi?.kind === 'number' &&
+          close?.kind === 'symbol' && close.text === ']' &&
+          /^\d+$/.test(lo.text) && /^\d+$/.test(hi.text)) {
+        if (count === 0) size = Number(hi.text) - Number(lo.text) + 1;
+        this.pos += 4;
+      } else {
+        this.skipBalancedRest('[', ']');
+      }
+      count++;
+    }
+    return count === 1 ? size : null;
+  }
+
+  private parseRecord(): M2Type {
+    this.next(); // RECORD
+    let depth = 1;
+    while (this.pos < this.tokens.length && depth > 0) {
+      const t = this.next()!;
+      if (t.kind === 'keyword' && t.text === 'RECORD') depth++;
+      else if (t.kind === 'keyword' && t.text === 'END') depth--;
+    }
+    return { kind: 'record', name: null, textKey: '' };
+  }
+
+  private parsePointer(): M2Type {
+    this.next(); // POINTER
+    if (this.atKw('TO')) this.next();
+    const target = this.parseType() ?? UNKNOWN;
+    return { kind: 'pointer', target, name: null };
+  }
+
+  private parseSet(): M2Type {
+    this.next(); // SET
+    if (this.atKw('OF')) this.next();
+    this.parseType();
+    return { kind: 'setNamed', name: '' };
+  }
+
+  private parseProcType(): M2Type {
+    this.next(); // PROCEDURE
+    if (this.atSym('(')) this.skipBalancedRest('(', ')');
+    if (this.atSym(':')) { this.next(); this.parseType(); }
+    return { kind: 'proc' };
+  }
+
+  private parseEnum(): M2Type {
+    this.next(); // (
+    let ok = true;
+    let count = 0;
+    while (this.pos < this.tokens.length) {
+      const t = this.next()!;
+      if (t.kind === 'symbol' && t.text === ')') return ok && count > 0 ? { kind: 'enum', name: '' } : UNKNOWN;
+      if (t.kind === 'ident') {
+        count++;
+        if (!this.atSym(',') && !this.atSym(')')) ok = false;
+        if (this.atSym(',')) this.next();
+      } else if (t.kind === 'symbol' && t.text === ',') {
+        continue;
+      } else {
+        ok = false;
+      }
+    }
+    return UNKNOWN;
+  }
+
+  private parseSubrange(): M2Type {
+    this.next(); // [
+    const lo = this.parseType();
+    let base: M2Type = UNKNOWN;
+    if (lo) {
+      if (lo.kind === 'whole' || lo.kind === 'real') base = lo;
+      else if (lo.kind === 'char' || lo.kind === 'stringLit') base = { kind: 'char' };
+      else if (lo.kind === 'enum') base = lo;
+      else if (lo.kind === 'bool') base = { kind: 'bool' };
+    }
+    // Skip to the closing bracket (tolerates '..' and the upper bound).
+    let depth = 1;
+    while (this.pos < this.tokens.length && depth > 0) {
+      const t = this.next()!;
+      if (t.kind === 'symbol' && t.text === '[') depth++;
+      else if (t.kind === 'symbol' && t.text === ']') depth--;
+    }
+    return { kind: 'subrange', base };
+  }
+
+  private skipBalancedRest(open: string, close: string): void {
+    let depth = 0;
+    while (this.pos < this.tokens.length) {
+      const t = this.next()!;
+      if (t.kind === 'symbol' && t.text === open) depth++;
+      else if (t.kind === 'symbol' && t.text === close) {
+        if (depth === 0) return;
+        depth--;
+      }
+    }
+  }
+}
+
+/** Resolve aliases, subrange bases and imports to a final type. */
+export function resolveDeclaredType(
+  sym: M2Symbol, hostUnit: M2Unit, hostDir: string, line: number, ch: number,
+): M2Type {
+  const detail = sym.detail;
+  if (sym.kind === 'constant' && /^\w+\.\w+$/.test(detail)) {
+    // Enumeration literal recorded as `Type.Literal`.
+    return { kind: 'enum', name: detail.slice(0, detail.indexOf('.')) };
+  }
+  if (sym.kind === 'variable' || sym.kind === 'parameter') {
+    const colon = detail.indexOf(':');
+    if (colon < 0) return UNKNOWN;
+    return resolveT(parseTypeText(detail.slice(colon + 1)), hostUnit, hostDir, line, ch, new Set());
+  }
+  if (sym.kind === 'constant') {
+    const eq = detail.indexOf('=');
+    if (eq < 0) return UNKNOWN;
+    return resolveT(parseTypeText(detail.slice(eq + 1)), hostUnit, hostDir, line, ch, new Set());
+  }
+  return UNKNOWN;
+}
+
+/** Declared return type of a procedure symbol (`PROCEDURE F: T` detail). */
+export function returnTypeOf(sym: M2Symbol, hostUnit: M2Unit, hostDir: string): M2Type | null {
+  const m = /^PROCEDURE\s+\w+\s*:\s*(.+)$/.exec(sym.detail);
+  if (!m) return null;
+  return resolveT(parseTypeText(m[1]), hostUnit, hostDir, sym.nameRange.startLine, sym.nameRange.startCh, new Set());
+}
+
+/** Resolve a parsed type fragment in scope (fresh cycle guard). */
+export function resolveType(t: M2Type, unit: M2Unit, dir: string, line: number, ch: number): M2Type {
+  return resolveT(t, unit, dir, line, ch, new Set());
+}
+
+function resolveT(t: M2Type, unit: M2Unit, dir: string, line: number, ch: number, seen: Set<string>): M2Type {
+  if (t.kind === 'subrange') {
+    return { kind: 'subrange', base: resolveT(t.base, unit, dir, line, ch, seen) };
+  }
+  if (t.kind !== 'named') {
+    switch (t.kind) {
+      case 'array': return { ...t, element: resolveT(t.element, unit, dir, line, ch, seen) };
+      case 'pointer': return { ...t, target: resolveT(t.target, unit, dir, line, ch, seen) };
+      default: return t;
+    }
+  }
+  const key = `${t.qualifier ?? ''}.${t.name}`;
+  if (seen.has(key)) return UNKNOWN;
+  const resolved = resolveName(unit, dir, line, ch, t.qualifier, t.name);
+  if (!resolved || resolved.sym.kind !== 'type') return UNKNOWN;
+  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;
+  seen.add(key);
+  const inner = resolveT(
+    parseTypeText(detail.slice(eq + 1)), host, path.dirname(resolved.filePath),
+    resolved.sym.nameRange.startLine, resolved.sym.nameRange.startCh, seen,
+  );
+  return attachName(inner, t.qualifier ? `${t.qualifier}.${t.name}` : t.name);
+}
+
+/** Attach the ultimate type name so nominal comparison works through aliases. */
+function attachName(t: M2Type, name: string): M2Type {
+  switch (t.kind) {
+    case 'array': return { ...t, name };
+    case 'record': return { ...t, name };
+    case 'pointer': return { ...t, name };
+    case 'enum': return { ...t, name: t.name || name };
+    case 'setNamed': return { ...t, name: t.name || name };
+    default: return t;
+  }
+}
+
+const fileUnitCache = new Map<string, { mtimeMs: number; unit: M2Unit }>();
+
+export function unitOfFile(filePath: string): M2Unit | null {
+  try {
+    const st = fs.statSync(filePath);
+    const hit = fileUnitCache.get(filePath);
+    if (hit && hit.mtimeMs === st.mtimeMs) return hit.unit;
+    const unit = parseUnitText(fs.readFileSync(filePath, 'utf8'), filePath);
+    fileUnitCache.set(filePath, { mtimeMs: st.mtimeMs, unit });
+    return unit;
+  } catch {
+    return null;
+  }
+}
+
+export function sameNominal(a: string | null, b: string | null): boolean {
+  return !!a && !!b && a === b;
+}
+
+/** Assignment compatibility (miss-safe: unknown is compatible with everything). */
+export function isAssignable(to: M2Type, from: M2Type): boolean {
+  if (to.kind === 'unknown' || from.kind === 'unknown') return true;
+  const t = stripSub(to);
+  const f = stripSub(from);
+  if (t.kind === 'whole' && f.kind === 'whole') return true;
+  if (t.kind === 'real' && f.kind === 'real') return true;
+  if (t.kind === 'char' && f.kind === 'char') return true;
+  if (t.kind === 'char' && f.kind === 'stringLit' && f.length === 1) return true;
+  if (t.kind === 'array' && f.kind === 'stringLit') {
+    if (t.element.kind !== 'char' && t.element.kind !== 'unknown') return false;
+    return t.size === null || f.length <= t.size;
+  }
+  if (t.kind === 'array' && f.kind === 'array') {
+    if (t.name || f.name) return sameNominal(t.name ?? null, f.name ?? null);
+    return normKey(t) === normKey(f);
+  }
+  if (t.kind === 'record' && f.kind === 'record') {
+    if (t.name || f.name) return sameNominal(t.name ?? null, f.name ?? null);
+    return normKey(t) === normKey(f);
+  }
+  if (t.kind === 'pointer' && f.kind === 'nil') return true;
+  if (t.kind === 'pointer' && f.kind === 'pointer') return typeKey(t.target) === typeKey(f.target);
+  if (t.kind === 'proc' && f.kind === 'nil') return true;
+  if (t.kind === 'proc' && f.kind === 'proc') return true;
+  if (t.kind === 'enum' && f.kind === 'enum') return t.name === f.name;
+  if (t.kind === 'bool' && f.kind === 'bool') return true;
+  if (t.kind === 'bitset' && (f.kind === 'bitset' || f.kind === 'setLit')) return true;
+  if (t.kind === 'setNamed' && (f.kind === 'setLit' || f.kind === 'bitset')) return true;
+  if (t.kind === 'setNamed' && f.kind === 'setNamed') return t.name === f.name;
+  return false;
+}
+
+/** Open-array formal accepts a fixed array with compatible elements. */
+export function isArrayArgCompatible(param: M2Type, arg: M2Type): boolean {
+  if (param.kind === 'array' && param.size === null && arg.kind === 'array') {
+    return isAssignable(param.element, arg.element);
+  }
+  return isAssignable(param, arg);
+}
+
+/** `= #` and ordering compatibility (miss-safe). Ordering flag restricts operators. */
+export function isComparable(a: M2Type, b: M2Type, ordering: boolean): boolean {
+  if (a.kind === 'unknown' || b.kind === 'unknown') return true;
+  if (a.kind === 'nil' && b.kind === 'nil') return true;
+  const sa = subInfo(a);
+  const sb = subInfo(b);
+  const ta = sa.base;
+  const tb = sb.base;
+  if (ta.kind === 'whole' && tb.kind === 'whole') {
+    if (ta.literal || tb.literal || sa.subrange || sb.subrange) return true;
+    return ta.id === tb.id;
+  }
+  if (ta.kind === 'real' && tb.kind === 'real') {
+    if (ta.literal || tb.literal || sa.subrange || sb.subrange) return true;
+    return ta.id === tb.id;
+  }
+  if ((ta.kind === 'char' || ta.kind === 'stringLit') && (tb.kind === 'char' || tb.kind === 'stringLit')) {
+    if (ta.kind === 'stringLit' && tb.kind === 'char' && ta.length !== 1) return false;
+    if (tb.kind === 'stringLit' && ta.kind === 'char' && tb.length !== 1) return false;
+    return true;
+  }
+  if (ta.kind === 'stringLit' && tb.kind === 'array') return tb.element.kind === 'char' || tb.element.kind === 'unknown';
+  if (tb.kind === 'stringLit' && ta.kind === 'array') return ta.element.kind === 'char' || ta.element.kind === 'unknown';
+  if (ta.kind === 'bool' && tb.kind === 'bool') return !ordering;
+  if (ta.kind === 'enum' && tb.kind === 'enum') return ta.name === tb.name;
+  if (isSetish(ta) && isSetish(tb)) return !ordering;
+  if (ta.kind === 'pointer' && tb.kind === 'pointer') {
+    if (ordering) return false;
+    return typeKey(ta.target) === typeKey(tb.target);
+  }
+  if (ta.kind === 'pointer' && tb.kind === 'nil') return !ordering;
+  if (tb.kind === 'pointer' && ta.kind === 'nil') return !ordering;
+  // Arrays and records are never comparable (confirmed against gm2).
+  return false;
+}
+
+function isSetish(t: M2Type): boolean {
+  return t.kind === 'bitset' || t.kind === 'setLit' || t.kind === 'setNamed';
+}
+
+/** Numeric (whole/real) or set type for arithmetic operators. */
+export function arithKind(t: M2Type): 'whole' | 'real' | 'set' | 'other' | 'unknown' {
+  const b = stripSub(t);
+  if (b.kind === 'unknown') return 'unknown';
+  if (b.kind === 'whole') return 'whole';
+  if (b.kind === 'real') return 'real';
+  if (b.kind === 'bitset' || b.kind === 'setLit' || b.kind === 'setNamed') return 'set';
+  return 'other';
+}
+
+export function isOrdinal(t: M2Type): boolean {
+  const b = stripSub(t);
+  return b.kind === 'unknown' || b.kind === 'whole' || b.kind === 'char' ||
+    b.kind === 'bool' || b.kind === 'enum';
+}
+
+function stripSub(t: M2Type): M2Type {
+  return t.kind === 'subrange' ? t.base : t;
+}
+
+function subInfo(t: M2Type): { base: M2Type; subrange: boolean } {
+  if (t.kind === 'subrange') return { base: t.base, subrange: true };
+  return { base: t, subrange: false };
+}
+
+function normKey(t: M2Type): string {
+  return JSON.stringify(t);
+}
+
+function typeKey(t: M2Type): string {
+  const b = stripSub(t);
+  switch (b.kind) {
+    case 'whole': return `whole:${b.literal ? '*' : b.id}`;
+    case 'real': return `real:${b.literal ? '*' : b.id}`;
+    case 'array': return `array:${b.name ?? normKey(b.element)}`;
+    case 'record': return `record:${b.name ?? normKey(b)}`;
+    case 'pointer': return `pointer:${b.name ?? typeKey(b.target)}`;
+    case 'enum': return `enum:${b.name}`;
+    case 'unknown': return 'unknown';
+    default: return b.kind;
+  }
+}
+
+export function displayType(t: M2Type): string {
+  const b = stripSub(t);
+  switch (b.kind) {
+    case 'unknown': return 'unknown type';
+    case 'whole': return b.literal ? 'whole-number constant' : b.id;
+    case 'real': return b.literal ? 'real constant' : b.id;
+    case 'char': return 'CHAR';
+    case 'stringLit': return `string constant (${b.length})`;
+    case 'bool': return 'BOOLEAN';
+    case 'bitset': return 'BITSET';
+    case 'setNamed': return b.name ? `SET ${b.name}` : 'SET';
+    case 'setLit': return 'set constructor';
+    case 'array': return b.name ? `ARRAY ${b.name}` : 'ARRAY';
+    case 'record': return b.name ? `RECORD ${b.name}` : 'RECORD';
+    case 'pointer': return b.name ? `POINTER ${b.name}` : 'POINTER';
+    case 'enum': return b.name ? `enumeration ${b.name}` : 'enumeration';
+    case 'proc': return 'PROCEDURE';
+    case 'nil': return 'NIL';
+    default: return 'unknown type';
+  }
+}
+
+/** Resolve an imported qualified name (`M.X`) to its declaration for typing. */
+export function resolveQualified(
+  unit: M2Unit, docDir: string, module: string, name: string,
+): { sym: M2Symbol; host: M2Unit; dir: string } | null {
+  const def = loadDefModule(docDir, module);
+  if (!def) return null;
+  const sym = topLevel(def, name);
+  if (!sym) return null;
+  return { sym, host: def, dir: path.dirname(def.filePath) };
+}