ソースを参照

feat: add M2 -I resolution and incremental separate builds

Eric Streit 1 日 前
親
コミット
92d11d99dd

+ 3 - 3
README.md

@@ -83,13 +83,13 @@ docs/              STATUS.md, plan.md, plan-*.md, analysis_*.md, summary_*.md
 
 ## Status (2026-10-24)
 
-- Suite **303/303**; self-hosting **fixpoint OK** (byte-identical
-  stage2/stage3, 3,717,887 bytes); corpus **519/612 compile-OK,
+- Suite **304/304**; self-hosting **fixpoint OK** (byte-identical
+  stage2/stage3, 3,806,078 bytes); corpus **519/612 compile-OK,
   0 crashes** (`tools/v3-corpus`; the corpus is gm2's testsuite, so it
   mixes conforming Modula-2 with gm2 leniencies — see `docs/gaps/`).
   The remaining `except*`/`onebyte*`-style corpus files still hit V3's
   declaration-order policy.
-- Active branch **`master`**, feature state **`v3-separate-compilation`**.
+- Active branch **`master`**, feature state **`v3-separate-compilation-p1`**.
   Recent milestones: `PACKEDSET`, ISO exceptions (`EXCEPT`/`RETRY`,
   `EXCEPTIONS`, `M2EXCEPTION`, catchable language traps including
   `functionException`, module `FINALLY`), self-referential pointer

+ 11 - 8
TUTORIAL.md

@@ -10,7 +10,7 @@ From the repository root:
 ```sh
 cd compiler
 ./build.sh          # M2.atg -> scanner/parser/driver -> ./M2
-./run_tests.sh      # 303/303
+./run_tests.sh      # 304/304
 ```
 
 `M2` reads Modula-2 sources and writes QBE IR to `gen_ssa/`.
@@ -78,8 +78,9 @@ cc gen_ssa/SepProg.o gen_ssa/SepLib.o ../runtime/syslib/shim.c \
 /tmp/sep
 ```
 
-`M2 -c` requires imported interfaces on the command line.  The target’s own
-`.def` is found automatically.
+`M2 -c` accepts `-I` directories and resolves imported interfaces
+transitively; the target’s own `.def` is found automatically.  Explicit
+interface lists still work.
 
 ### `M2make --separate`
 
@@ -147,16 +148,18 @@ cd .. && TMP="$PWD/.scratch/fixpoint" ./bootstrap/fixpoint.sh
 tools/v3-corpus/corpus.sh
 ```
 
-Current gates: suite **303/303**, fixpoint **OK (3,717,887 bytes)**,
+Current gates: suite **304/304**, fixpoint **OK (3,806,078 bytes)**,
 corpus **519/612 compile-OK, 0 crashes**.
 
 ## 7. Current limits
 
-- `M2 -c` does not scan `-I` paths itself; use `M2make --separate`.
-- `M2make --separate` rebuilds all objects; there is no incremental object
-  or ABI-version cache.
+- `M2 -I` transitive interface scanning is implemented.
+- `M2make --separate` rebuilds only stale objects, relinks when an object,
+  the shim, or the output changes, and treats a newer compiler binary as an
+  ABI event.  There is no content-based ABI version marker yet.
 - Generic instance objects are not emitted separately yet.
-- The fixpoint gate still compares the one-shot compiler image.
+- Per-object self-hosting is not yet a green fixpoint gate; the one-shot
+  fixpoint remains the comparison.
 
 For details, see `docs/separate-compilation.md` and
 `docs/plan-separate-compilation.md`.

+ 34 - 0
compiler/run_tests.sh

@@ -721,6 +721,40 @@ expect_separate() {
 expect_separate SepProg 42 SepLib sep_lib.def sep_lib.mod sep_prog.mod
 expect_separate SepEmptyProg 42 SepEmpty sep_empty.def sep_empty.mod sep_empty_prog.mod
 
+# M2 -c -I: resolve the target's own .def and imports from the include path.
+sep_compile_auto() {
+  # $1 = module, $2 = source
+  mod="$1"; target="$2"
+  rm -f "gen_ssa/$mod.ssa" "gen_ssa/$mod.s" "gen_ssa/$mod.o"
+  ./M2 -c -I tests "tests/$target" 2>&1 | grep -q "Parsed correctly" || return 1
+  [ -f "gen_ssa/$mod.ssa" ] || return 1
+  qbe -o "gen_ssa/$mod.s" "gen_ssa/$mod.ssa" 2>/dev/null || return 1
+  cc -c "gen_ssa/$mod.s" -o "gen_ssa/$mod.o" 2>/dev/null || return 1
+}
+
+expect_separate_auto() {
+  # $1 = program module, $2 = expected exit code, $3 = library module,
+  # $4 = library source, $5 = program source
+  prog="$1"; want="$2"; lib="$3"; lsrc="$4"; psrc="$5"
+  if ! sep_compile_auto "$lib" "$lsrc"; then
+    fail=$((fail+1)); echo "FAIL(separate-I): $lib compile"; return
+  fi
+  if ! sep_compile_auto "$prog" "$psrc"; then
+    fail=$((fail+1)); echo "FAIL(separate-I): $prog compile"; return
+  fi
+  if ! cc "gen_ssa/$prog.o" "gen_ssa/$lib.o" "$SHIM"        -o "gen_ssa/$prog" -lm 2> "gen_ssa/$prog.ccerr"; then
+    fail=$((fail+1)); echo "FAIL(separate-I): $prog link"; cat "gen_ssa/$prog.ccerr"; return
+  fi
+  timeout 10 "gen_ssa/$prog" >/dev/null 2>&1; got=$?
+  if [ "$got" = "$want" ]; then
+    pass=$((pass+1)); echo "PASS(separate-I): $prog -> $got"
+  else
+    fail=$((fail+1)); echo "FAIL(separate-I): $prog got $got want $want"
+  fi
+}
+
+expect_separate_auto SepResProg 42 SepRes sepres.mod sepres_prog.mod
+
 # --- M2 process exit codes: 0 = ok, 1 = compile errors, 2 = usage/IO ---
 check_exit() {
   # $1 = description, $2 = expected rc, rest = args

+ 359 - 13
compiler/src/compiler.frm

@@ -320,12 +320,28 @@ MODULE -->Grammar;
     uname, iname, gname: GName;
     gpath: GPath;
     isDef: BOOLEAN;
+    (* -I interface search + transitive .def resolution for M2 -c *)
+    incDirs: ARRAY [0 .. 15] OF ARRAY [0 .. 255] OF CHAR;
+    nIncDirs: CARDINAL;
+    scanMods: ARRAY [0 .. 127] OF GName;
+    nScanMods: CARDINAL;
+    sepNames: ARRAY [0 .. 127] OF GName;
+    sepPaths: ARRAY [0 .. 127] OF ARRAY [0 .. 255] OF CHAR;
+    sepState: ARRAY [0 .. 127] OF INTEGER;
+    sepOrder: ARRAY [0 .. 127] OF CARDINAL;
+    nSep, nSepOrder: CARDINAL;
+    scanBuf: ARRAY [0 .. 65535] OF CHAR;
+    scanPath, scanDir: ARRAY [0 .. 255] OF CHAR;
+    scanWantFrom, scanFromSeen, scanImporting: BOOLEAN;
 
   PROCEDURE EqNm (a, b: ARRAY OF CHAR): BOOLEAN;
+  (* NUL comparison.  Do not use HIGH on the right-hand string literal:
+     V3 literal descriptors use the literal length, so HIGH would stop
+     before the NUL terminator in a separately compiled compiler. *)
     VAR i: CARDINAL;
   BEGIN
     i := 0;
-    WHILE (i <= HIGH(a)) AND (i <= HIGH(b)) DO
+    WHILE i < 256 DO
       IF a[i] # b[i] THEN RETURN FALSE END;
       IF a[i] = CHR(0) THEN RETURN TRUE END;
       INC(i)
@@ -339,7 +355,7 @@ MODULE -->Grammar;
     VAR i: CARDINAL; ca, cb: CHAR;
   BEGIN
     i := 0;
-    WHILE (i <= HIGH(a)) AND (i <= HIGH(b)) DO
+    WHILE i < 64 DO
       ca := a[i]; cb := b[i];
       IF (ca >= "A") AND (ca <= "Z") THEN ca := CHR(ORD(ca) + 32) END;
       IF (cb >= "A") AND (cb <= "Z") THEN cb := CHR(ORD(cb) + 32) END;
@@ -350,6 +366,333 @@ MODULE -->Grammar;
     RETURN FALSE
   END EqNmCI;
 
+  PROCEDURE IsOpt (s, opt: ARRAY OF CHAR): BOOLEAN;
+  (* Option comparison without relying on HIGH of a string literal:
+     V3 string-literal descriptors use the literal length, while the
+     caller's fixed buffer is NUL-padded. *)
+    VAR i: CARDINAL;
+  BEGIN
+    i := 0;
+    WHILE i < 64 DO
+      IF s[i] # opt[i] THEN RETURN FALSE END;
+      IF s[i] = CHR(0) THEN RETURN TRUE END;
+      INC(i)
+    END;
+    RETURN FALSE
+  END IsOpt;
+
+  PROCEDURE CopySlice (src: ARRAY OF CHAR; start, count: CARDINAL;
+                       VAR dst: ARRAY OF CHAR);
+    VAR i: CARDINAL;
+  BEGIN
+    i := 0;
+    WHILE (i < count) AND (start + i <= HIGH(src)) AND (i <= HIGH(dst)) DO
+      dst[i] := src[start + i]; INC(i)
+    END;
+    IF i <= HIGH(dst) THEN dst[i] := CHR(0) END
+  END CopySlice;
+
+  PROCEDURE StrCopy (src: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+    VAR i: CARDINAL;
+  BEGIN
+    i := 0;
+    WHILE (i <= HIGH(src)) AND (i <= HIGH(dst)) AND (src[i] # CHR(0)) DO
+      dst[i] := src[i]; INC(i)
+    END;
+    IF i <= HIGH(dst) THEN dst[i] := CHR(0) END
+  END StrCopy;
+
+  PROCEDURE StrCat (a, b: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+    VAR i, j: CARDINAL;
+  BEGIN
+    i := 0;
+    WHILE (i <= HIGH(a)) AND (i <= HIGH(dst)) AND (a[i] # CHR(0)) DO
+      dst[i] := a[i]; INC(i)
+    END;
+    j := 0;
+    WHILE (j <= HIGH(b)) AND (i <= HIGH(dst)) AND (b[j] # CHR(0)) DO
+      dst[i] := b[j]; INC(i); INC(j)
+    END;
+    IF i <= HIGH(dst) THEN dst[i] := CHR(0) END
+  END StrCat;
+
+  PROCEDURE LowerCopy (src: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+    VAR i: CARDINAL; ch: CHAR;
+  BEGIN
+    i := 0;
+    WHILE (i <= HIGH(src)) AND (i <= HIGH(dst)) AND (src[i] # CHR(0)) DO
+      ch := src[i];
+      IF (ch >= "A") AND (ch <= "Z") THEN
+        ch := CHR(ORD(ch) + ORD("a") - ORD("A"))
+      END;
+      dst[i] := ch; INC(i)
+    END;
+    IF i <= HIGH(dst) THEN dst[i] := CHR(0) END
+  END LowerCopy;
+
+  PROCEDURE DirOfPath (path: ARRAY OF CHAR; VAR dir: ARRAY OF CHAR);
+    VAR i, k: CARDINAL;
+  BEGIN
+    dir[0] := CHR(0); k := 0; i := 0;
+    WHILE (i <= HIGH(path)) AND (path[i] # CHR(0)) DO
+      IF (path[i] = "/") OR (path[i] = CHR(92)) THEN k := i + 1 END;
+      INC(i)
+    END;
+    i := 0;
+    WHILE i < k DO dir[i] := path[i]; INC(i) END;
+    dir[i] := CHR(0)
+  END DirOfPath;
+
+  PROCEDURE TryDef (dir, name, ext: ARRAY OF CHAR;
+                    VAR path: ARRAY OF CHAR): BOOLEAN;
+    VAR n: CARDINAL; i: CARDINAL;
+  BEGIN
+    path[0] := CHR(0);
+    n := 0;
+    i := 0;
+    WHILE (i <= HIGH(dir)) AND (dir[i] # CHR(0)) DO
+      IF n <= HIGH(path) THEN path[n] := dir[i] END;
+      INC(n); INC(i)
+    END;
+    IF (n > 0) AND (path[n - 1] # "/") THEN
+      IF n <= HIGH(path) THEN path[n] := "/" END;
+      INC(n)
+    END;
+    i := 0;
+    WHILE (i <= HIGH(name)) AND (name[i] # CHR(0)) DO
+      IF n <= HIGH(path) THEN path[n] := name[i] END;
+      INC(n); INC(i)
+    END;
+    i := 0;
+    WHILE (i <= HIGH(ext)) AND (ext[i] # CHR(0)) DO
+      IF n <= HIGH(path) THEN path[n] := ext[i] END;
+      INC(n); INC(i)
+    END;
+    IF n <= HIGH(path) THEN path[n] := CHR(0) END;
+    RETURN FileExists(path)
+  END TryDef;
+
+  PROCEDURE FindDef (name, targetDir: ARRAY OF CHAR;
+                     VAR path: ARRAY OF CHAR): BOOLEAN;
+    VAR i: CARDINAL; low: GName; d: ARRAY [0 .. 255] OF CHAR;
+  BEGIN
+    path[0] := CHR(0);
+    LowerCopy(name, low);
+    (* -I directories take precedence; the target's own directory is a
+       fallback (important when a runtime and a host copy share a name). *)
+    i := 0;
+    WHILE i < nIncDirs DO
+      StrCopy(incDirs[i], d);
+      IF TryDef(d, name, ".def", path) THEN RETURN TRUE END;
+      IF TryDef(d, name, ".DEF", path) THEN RETURN TRUE END;
+      IF TryDef(d, low, ".def", path) THEN RETURN TRUE END;
+      IF TryDef(d, low, ".DEF", path) THEN RETURN TRUE END;
+      INC(i)
+    END;
+    IF TryDef(targetDir, name, ".def", path) THEN RETURN TRUE END;
+    IF TryDef(targetDir, name, ".DEF", path) THEN RETURN TRUE END;
+    IF TryDef(targetDir, low, ".def", path) THEN RETURN TRUE END;
+    IF TryDef(targetDir, low, ".DEF", path) THEN RETURN TRUE END;
+    RETURN FALSE
+  END FindDef;
+
+  PROCEDURE IsNameChar (ch: CHAR): BOOLEAN;
+  BEGIN
+    RETURN ((ch >= "A") AND (ch <= "Z"))
+        OR ((ch >= "a") AND (ch <= "z"))
+        OR ((ch >= "0") AND (ch <= "9"))
+        OR (ch = "_")
+  END IsNameChar;
+
+  PROCEDURE AddScanMod (name: ARRAY OF CHAR);
+    VAR i: CARDINAL;
+  BEGIN
+    i := 0;
+    WHILE i < nScanMods DO
+      IF EqNm(scanMods[i], name) THEN RETURN END;
+      INC(i)
+    END;
+    IF nScanMods <= HIGH(scanMods) THEN
+      StrCopy(name, scanMods[nScanMods]); INC(nScanMods)
+    END
+  END AddScanMod;
+
+  PROCEDURE HandleScanTok (tok: GName);
+    VAR wantFrom, fromSeen, importing: BOOLEAN;
+  BEGIN
+    (* These flags live in ScanFileImports's caller-visible globals. *)
+    IF EqNmCI(tok, "FROM") THEN
+      scanWantFrom := TRUE; scanFromSeen := FALSE; scanImporting := FALSE
+    ELSIF EqNmCI(tok, "IMPORT") THEN
+      IF scanFromSeen THEN scanImporting := FALSE
+      ELSE scanImporting := TRUE
+      END;
+      scanWantFrom := FALSE
+    ELSIF scanWantFrom THEN
+      AddScanMod(tok);
+      scanWantFrom := FALSE; scanFromSeen := TRUE
+    ELSIF scanImporting THEN
+      AddScanMod(tok)
+    END
+  END HandleScanTok;
+
+  PROCEDURE ScanFileImports (path: ARRAY OF CHAR);
+    VAR f: FileIO.File; nRead, i, start: CARDINAL;
+        ch, quote: CHAR; cmt: CARDINAL; tok: GName;
+  BEGIN
+    nScanMods := 0;
+    FileIO.Open(f, path, FALSE);
+    IF NOT FileIO.Okay THEN RETURN END;
+    nRead := HIGH(scanBuf) + 1;
+    FileIO.ReadBytes(f, scanBuf, nRead);
+    FileIO.Close(f);
+    cmt := 0; quote := CHR(0);
+    scanWantFrom := FALSE; scanFromSeen := FALSE; scanImporting := FALSE;
+    i := 0;
+    WHILE i < nRead DO
+      ch := scanBuf[i];
+      IF cmt > 0 THEN
+        IF (ch = "(") AND (i + 1 < nRead) AND (scanBuf[i + 1] = "*") THEN
+          INC(cmt); INC(i, 2)
+        ELSIF (ch = "*") AND (i + 1 < nRead) AND (scanBuf[i + 1] = ")") THEN
+          DEC(cmt); INC(i, 2)
+        ELSE INC(i)
+        END
+      ELSIF quote # CHR(0) THEN
+        IF ch = quote THEN
+          IF (i + 1 < nRead) AND (scanBuf[i + 1] = quote) THEN INC(i, 2)
+          ELSE quote := CHR(0); INC(i)
+          END
+        ELSE INC(i)
+        END
+      ELSIF (ch = "(") AND (i + 1 < nRead) AND (scanBuf[i + 1] = "*") THEN
+        cmt := 1; INC(i, 2)
+      ELSIF (ch = "/") AND (i + 1 < nRead) AND (scanBuf[i + 1] = "/") THEN
+        WHILE (i < nRead) AND (scanBuf[i] # CHR(10)) DO INC(i) END
+      ELSIF (ch = "'") OR (ch = '"') THEN
+        quote := ch; INC(i)
+      ELSIF (ch = ";") THEN
+        scanWantFrom := FALSE; scanFromSeen := FALSE; scanImporting := FALSE;
+        INC(i)
+      ELSIF IsNameChar(ch) THEN
+        start := i;
+        WHILE (i < nRead) AND IsNameChar(scanBuf[i]) DO INC(i) END;
+        IF i - start <= HIGH(tok) THEN
+          CopySlice(scanBuf, start, i - start, tok);
+          HandleScanTok(tok)
+        END
+      ELSE INC(i)
+      END
+    END
+  END ScanFileImports;
+
+  PROCEDURE FindSep (name: ARRAY OF CHAR): INTEGER;
+    VAR i: CARDINAL;
+  BEGIN
+    i := 0;
+    WHILE i < nSep DO
+      IF EqNm(sepNames[i], name) THEN RETURN VAL(INTEGER, i) END;
+      INC(i)
+    END;
+    RETURN -1
+  END FindSep;
+
+  PROCEDURE ResolveModule (name: ARRAY OF CHAR): BOOLEAN; FORWARD;
+
+  PROCEDURE AddDefPath (path, name: ARRAY OF CHAR): BOOLEAN;
+    VAR idx, j: INTEGER; pend: ARRAY [0 .. 127] OF GName; pn: CARDINAL;
+  BEGIN
+    idx := FindSep(name);
+    IF idx >= 0 THEN RETURN TRUE END;
+    IF nSep > HIGH(sepNames) THEN RETURN FALSE END;
+    idx := VAL(INTEGER, nSep);
+    StrCopy(name, sepNames[nSep]); StrCopy(path, sepPaths[nSep]);
+    sepState[nSep] := 1; INC(nSep);
+    ScanFileImports(path);
+    pn := nScanMods;
+    j := 0;
+    WHILE j < VAL(INTEGER, pn) DO
+      StrCopy(scanMods[j], pend[j]); INC(j)
+    END;
+    j := 0;
+    WHILE j < VAL(INTEGER, pn) DO
+      IF NOT ResolveModule(pend[j]) THEN END;
+      INC(j)
+    END;
+    sepState[VAL(CARDINAL, idx)] := 2;
+    IF nSepOrder <= HIGH(sepOrder) THEN
+      sepOrder[nSepOrder] := VAL(CARDINAL, idx); INC(nSepOrder)
+    END;
+    RETURN TRUE
+  END AddDefPath;
+
+  PROCEDURE ResolveModule (name: ARRAY OF CHAR): BOOLEAN;
+    VAR path: ARRAY [0 .. 255] OF CHAR; idx: INTEGER;
+  BEGIN
+    IF EqNm(name, "SYSTEM") OR EqNm(name, "libc") THEN RETURN TRUE END;
+    idx := FindSep(name);
+    IF idx >= 0 THEN RETURN TRUE END;
+    IF NOT FindDef(name, scanDir, path) THEN RETURN FALSE END;
+    RETURN AddDefPath(path, name)
+  END ResolveModule;
+
+  PROCEDURE ResolveFileImports (path: ARRAY OF CHAR);
+    VAR j: INTEGER; pend: ARRAY [0 .. 127] OF GName; pn: CARDINAL;
+  BEGIN
+    ScanFileImports(path);
+    pn := nScanMods;
+    j := 0;
+    WHILE j < VAL(INTEGER, pn) DO
+      StrCopy(scanMods[j], pend[j]); INC(j)
+    END;
+    j := 0;
+    WHILE j < VAL(INTEGER, pn) DO
+      IF NOT ResolveModule(pend[j]) THEN END;
+      INC(j)
+    END
+  END ResolveFileImports;
+
+  PROCEDURE AddIncDir (s: ARRAY OF CHAR);
+    VAR start, i: CARDINAL; d: ARRAY [0 .. 255] OF CHAR;
+  BEGIN
+    start := 0; i := 0;
+    LOOP
+      IF (i > HIGH(s)) OR (s[i] = CHR(0)) OR (s[i] = ":") THEN
+        IF i > start THEN
+          CopySlice(s, start, i - start, d);
+          IF nIncDirs <= HIGH(incDirs) THEN
+            StrCopy(d, incDirs[nIncDirs]); INC(nIncDirs)
+          END
+        END;
+        IF (i > HIGH(s)) OR (s[i] = CHR(0)) THEN EXIT END;
+        start := i + 1
+      END;
+      INC(i)
+    END
+  END AddIncDir;
+
+  PROCEDURE PrepareSeparate (target: ARRAY OF CHAR);
+    VAR j: CARDINAL; own: ARRAY [0 .. 255] OF CHAR; nm: GName;
+  BEGIN
+    nSep := 0; nSepOrder := 0;
+    DirOfPath(target, scanDir);
+    (* the target's own interface supplies exported globals/consts *)
+    ChangeExtension(target, ".def", own);
+    IF (NOT EqNm(target, own)) AND FileExists(own) THEN
+      BaseName(own, nm);
+      IF NOT AddDefPath(own, nm) THEN END
+    END;
+    ResolveFileImports(target);
+    IF nSepOrder + 1 <= HIGH(files) THEN
+      StrCopy(target, files[nSepOrder]);
+      j := 0;
+      WHILE j < nSepOrder DO
+        StrCopy(sepPaths[sepOrder[j]], files[j]); INC(j)
+      END;
+      nFiles := nSepOrder + 1
+    END
+  END PrepareSeparate;
+
   PROCEDURE NoteGen (name: ARRAY OF CHAR; isD: BOOLEAN;
                      path: ARRAY OF CHAR);
     VAR i: CARDINAL;
@@ -469,12 +812,20 @@ MODULE -->Grammar;
     LOOP
       FileIO.NextParameter(sourceName);
       IF (sourceName[0] = CHR(0)) OR (nFiles > HIGH(files)) THEN EXIT END;
-      IF EqNm(sourceName, "-notwophase") THEN
+      IF IsOpt(sourceName, "-notwophase") THEN
         doTwoPhase := FALSE
-      ELSIF EqNm(sourceName, "-legacyopen1d") THEN
+      ELSIF IsOpt(sourceName, "-legacyopen1d") THEN
         Lower.SetLegacyOpen1D
-      ELSIF EqNm(sourceName, "-c") THEN
+      ELSIF IsOpt(sourceName, "-c") THEN
         separate := TRUE
+      ELSIF IsOpt(sourceName, "-I") THEN
+        FileIO.NextParameter(tmpCmd);
+        IF tmpCmd[0] = CHR(0) THEN
+          FileIO.WriteString(FileIO.StdOut, "-I expects a directory");
+          FileIO.WriteLn(FileIO.StdOut);
+          exit(2)
+        END;
+        AddIncDir(tmpCmd)
       ELSE
         Strings.Assign(sourceName, files[nFiles]);
         INC(nFiles)
@@ -486,15 +837,10 @@ MODULE -->Grammar;
       exit(2)
     END;
 
-    (* M2 -c Foo.mod: pick up Foo.def automatically when it exists;
-       imported interfaces may still be supplied explicitly. *)
+    (* M2 -c Foo.mod: resolve Foo.def and its import interfaces through
+       -I directories.  Explicit interface lists keep the old path. *)
     IF separate AND (nFiles = 1) THEN
-      ChangeExtension(files[0], ".def", tmpCmd);
-      IF (NOT EqNm(files[0], tmpCmd)) AND FileExists(tmpCmd) THEN
-        Strings.Assign(files[0], files[1]);
-        Strings.Assign(tmpCmd, files[0]);
-        nFiles := 2
-      END
+      PrepareSeparate(files[0])
     END;
     BaseName(files[nFiles - 1], progName);
     SymTab.Init;

+ 5 - 0
compiler/tests/sepres.def

@@ -0,0 +1,5 @@
+DEFINITION MODULE SepRes;
+(* M2 -I resolution test: file name matches the module name. *)
+VAR Value : INTEGER;
+PROCEDURE Add (x : INTEGER);
+END SepRes.

+ 5 - 0
compiler/tests/sepres.mod

@@ -0,0 +1,5 @@
+IMPLEMENTATION MODULE SepRes;
+PROCEDURE Add (x : INTEGER);
+BEGIN Value := Value + x END Add;
+BEGIN Value := 40
+END SepRes.

+ 7 - 0
compiler/tests/sepres_prog.mod

@@ -0,0 +1,7 @@
+MODULE SepResProg;
+FROM SepRes IMPORT Value, Add;
+VAR ExitCode : INTEGER;
+BEGIN
+  Add(2);
+  IF Value = 42 THEN ExitCode := 42 ELSE ExitCode := 1 END
+END SepResProg.

+ 2 - 2
docs/INDEX.md

@@ -16,8 +16,8 @@ bisecting *why* a decision was made, not for reading end-to-end).
   ASTDUMP root cause).
 - **`git-branches-tags.md`** — branch/tag map (which refs are current,
   historical, or moving).
-- **`separate-compilation.md`** — implemented `-c`/per-object workflow,
-  stable symbols and remaining limits.
+- **`separate-compilation.md`** — implemented `-c -I`/incremental
+  per-object workflow, stable symbols and remaining limits.
 - **`library-folders.md`** — dialect library layout and selection.
 - **`STATUS.md`** — current state, architecture, conformance policy,
   what remains.

+ 9 - 8
docs/STATUS.md

@@ -4,9 +4,9 @@ This answers "where are we now?".
 
 ## Numbers
 
-- Suite **303/303** (`compiler/run_tests.sh`).
+- Suite **304/304** (`compiler/run_tests.sh`).
 - Self-hosting **FIXPOINT OK** — stage2 and stage3 images are
-  byte-identical (3,717,887 bytes).
+  byte-identical (3,806,078 bytes).
 - Corpus **519 / 612** sources compile with 0 errors, **0 crashes**
   (`tools/v3-corpus/corpus.sh` over the gm2 testsuite; the harness now
   resolves V3's own shipped libraries); the rest are genuine language
@@ -14,7 +14,7 @@ This answers "where are we now?".
   imports of gm2-only modules.  Open-array corpus cases `unbounded`,
   `unbounded2`, `unbounded3`, `unbounded4` and `array9` now compile;
   `unbounded`/`unbounded2`/`unbounded3`/`unbounded4` also run.
-- Branch **`master`**, feature state **`v3-separate-compilation`**; tree
+- Branch **`master`**, feature state **`v3-separate-compilation-p1`**; tree
   clean.
 
 ## Architecture (current)
@@ -198,11 +198,12 @@ declarations still work (`t_fwdproc`, `t_fwdok`).
    `t_manyproc`), the label/`GOTO` tables, the 32-actual AST buffer and
    `SymTab`'s `MaxTypes` all report `230` (implementation limit) instead
    of truncating silently.  Still weak: error recovery and message polish.
-4. **Separate compilation** — `M2 -c` emits per-unit objects (libraries
-   have no `main`), stable module/class symbols, `export data`, and
-   dependency-ordered init calls.  `M2make --separate` resolves
-   interfaces and links objects.  Core shipped; generics, incremental
-   builds and an `M2 -I` scanner remain.  See
+4. **Separate compilation** — `M2 -c -I` resolves interfaces and emits
+   per-unit objects (libraries have no `main`), with stable module/class
+   symbols, `export data`, and dependency-ordered init calls.  `M2make
+   --separate` builds stale objects incrementally and links them.  Core
+   shipped; generic-instance objects, content-based ABI markers and a
+   green per-object self-host fixpoint remain.  See
    `docs/separate-compilation.md`;
    summary: `summary_session-2026-10-24-separate-compilation.md`.
 5. **Library folders** — `stdlib/` is retired; libraries live in

+ 11 - 8
docs/comparison-PIM4-gm2-V3.md

@@ -1,8 +1,9 @@
 # PIM4 vs GNU Modula-2 vs M2R10 vs TopSpeed V3 vs V3 — feature comparison
 
 **Date:** 2026-10-24 · **V3 state:** open arrays, dialect library
-folders and core separate compilation (suite 303/303, self-hosting
-fixpoint OK 3,717,887 bytes, corpus 519/612, 0 crashes).
+folders and separate compilation with `M2 -I`/incremental objects
+(suite 304/304, self-hosting fixpoint OK 3,806,078 bytes, corpus
+519/612, 0 crashes).
 
 **Scope and sources.**
 
@@ -115,9 +116,9 @@ change individual cells.
 |---|---|---|---|---|---|
 | Definition / implementation / program modules | ✅ | ✅ | ✅ (+ blueprints) | ✅ | ✅ |
 | Local modules | ✅ | ✅ | ❌ removed | ✅ | ✅ |
-| Separate compilation | ✅ | ✅ (object files/archives) | ✅ compilation units | ✅ | ✅ core: `M2 -c` per-unit objects + `M2make --separate`; one-shot remains default |
+| Separate compilation | ✅ | ✅ (object files/archives) | ✅ compilation units | ✅ | ✅ `M2 -c -I` per-unit objects + `M2make --separate`; one-shot remains default |
 | Stable cross-object symbols / init order | n/a | ✅ | ✅ | ✅ | ✅ `<Module>__proc`, module-qualified pools/vtables, dependency-ordered `<Module>_init` |
-| Incremental object rebuild / ABI marker | n/a | ✅ | ✅ | ✅ | 🔄 `--separate` rebuilds all objects; no ABI version marker yet |
+| Incremental object rebuild / ABI marker | n/a | ✅ | ✅ | ✅ | 🔄 stale mtime rebuilds, compiler/shim ABI/relink triggers; no content-based ABI marker |
 | Generics | ❌ | ✅ ISO 10514-2 | ❌ external templates (`GENLIB`/`BLUEPRINT`) | ❌ | ✅ ISO 10514-2 refinement |
 | Object orientation | ❌ | ❌ | ❌ (extensible records only) | ✅ Clarion-form `CLASS`, `VIRTUAL`, inheritance | 🔄 Clarion-form `CLASS` (single inheritance, virtual dispatch), not ISO 10514-3 |
 | PIM coroutines (`PROCESS`, `NEWPROCESS`, `TRANSFER`, `IOTRANSFER`) | ✅ | ✅ | 🔄 `COROUTINE` type/`YIELD` (R10 phase II) | ✅ PIM-style | ✅ (`IOTRANSFER` degrades to a transfer) |
@@ -200,10 +201,12 @@ change individual cells.
   objects have no `main`; every object exports `<Module>_init` and the
   program calls imported inits in dependency order.  Stable symbols are
   `<Module>__proc`, `<Module>_<global>`, module-qualified pools and
-  `<Module>__<Class>__vt`.  The one-shot mode remains the default for
-  the suite/corpus.  Still deferred: an `M2 -I` import scanner,
-  incremental object/ABI checks, separate generic-instance objects and
-  per-object self-hosting.
+  `<Module>__<Class>__vt`.  `M2 -I` resolves interfaces transitively;
+  `M2make --separate` rebuilds stale objects and relinks on source/
+  interface/compiler/shim changes.  The one-shot mode remains the
+  default for the suite/corpus.  Still deferred: content-based ABI
+  markers, separate generic-instance objects and a green per-object
+  self-host fixpoint.
 - **OO** is the Clarion-form `CLASS` dialect (single inheritance,
   virtual dispatch), not ISO 10514-3.
 

+ 7 - 6
docs/features.md

@@ -1,4 +1,4 @@
-# m2compiler-V3 — feature status (2026-10-24; suite 303/303 green)
+# m2compiler-V3 — feature status (2026-10-24; suite 304/304 green)
 
 Pipeline: Coco/R `M2.atg` (1748 lines, 73 productions) → `gm2`-built
 `M2` → QBE `.ssa` → `qbe` → `cc` → run. `SymTab.mod` 1623 lines,
@@ -163,8 +163,9 @@ Legend: ✅ done · 🔄 partial · ⏸ not started / deferred.
   (any depth), array/record/set/pointer params, open-array `VAR`
   formals.
 - ✅ Separate compilation: one-shot (`lib.def lib.mod prog.mod` → one
-  image) and per-unit objects (`M2 -c` / `M2make --separate`); module
-  init functions, dependency-ordered cross-object init, stable symbols,
+  image) and per-unit objects (`M2 -c -I` / `M2make --separate`);
+  transitive `.def` resolution, incremental object rebuilds, module init
+  functions, dependency-ordered cross-object init, stable symbols,
   exported globals; opaque completion; strict 235 signature checks
   between a definition and its implementation.
 - ✅ Module `FINALLY` (program/implementation/local/class bodies), with
@@ -174,9 +175,9 @@ Legend: ✅ done · 🔄 partial · ⏸ not started / deferred.
   finalizers.
 - ✅ Procedure-type variables / indirect calls (`t_proctype`); ✅
   composite function results (record/array/set/class, sret) and chained
-  post-call method calls (`F().M()`); 🔄 per-file objects exist but
-  incremental/ABI checks and `M2 -I` import scanning are deferred;
-  import cycles are rejected.
+  post-call method calls (`F().M()`); 🔄 per-file objects and `M2 -I`
+  scanning exist; content-based ABI markers and generic-instance objects
+  are deferred; import cycles are rejected.
 - 🔄 ISO exceptions: **M4** — `EXCEPT`/`RETRY`, handler stack,
   `SYSTEM.THROW`, `EXCEPTIONS`, `M2EXCEPTION`, signal mapping,
   `RETURN`/nested handlers, catchable index/range/CASE traps, and

+ 19 - 13
docs/plan-separate-compilation.md

@@ -5,7 +5,7 @@
 one-shot mode remains the default for the suite/corpus; this document
 keeps the design record and the deferred phases.
 
-**Baseline now:** suite 303/303, fixpoint OK 3,717,887 bytes, corpus
+**Baseline now:** suite 304/304, fixpoint OK 3,806,078 bytes, corpus
 519/612, 0 crashes.
 
 ---
@@ -148,18 +148,22 @@ cc Prog.o Lib.o runtime/syslib/shim.o -lm -o Prog
 1. **Stable names + per-unit `.ssa` in one session.**  Keep the
    whole-session driver, emit one `.ssa` per unit, link them.  Validates
    symbols, pools, vtables and the init list.
-2. **`-c` / `-I` driver mode.**  DONE for `M2 -c` with explicit
-   interfaces and a sibling target `.def`; M2make `--separate` supplies
-   transitive `.def` resolution.  `M2 -I` itself is still deferred.
+2. **`-c` / `-I` driver mode.**  DONE: `M2 -c -I` recursively resolves
+   imported `.def` files; explicit interfaces still work.  M2make
+   `--separate` uses the same path.
 3. **Init order + program/main split.**  DONE: library objects have no
    main, every object exports `<mod>_init`, and the program object calls
    imported inits in dependency order.
 4. **Generics and classes.**  PARTIAL: class vtable/method symbols are
    stable across objects; generic instances are still client-local.
-5. **M2make incremental builds.**  DEFERRED: `--separate` rebuilds all
-   objects.
-6. **Self-host with separate compilation.**  DEFERRED: the fixpoint
-   still compares the one-shot compiler image.
+5. **M2make incremental builds.**  DONE at mtime level: stale objects
+   are rebuilt, the compiler binary is an ABI trigger, and relinking
+   follows object/shim changes.  Content-based ABI markers are still
+   deferred.
+6. **Self-host with separate compilation.**  IN PROGRESS: a linked
+   compiler is buildable from objects, but the separately linked stage-2
+   compiler has a signature-check regression; the one-shot fixpoint
+   remains the gate.
 
 ## 6. Tests / gates for each phase
 
@@ -206,13 +210,15 @@ cc Prog.o Lib.o runtime/syslib/shim.o -lm -o Prog
   list, and exports all top-level data.
 - `M2 -c` lowers the target `.mod` (and its own `.def` for exported
   data), skips imported definition units, and emits
-  `gen_ssa/<module>.ssa`; target `Foo.mod` auto-finds `Foo.def`.
-- `M2make --separate` compiles each module against the ordered
-  interfaces, writes `gen_ssa/<Module>.o`, and links with `cc`.
+  `gen_ssa/<module>.ssa`; `-I` resolves target/import interfaces
+  transitively.
+- `M2make --separate` compiles stale modules against interfaces,
+  writes `gen_ssa/<Module>.o`, and links with `cc`; source/interface/
+  compiler/shim mtimes drive rebuild and relink.
 - `Lower` emits an empty `<module>_init` for bodyless library objects
   so cross-object init calls always resolve.
-- Tests: `SepProg`, `SepEmptyProg`, plus a manual cross-object class
-  check.
+- Tests: `SepProg`, `SepEmptyProg`, `SepResProg` (`-I`), plus a manual
+  cross-object class check and M2make incremental checks.
 
 ## Appendix — related future work: dialect library folders
 

+ 15 - 12
docs/separate-compilation.md

@@ -17,10 +17,12 @@ M2make --separate --dialect iso \
        -o Prog Prog.mod
 ```
 
-It resolves the imported `.def` files transitively, compiles each
+It resolves the imported `.def` files transitively, compiles each stale
 `.mod` against interfaces only, writes objects to `gen_ssa/<Module>.o`,
 and links them with `cc` plus the shared runtime shim.  No `.def` is
-passed to `cc`.
+passed to `cc`.  A second run reports `up to date`; touching a source
+recompiles only that object and relinks.  A newer compiler binary is
+treated as an ABI event, and changes to the shim trigger a relink.
 
 For a single unit, `M2 -c` emits QBE IR directly:
 
@@ -60,25 +62,26 @@ The implementation is split across:
 | `SymTab` | uid → module/depth/class tables; stable type/class names |
 | `QbeGen` | stable `Mangled`, module-qualified pools, `export data`, library/program split, external init list |
 | `Lower` | `-c` mode, empty init for bodyless objects, target `.def` data emission, class/vtable stability |
-| `compiler.frm` / `M2.mod` | `-c`, sibling `.def` lookup, imported-def init list, target def matching |
-| `M2make` | `--separate`: per-module `M2 -c` + qbe + cc, final link |
+| `compiler.frm` / `M2.mod` | `-c`, `-I` transitive `.def` scanner, sibling `.def` lookup, imported-def init list, target def matching |
+| `M2make` | `--separate`: per-module `M2 -c -I` + qbe + cc, incremental stale checks and final link |
 
 ## Limits / next phases
 
-- **No `M2 -I` import scanner yet.**  `M2 -c` uses explicit interfaces;
-  use `M2make --separate` for transitive resolution.
-- **No incremental build:** `--separate` rebuilds every object.  Stale
-  objects are not detected; a `.def`/object mismatch surfaces as a
-  normal linker error.
+- **Incremental checks are mtime-based.**  Sources/interfaces, the
+  compiler binary and the shim trigger rebuilds/relinks; there is no
+  content-based ABI/version marker or flag fingerprint.
 - **Generics** still need the generic implementation source at the
   client; separate generic instances are not emitted as their own
   objects yet.
 - **Classes** have stable vtable/method symbols for normal imported
   class use; a class that exists only in a `.def` (no implementation
   object) must still have its vtable materialised by a user object.
-- **ABI/version marker** in objects is not implemented.
-- The fixpoint gate still compares the one-shot compiler image, not a
-  multi-object rebuild.
+- **Per-object self-hosting** builds a linked compiler from objects, but
+  is not yet a green fixpoint gate: the separately linked stage-2
+  compiler currently mis-checks some definition/implementation
+  signatures.  The one-shot fixpoint remains the gate.
+- `M2 -I` scanning is conservative: import files are read up to a 64 KiB
+  buffer and interfaces are ordered by dependency.
 
 ## Tests
 

+ 18 - 1
docs/summary_session-2026-10-24-separate-compilation.md

@@ -4,7 +4,7 @@ V3 now builds per-unit objects.  The one-shot mode remains the default
 for the suite/corpus.
 
 **Branch:** `master`.  **Feature state:** `v3-separate-compilation`.
-**Gates:** suite **303/303**, fixpoint **OK (3,717,887 bytes)**,
+**Gates:** suite **304/304**, fixpoint **OK (3,806,078 bytes)**,
 corpus **519/612 compile-OK, 0 crashes**.
 
 ---
@@ -61,11 +61,28 @@ Manual M2make cross-object class check: `CLib.def/.mod` + `CProg.mod`
 - The fixpoint gate still compares the one-shot compiler image, not a
   multi-object self-host.
 
+## Follow-up — `-I` and incremental objects (2026-10-24)
+
+- `M2 -c -I dir` now recursively scans target/import source files,
+  resolves `.def` files (including lower-case names), topologically
+  orders them, and parses them before the target.
+- `M2make --separate` now passes the target through `M2 -c -I ...`
+  instead of an ever-growing explicit interface list.
+- Stale-object checks use source/interface mtimes; a newer compiler
+  binary is treated as an ABI event; shim changes relink.  A second run
+  reports `up to date`.
+- Added `SepResProg` (`M2 -c -I`), M2make separate up-to-date,
+  single-object recompile and `.def`-change rebuild tests.
+- A per-object compiler build links and runs, but the separately linked
+  stage-2 compiler hits a signature-check regression on SysShim; the
+  one-shot fixpoint remains the gate.
+
 ## Commits and tags
 
 | commit | tag | work |
 | --- | --- | --- |
 | `0adc637` | `v3-separate-compilation` | stable symbols, `-c`, library/program split, init list, M2make `--separate`, tests/docs |
+| this P1 commit | `v3-separate-compilation-p1` | `M2 -I` transitive resolution, incremental M2make objects, tests/docs |
 | this docs commit | `v3-separate-compilation-summary` | session summary |
 | final docs commit | `v3-session-2026-10-24-separate-end` | session tag |
 

+ 9 - 8
m2make.md

@@ -47,11 +47,12 @@ tools/m2make/b_v3/M2make --separate \
     -o /tmp/prog prog.mod
 ```
 
-Each module is compiled with `M2 -c` against the ordered interfaces to
-`gen_ssa/<Module>.o`; the program object contains `main`, library objects
-contain only their code plus `<Module>_init`.  All objects are linked with
-`cc` and the shim.  This mode currently rebuilds every object; it does not
-yet do incremental `.def`-timestamp checks.
+Each module is compiled with `M2 -c -I ...`; M2 resolves the target’s
+`.def` and imported interfaces transitively.  The program object contains
+`main`, library objects contain only their code plus `<Module>_init`.  All
+objects are linked with `cc` and the shim.  Stale objects are rebuilt from
+their source/interface mtimes, a newer compiler binary is treated as an ABI
+event, and the link is redone when an object or the shim changes.
 
 ## Options
 
@@ -72,12 +73,12 @@ yet do incremental `.def`-timestamp checks.
 
 ## Current limits
 
-- `--separate` rebuilds every object; stale-object/ABI checks are deferred.
+- Incremental object checks use mtimes, not a content-based ABI marker.
 - Generic instances are still resolved at client compile time.
 - The default one-shot mode remains the mode used by the suite, corpus and
   self-hosting fixpoint.
-- `M2 -c` itself needs explicit imported interfaces; M2make is the
-  transitive resolver.
+- `M2 -c` resolves imported interfaces through `-I`; explicit interface
+  lists remain supported.
 
 See `TUTORIAL.md`, `docs/separate-compilation.md`, and
 `tools/m2make/README.md` for details.

+ 103 - 37
tools/m2make/M2make.mod

@@ -391,9 +391,13 @@ BEGIN
   WHILE di < nDirs DO
     GetDir(di, tmpQ);
     TryVariant(tmpQ, n, ".def", cand);
-    IF Len(cand) > 0 THEN Copy(cand, dp); found := TRUE END;
+    IF (Len(dp) = 0) AND (Len(cand) > 0) THEN
+      Copy(cand, dp); found := TRUE
+    END;
     TryVariant(tmpQ, n, ".mod", cand);
-    IF Len(cand) > 0 THEN Copy(cand, mp); found := TRUE END;
+    IF (Len(mp) = 0) AND (Len(cand) > 0) THEN
+      Copy(cand, mp); found := TRUE
+    END;
     TryVariant(tmpQ, low, ".def", cand);
     IF (Len(dp) = 0) AND (Len(cand) > 0) THEN
       Copy(cand, dp); found := TRUE
@@ -839,14 +843,39 @@ BEGIN
   Cat(tmpA, ".o", obj)
 END SepObjName;
 
+PROCEDURE SepObjStale (m: CARDINAL; obj: ARRAY OF CHAR): BOOLEAN;
+(* Separate mode is conservative: any interface change rebuilds the
+   object.  This also covers transitive .def type-layout changes, at the
+   cost of rebuilding more than strictly necessary. *)
+VAR ot, ct: LONGINT; k: CARDINAL;
+BEGIN
+  IF NOT M2makeOS.FileExists(obj) THEN RETURN TRUE END;
+  IF NOT M2makeOS.FileMTime(obj, ot) THEN RETURN TRUE END;
+  (* A compiler rebuild is an ABI event; conservative but cheap. *)
+  IF M2makeOS.FileMTime(m2bin, ct) AND (ct > ot) THEN RETURN TRUE END;
+  GetDef(m, tmpP);
+  IF SrcNewer(tmpP, ot) THEN RETURN TRUE END;
+  GetSrc(m, tmpP);
+  IF SrcNewer(tmpP, ot) THEN RETURN TRUE END;
+  k := 0;
+  WHILE k < nOrder DO
+    GetDef(order[k], tmpP);
+    IF SrcNewer(tmpP, ot) THEN RETURN TRUE END;
+    INC(k)
+  END;
+  RETURN FALSE
+END SepObjStale;
+
 PROCEDURE BuildM2Separate(out: ARRAY OF CHAR);
-(* Compile every module to its own QBE object, then link.  The program
-   module is last in the topological order and is the only object with
-   main; every module object exports <mod>_init. *)
+(* Compile stale modules to their own QBE objects, then link.  The
+   program module is last in the topological order and is the only
+   object with main; every module object exports <mod>_init. *)
 VAR cmd: Cmd; k, m: CARDINAL; rc: INTEGER;
-    obj, src, ssafile, sfile: Path;
+    obj, src, ssafile, sfile, flags: Path;
+    outT: LONGINT; compiledAny, linkNeeded: BOOLEAN;
 BEGIN
   Zero(cmd);
+  compiledAny := FALSE;
   k := 0;
   WHILE k < nOrder DO
     m := order[k];
@@ -854,37 +883,75 @@ BEGIN
     IF Len(src) > 0 THEN
       IF modIsMain[m] THEN Copy(progName, tmpN) ELSE GetName(m, tmpN) END;
       Zero(obj); Cat("gen_ssa/", tmpN, tmpA); Cat(tmpA, ".o", obj);
-      CpyPath(ssafile, "gen_ssa/"); Cat(ssafile, tmpN, tmpA); Cat(tmpA, ".ssa", ssafile);
-      CpyPath(sfile, "gen_ssa/"); Cat(sfile, tmpN, tmpA); Cat(tmpA, ".s", sfile);
-      Zero(tmpCmd);
-      AppendSeg(tmpCmd, "mkdir -p gen_ssa && ");
-      AppendSeg(tmpCmd, m2bin);
-      AppendSeg(tmpCmd, " -c");
-      IF Len(m2Flags) > 0 THEN
+      IF SepObjStale(m, obj) THEN
+        CpyPath(ssafile, "gen_ssa/"); Cat(ssafile, tmpN, tmpA); Cat(tmpA, ".ssa", ssafile);
+        CpyPath(sfile, "gen_ssa/"); Cat(sfile, tmpN, tmpA); Cat(tmpA, ".s", sfile);
+        Zero(tmpCmd);
+        AppendSeg(tmpCmd, "mkdir -p gen_ssa && ");
+        AppendSeg(tmpCmd, m2bin);
+        AppendSeg(tmpCmd, " -c");
+        IF Len(m2Flags) > 0 THEN
+          AppendSeg(tmpCmd, " ");
+          AppendSeg(tmpCmd, m2Flags)
+        END;
+        (* M2 resolves the target .def and all imported .defs from -I. *)
+        IFlags(flags);
+        IF Len(flags) > 0 THEN
+          AppendSeg(tmpCmd, " ");
+          AppendSeg(tmpCmd, flags)
+        END;
+        AppendTok(tmpCmd, src);
+        AppendSeg(tmpCmd, " 2>&1 | tee m2make.log | grep -q 'Parsed correctly'");
+        AppendSeg(tmpCmd, " && qbe -o ");
+        AppendTok(tmpCmd, sfile);
         AppendSeg(tmpCmd, " ");
-        AppendSeg(tmpCmd, m2Flags)
-      END;
-      (* all interfaces, in dependency order, then this object *)
-      m := 0;
-      WHILE m < nOrder DO
-        GetDef(order[m], tmpP);
-        IF Len(tmpP) > 0 THEN AppendTok(tmpCmd, tmpP) END;
-        INC(m)
-      END;
-      AppendTok(tmpCmd, src);
-      AppendSeg(tmpCmd, " 2>&1 | tee m2make.log | grep -q 'Parsed correctly'");
-      AppendSeg(tmpCmd, " && qbe -o ");
-      AppendTok(tmpCmd, sfile);
-      AppendSeg(tmpCmd, " ");
-      AppendTok(tmpCmd, ssafile);
-      AppendSeg(tmpCmd, " && cc -c ");
-      AppendTok(tmpCmd, sfile);
-      AppendSeg(tmpCmd, " -o ");
-      AppendTok(tmpCmd, obj);
-      AppendCmd(cmd, tmpCmd)
+        AppendTok(tmpCmd, ssafile);
+        AppendSeg(tmpCmd, " && cc -c ");
+        AppendTok(tmpCmd, sfile);
+        AppendSeg(tmpCmd, " -o ");
+        AppendTok(tmpCmd, obj);
+        CheckLen(tmpCmd);
+        Out(tmpCmd);
+        OutLn;
+        IF NOT dryRun THEN
+          rc := M2makeOS.ExecCmd(tmpCmd);
+          IF rc # 0 THEN
+            Out("M2make: separate compile failed");
+            OutLn;
+            M2makeOS.ExitNow(1)
+          END
+        END;
+        compiledAny := TRUE
+      END
     END;
     INC(k)
   END;
+  linkNeeded := compiledAny;
+  IF NOT linkNeeded THEN
+    IF NOT M2makeOS.FileExists(out) THEN linkNeeded := TRUE
+    ELSIF NOT M2makeOS.FileMTime(out, outT) THEN linkNeeded := TRUE
+    ELSE
+      k := 0;
+      WHILE k < nOrder DO
+        m := order[k];
+        GetSrc(m, src);
+        IF Len(src) > 0 THEN
+          IF modIsMain[m] THEN Copy(progName, tmpN) ELSE GetName(m, tmpN) END;
+          Zero(obj); Cat("gen_ssa/", tmpN, tmpA); Cat(tmpA, ".o", obj);
+          IF ObjNewerThan(obj, out, outT) THEN linkNeeded := TRUE END
+        END;
+        INC(k)
+      END;
+      IF SrcNewer(shimPath, outT) THEN linkNeeded := TRUE END;
+      IF Len(extraC) > 0 THEN
+        IF SrcNewer(extraC, outT) THEN linkNeeded := TRUE END
+      END
+    END
+  END;
+  IF NOT linkNeeded THEN
+    Out("M2make: "); Out(out); Out(" up to date"); OutLn;
+    RETURN
+  END;
   (* link: one object per module, target object (main) last *)
   Zero(tmpCmd);
   AppendSeg(tmpCmd, "cc ");
@@ -904,12 +971,11 @@ BEGIN
   AppendSeg(tmpCmd, " -o ");
   AppendTok(tmpCmd, out);
   AppendSeg(tmpCmd, " -lm");
-  AppendCmd(cmd, tmpCmd);
-  CheckLen(cmd);
-  Out(cmd);
+  CheckLen(tmpCmd);
+  Out(tmpCmd);
   OutLn;
   IF dryRun THEN RETURN END;
-  rc := M2makeOS.ExecCmd(cmd);
+  rc := M2makeOS.ExecCmd(tmpCmd);
   IF rc # 0 THEN
     Out("M2make: separate build failed");
     OutLn;

+ 6 - 5
tools/m2make/README.md

@@ -29,11 +29,12 @@ M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]
 - `--dialect pim|iso|gm2`: add `lib/<dialect>`, `lib/common`,
   `lib/gm2` and `runtime/syslib` to the module search path.  Use one
   dialect per session; do not mix `lib/pim` and `lib/iso`.
-- `--separate`: compile every module to `gen_ssa/<Module>.o` with
-  `M2 -c`, then link the objects with `cc` and the runtime shim.  The
-  program object is the only one with `main`; library objects export
-  `<Module>_init`.  This mode still rebuilds all objects (no
-  incremental `.def`-dependency scan).
+- `--separate`: compile stale modules to `gen_ssa/<Module>.o` with
+  `M2 -c -I ...`, then link the objects with `cc` and the runtime shim.
+  The program object is the only one with `main`; library objects export
+  `<Module>_init`.  Source/interface mtimes, a newer compiler binary and
+  the shim drive rebuild/relink; there is no content-based ABI marker
+  yet.
 
 `m2` mode (default, one-shot) runs one session — `<m2bin> <ordered .def/.mod files>`
 then `qbe` then `cc <image>.s <shim> [<extra>] -o <out> -lm` — and

+ 46 - 4
tools/m2make/tests/run_tests.sh

@@ -72,7 +72,7 @@ touch tests/Calc.def tests/Calc.mod tests/Main2.mod
 # --- real m2 build via the V3-built M2make ---
 rm -rf gen_ssa Main m2make.log
 if $V3M -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
-     -I ../../stdlib -o Main tests/Main.mod > /tmp/m2make_t.log 2>&1; then
+     -I ../../lib/common -o Main tests/Main.mod > /tmp/m2make_t.log 2>&1; then
   ok "m2-mode real build (V3 M2make)"
 else
   no "m2-mode real build (V3 M2make)"
@@ -83,23 +83,65 @@ else
   no "m2-built program runs"
 fi
 out=$($V3M -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
-  -I ../../stdlib -o Main tests/Main.mod 2>&1)
+  -I ../../lib/common -o Main tests/Main.mod 2>&1)
 case "$out" in
   *"up to date"*) ok "m2-mode up-to-date skip" ;;
   *) no "m2-mode up-to-date skip: $out" ;;
 esac
 
+# --- separate-object build, up-to-date, incremental relink ---
+rm -rf gen_ssa SepMain m2make.log
+if $V3M --separate -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
+     -I ../../lib/common -o SepMain tests/Main.mod > /tmp/m2make_t.log 2>&1; then
+  ok "separate-mode real build"
+else
+  no "separate-mode real build"
+fi
+if ./SepMain 2>/dev/null | grep -q "42"; then
+  ok "separate-built program runs (42)"
+else
+  no "separate-built program runs"
+fi
+out=$($V3M --separate -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
+  -I ../../lib/common -o SepMain tests/Main.mod 2>&1)
+case "$out" in
+  *"up to date"*) ok "separate-mode up-to-date skip" ;;
+  *) no "separate-mode up-to-date skip: $out" ;;
+esac
+sleep 1
+touch tests/Calc.mod
+out=$($V3M --separate -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
+  -I ../../lib/common -o SepMain tests/Main.mod 2>&1)
+case "$out" in
+  *"tests/Calc.mod"*) ok "separate-mode recompiles changed object" ;;
+  *) no "separate-mode recompiles changed object: $out" ;;
+esac
+if ./SepMain 2>/dev/null | grep -q "42"; then
+  ok "separate relink runs (42)"
+else
+  no "separate relink runs"
+fi
+sleep 1
+touch tests/Calc.def
+out=$($V3M --separate -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
+  -I ../../lib/common -o SepMain tests/Main.mod 2>&1)
+case "$out" in
+  *"tests/Main.mod"*) ok "separate-mode recompiles importer after .def change" ;;
+  *) no "separate-mode .def rebuild: $out" ;;
+esac
+touch tests/Calc.def tests/Calc.mod tests/Main.mod
+
 # --- cross-check: gm2-built M2make drives an m2 build too ---
 rm -rf gen_ssa MainX m2make.log
 if $GM2 -c m2 --m2bin ../../compiler/M2 --shim ../../runtime/syslib/shim.c \
-     -I ../../stdlib -o MainX tests/Main.mod > /tmp/m2make_t.log 2>&1 \
+     -I ../../lib/common -o MainX tests/Main.mod > /tmp/m2make_t.log 2>&1 \
      && ./MainX 2>/dev/null | grep -q "42"; then
   ok "gm2-built M2make drives m2 build"
 else
   no "gm2-built M2make drives m2 build"
 fi
 
-rm -f Main MainX Main2 *.o
+rm -f Main MainX Main2 SepMain *.o
 rm -rf gen_ssa
 rm -f m2make.log