Pārlūkot izejas kodu

m2make: build-order front-end for M2/gm2 (topo-sort imports, incremental build); FileIO argv starts at 1

- tools/m2make: portable M2make.mod core + per-compiler OS layer
  (gm2 FOR "C" / V3 EXTERNAL), builds under both b_gm2 and b_v3;
  tests/run_tests.sh 18/18.
- runtime/syslib/FileIO: NextParameter now skips argv[0] (program
  name), matching the gm2 host FileIO.
- main suite 107/107.
Eric Streit 2 nedēļas atpakaļ
vecāks
revīzija
2f243ed497

+ 60 - 0
m2make.md

@@ -0,0 +1,60 @@
+# Building M2make
+
+M2make is a front-end for the M2 Modula-2 compiler or GNU Modula-2 compiler.
+It's goal is to check the order of compilation of the different files of a project, 
+compiling only what is necessary. It only a main file like this :
+
+```
+M2make toto.mod
+```
+
+I will use a topological sort to figure out the order of compiling,
+and which file needs to be compiled (if modified). 
+
+I needs to compile only what is necessary.
+
+## Status (implemented in `tools/m2make`, GNU Modula-2)
+
+`M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out] [--m2bin path] [--shim path] main.mod`
+
+- Follows `IMPORT`/`FROM` imports transitively from the main file,
+  topologically sorts the modules (iterative DFS, cycle reported),
+  skips the build when the output is newer than every source.
+- `m2` mode: one session (`M2` + `qbe` + `cc`), gated on M2's
+  `Parsed correctly` verdict. `gm2` mode: per-file `gm2 -fiso -c`
+  of stale units + ordered link.
+- One portable core (`M2make.mod` + `M2makeOS.def`, ISO Modula-2
+  importing only `FileIO`/`M2makeOS`) compiles with both `gm2 -fiso`
+  (`build_gm2.sh`) and the V3 `M2` driver (`build_m2.sh`); the
+  OS layer (`stat`/`system`/text input) lives in `m2make_os.c`
+  with per-compiler bindings (`M2makeC.def` for gm2,
+  `EXTERNAL` for M2). See `tools/m2make/README.md` for the exact
+  portable subset and limitations. `tests/run_tests.sh`: 18/18 green
+  with both binaries.
+
+
+I will use a topological sort to figure out the order of compiling,
+and which file needs to be compiled (if modified). 
+
+I needs to compile only what is necessary.
+
+
+<!--It also needs eventually to use a project file with these elements :-->
+
+<!--[compiler]-->
+<!--	M2 or Gnu Modula-2-->
+<!--	-->
+<!--[systemlibs]-->
+<!--	system libraries to be included-->
+<!--	-->
+<!--[projectfiles]-->
+<!--	all the project files to be included in the project. -->
+<!--	-->
+<!--the last one can be deduced by M2make which will load the main file -->
+<!--and follow all the imports to be included. From that, it makes a -->
+<!--topologic sort and compiles what is necessary in the right order.-->
+
+<!--[options]-->
+<!--	options to use with the compilers and to pass to the compile line.-->
+
+ 

+ 3 - 1
runtime/syslib/FileIO.mod

@@ -122,7 +122,9 @@ BEGIN
 END Compare;
 
 BEGIN
-  argIdx := 0;
+  (* Arguments exclude argv[0] (the program name), matching the gm2
+     host FileIO: user arguments start at cmdline index 1. *)
+  argIdx := 1;
   Okay := TRUE;
   StdOut := SysShim.stdout();
   con := StdOut;

+ 13 - 0
tools/m2make/.gitignore

@@ -0,0 +1,13 @@
+# M2make build outputs (binaries, objects, staging, generated)
+b_gm2/
+b_v3/
+gen_ssa/
+*.o
+*.s
+*.ssa
+*.LST
+Main
+Main2
+MainX
+m2make.log
+m2.log

+ 1111 - 0
tools/m2make/M2make.mod

@@ -0,0 +1,1111 @@
+MODULE M2make;
+(* M2make: build-order front-end for the M2 and GNU Modula-2 compilers.
+   Usage: M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]
+                 [--m2bin path] [--shim path] main.mod
+   Follows IMPORT/FROM imports from main.mod transitively, sorts the
+   modules topologically, skips the build when the output is newer
+   than every source, otherwise invokes the selected compiler.
+   Exits 0 on success (or nothing to do), 1 on build failure,
+   2 on usage errors.
+   Portable source: compiles with gm2 -fiso and with the V3 M2
+   driver.  Only FileIO (console) and M2makeOS (files/time/exec)
+   are imported; all string handling is NUL-based and local, since
+   the two compilers' FileIO string helpers differ. *)
+
+IMPORT FileIO, M2makeOS;
+
+CONST
+  MaxMods = 256;
+  MaxDeps = 64;
+  MaxDirs = 16;
+  MaxArgs = 64;
+  StackCap = 264;
+  NameStride = 64;
+  PathStride = 256;
+
+TYPE
+  Name = ARRAY [0..63] OF CHAR;
+  Path = ARRAY [0..255] OF CHAR;
+  Line = ARRAY [0..2047] OF CHAR;
+  Cmd = ARRAY [0..2047] OF CHAR;
+
+VAR
+  nMods: CARDINAL;
+  (* Flat pools (V3 cannot pass nested-array elements as open-array
+     actuals, nor double-index; all access is single-indexed here). *)
+  namePool: ARRAY [0..16383] OF CHAR;
+  srcPool: ARRAY [0..65535] OF CHAR;
+  defPool: ARRAY [0..65535] OF CHAR;
+  dirPool: ARRAY [0..4095] OF CHAR;
+  modScanned: ARRAY [0..255] OF BOOLEAN;
+  modExternal: ARRAY [0..255] OF BOOLEAN;
+  modIsMain: ARRAY [0..255] OF BOOLEAN;
+  nDep: ARRAY [0..255] OF CARDINAL;
+  depTab: ARRAY [0..16383] OF INTEGER;
+  state: ARRAY [0..255] OF CARDINAL;
+  childPos: ARRAY [0..255] OF CARDINAL;
+  order: ARRAY [0..255] OF CARDINAL;
+  nOrder: CARDINAL;
+  dryRun, verbose, useGm2: BOOLEAN;
+  outGiven: BOOLEAN;
+  mainPath, outName, m2bin, shimPath: Path;
+  nDirs: CARDINAL;
+  progName: Name;
+  wantProg: BOOLEAN;
+  mainIdx: INTEGER;
+  inComment: CARDINAL;
+  gPendingFrom, gInImport, gSawHead, gWantName, gFromSeen: BOOLEAN;
+  tmpA, tmpB: Path;
+  tmpN: Name;
+  tmpP, tmpQ: Path;
+  tmpCmd: Cmd;
+  segA: Cmd;
+
+(* ---------------- zeroed (NUL) string utilities ---------------- *)
+
+PROCEDURE Len(s: ARRAY OF CHAR): CARDINAL;
+(* NUL scan that never reads past HIGH(s): V3 evaluates AND operands
+   fully (no short-circuit) and traps out-of-range access, and string
+   literals carry no NUL padding, so the scan must stop by index. *)
+VAR i, hi: CARDINAL;
+BEGIN
+  hi := HIGH(s);
+  i := 0;
+  WHILE i < hi DO
+    IF s[i] = CHR(0) THEN RETURN i END;
+    INC(i)
+  END;
+  IF s[hi] = CHR(0) THEN RETURN hi END;
+  RETURN hi + 1
+END Len;
+
+PROCEDURE Zero(VAR s: ARRAY OF CHAR);
+VAR i: CARDINAL;
+BEGIN
+  i := 0;
+  WHILE i <= HIGH(s) DO s[i] := CHR(0); INC(i) END
+END Zero;
+
+PROCEDURE Copy(src: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+VAR i, n: CARDINAL;
+BEGIN
+  Zero(dst);
+  n := Len(src);
+  IF n > HIGH(dst) THEN n := HIGH(dst) END;
+  i := 0;
+  WHILE i < n DO dst[i] := src[i]; INC(i) END
+END Copy;
+
+PROCEDURE Cmp(a, b: ARRAY OF CHAR): INTEGER;
+(* Length-aware comparison: also correct when one side is a string
+   literal (no NUL padding under V3).  No AND-access in conditions. *)
+VAR i, na, nb: CARDINAL;
+BEGIN
+  na := Len(a);
+  nb := Len(b);
+  i := 0;
+  WHILE (i < na) AND (i < nb) DO
+    IF a[i] # b[i] THEN
+      IF a[i] < b[i] THEN RETURN -1 ELSE RETURN 1 END
+    END;
+    INC(i)
+  END;
+  IF na < nb THEN RETURN -1 END;
+  IF na > nb THEN RETURN 1 END;
+  RETURN 0
+END Cmp;
+
+PROCEDURE Cat(a, b: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+VAR i, k: CARDINAL; done: BOOLEAN;
+BEGIN
+  Zero(dst);
+  k := 0;
+  i := 0;
+  done := FALSE;
+  WHILE (NOT done) AND (i <= HIGH(a)) AND (k <= HIGH(dst)) DO
+    IF a[i] = CHR(0) THEN done := TRUE
+    ELSE dst[k] := a[i]; INC(k); INC(i)
+    END
+  END;
+  i := 0;
+  done := FALSE;
+  WHILE (NOT done) AND (i <= HIGH(b)) AND (k <= HIGH(dst)) DO
+    IF b[i] = CHR(0) THEN done := TRUE
+    ELSE dst[k] := b[i]; INC(k); INC(i)
+    END
+  END
+END Cat;
+
+PROCEDURE Sub(src: ARRAY OF CHAR; start, count: CARDINAL;
+              VAR dst: ARRAY OF CHAR);
+VAR j: CARDINAL; done: BOOLEAN;
+BEGIN
+  Zero(dst);
+  j := 0;
+  done := FALSE;
+  WHILE (NOT done) AND (j < count) AND (start + j <= HIGH(src))
+    AND (j <= HIGH(dst)) DO
+    IF src[start + j] = CHR(0) THEN done := TRUE
+    ELSE dst[j] := src[start + j]; INC(j)
+    END
+  END
+END Sub;
+
+PROCEDURE Lower(src: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+VAR i: CARDINAL; ch: CHAR; done: BOOLEAN;
+BEGIN
+  Zero(dst);
+  i := 0;
+  done := FALSE;
+  WHILE (NOT done) AND (i <= HIGH(src)) AND (i <= HIGH(dst)) DO
+    IF src[i] = CHR(0) THEN done := TRUE
+    ELSE
+      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
+  END
+END Lower;
+
+PROCEDURE IsLetter(ch: CHAR): BOOLEAN;
+BEGIN
+  RETURN ((ch >= "A") AND (ch <= "Z"))
+      OR ((ch >= "a") AND (ch <= "z"))
+END IsLetter;
+
+PROCEDURE IsDig(ch: CHAR): BOOLEAN;
+BEGIN
+  RETURN (ch >= "0") AND (ch <= "9")
+END IsDig;
+
+(* ---------------- flat-pool accessors ---------------- *)
+(* Single-indexed pool traffic only (see VAR comment). *)
+
+PROCEDURE GetName(m: CARDINAL; VAR dst: Name);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(dst) DO
+    dst[k] := namePool[m * NameStride + k];
+    INC(k)
+  END
+END GetName;
+
+PROCEDURE PutName(m: CARDINAL; src: Name);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(src) DO
+    namePool[m * NameStride + k] := src[k];
+    INC(k)
+  END
+END PutName;
+
+PROCEDURE GetSrc(m: CARDINAL; VAR dst: Path);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(dst) DO
+    dst[k] := srcPool[m * PathStride + k];
+    INC(k)
+  END
+END GetSrc;
+
+PROCEDURE PutSrc(m: CARDINAL; src: Path);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(src) DO
+    srcPool[m * PathStride + k] := src[k];
+    INC(k)
+  END
+END PutSrc;
+
+PROCEDURE GetDef(m: CARDINAL; VAR dst: Path);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(dst) DO
+    dst[k] := defPool[m * PathStride + k];
+    INC(k)
+  END
+END GetDef;
+
+PROCEDURE PutDef(m: CARDINAL; src: Path);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(src) DO
+    defPool[m * PathStride + k] := src[k];
+    INC(k)
+  END
+END PutDef;
+
+PROCEDURE GetDir(m: CARDINAL; VAR dst: Path);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(dst) DO
+    dst[k] := dirPool[m * PathStride + k];
+    INC(k)
+  END
+END GetDir;
+
+PROCEDURE PutDir(m: CARDINAL; src: Path);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(src) DO
+    dirPool[m * PathStride + k] := src[k];
+    INC(k)
+  END
+END PutDir;
+
+(* ---------------- console output ---------------- *)
+
+PROCEDURE Out(s: ARRAY OF CHAR);
+(* Character by character: V3 FileIO.WriteString emits the whole
+   descriptor capacity (including NUL padding), so loop instead. *)
+VAR i, n: CARDINAL;
+BEGIN
+  n := Len(s);
+  i := 0;
+  WHILE i < n DO
+    FileIO.Write(FileIO.StdOut, s[i]);
+    INC(i)
+  END
+END Out;
+
+PROCEDURE OutLn;
+BEGIN
+  FileIO.WriteLn(FileIO.StdOut)
+END OutLn;
+
+PROCEDURE OutInt(i: INTEGER);
+BEGIN
+  FileIO.WriteInt(FileIO.StdOut, i, 0)
+END OutInt;
+
+PROCEDURE Die(s: ARRAY OF CHAR; code: INTEGER);
+BEGIN
+  Out(s); OutLn;
+  M2makeOS.ExitNow(code)
+END Die;
+
+PROCEDURE Usage;
+BEGIN
+  Out("Usage: M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]"); OutLn;
+  Out("       [--m2bin path] [--shim path] main.mod"); OutLn
+END Usage;
+
+(* ---------------- module table ---------------- *)
+
+PROCEDURE FindMod(n: ARRAY OF CHAR): INTEGER;
+VAR i: CARDINAL;
+BEGIN
+  i := 0;
+  WHILE i < nMods DO
+    GetName(i, tmpN);
+    IF Cmp(tmpN, n) = 0 THEN RETURN VAL(INTEGER, i) END;
+    INC(i)
+  END;
+  RETURN -1
+END FindMod;
+
+PROCEDURE AddMod(n: ARRAY OF CHAR): INTEGER;
+BEGIN
+  IF FindMod(n) >= 0 THEN RETURN FindMod(n) END;
+  IF nMods >= MaxMods THEN Die("M2make: too many modules", 1) END;
+  Copy(n, tmpN);
+  PutName(nMods, tmpN);
+  Zero(tmpP);
+  PutSrc(nMods, tmpP);
+  PutDef(nMods, tmpP);
+  modScanned[nMods] := FALSE;
+  modExternal[nMods] := FALSE;
+  modIsMain[nMods] := FALSE;
+  nDep[nMods] := 0;
+  INC(nMods);
+  RETURN VAL(INTEGER, nMods - 1)
+END AddMod;
+
+PROCEDURE AddDep(m, d: INTEGER);
+VAR mc: CARDINAL; k: CARDINAL;
+BEGIN
+  IF (m < 0) OR (d < 0) OR (m = d) THEN RETURN END;
+  mc := VAL(CARDINAL, m);
+  k := 0;
+  WHILE k < nDep[mc] DO
+    IF depTab[mc * MaxDeps + k] = d THEN RETURN END;
+    INC(k)
+  END;
+  IF nDep[mc] >= MaxDeps THEN Die("M2make: too many imports", 1) END;
+  depTab[mc * MaxDeps + nDep[mc]] := d;
+  INC(nDep[mc])
+END AddDep;
+
+PROCEDURE AddDepName(midx: INTEGER; n: ARRAY OF CHAR);
+VAR d: INTEGER;
+BEGIN
+  d := AddMod(n);
+  AddDep(midx, d)
+END AddDepName;
+
+(* ---------------- path handling ---------------- *)
+
+PROCEDURE DirOf(p: ARRAY OF CHAR; VAR dir: ARRAY OF CHAR);
+VAR i, n, k: CARDINAL;
+BEGIN
+  Zero(dir);
+  n := Len(p);
+  k := 0;
+  i := 0;
+  WHILE i < n DO
+    IF p[i] = "/" THEN k := i + 1 END;
+    INC(i)
+  END;
+  i := 0;
+  WHILE (i < k) AND (i <= HIGH(dir)) DO
+    dir[i] := p[i]; INC(i)
+  END
+END DirOf;
+
+PROCEDURE TryVariant(dir, base, ext: ARRAY OF CHAR;
+                     VAR out: ARRAY OF CHAR);
+VAR full: Path;
+BEGIN
+  Zero(out);
+  IF Len(dir) = 0 THEN
+    Cat(base, ext, full)
+  ELSE
+    IF Len(dir) + 1 + Len(base) + Len(ext) > HIGH(full) THEN RETURN END;
+    IF dir[Len(dir) - 1] = "/" THEN
+      Cat(dir, base, tmpA);
+      Cat(tmpA, ext, full)
+    ELSE
+      Cat(dir, "/", tmpA);
+      Cat(tmpA, base, tmpB);
+      Cat(tmpB, ext, full)
+    END
+  END;
+  IF M2makeOS.FileExists(full) THEN Copy(full, out) END
+END TryVariant;
+
+PROCEDURE ResolveModule(n: ARRAY OF CHAR; VAR dp, mp: Path): BOOLEAN;
+VAR di: CARDINAL; low: Name; cand: Path; found: BOOLEAN;
+BEGIN
+  Zero(dp);
+  Zero(mp);
+  Lower(n, low);
+  found := FALSE;
+  di := 0;
+  WHILE di < nDirs DO
+    GetDir(di, tmpQ);
+    TryVariant(tmpQ, n, ".def", cand);
+    IF 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;
+    TryVariant(tmpQ, low, ".def", cand);
+    IF (Len(dp) = 0) AND (Len(cand) > 0) THEN
+      Copy(cand, dp); found := TRUE
+    END;
+    TryVariant(tmpQ, low, ".mod", cand);
+    IF (Len(mp) = 0) AND (Len(cand) > 0) THEN
+      Copy(cand, mp); found := TRUE
+    END;
+    INC(di)
+  END;
+  RETURN found
+END ResolveModule;
+
+(* ---------------- import scanner ---------------- *)
+
+PROCEDURE HandleTok(tok: ARRAY OF CHAR; midx: INTEGER);
+BEGIN
+  IF Cmp(tok, "FROM") = 0 THEN
+    gPendingFrom := TRUE;
+    gInImport := FALSE;
+    gSawHead := FALSE;
+    gWantName := FALSE;
+    gFromSeen := FALSE;
+    RETURN
+  END;
+  IF Cmp(tok, "IMPORT") = 0 THEN
+    IF gPendingFrom OR gFromSeen THEN
+      (* FROM M IMPORT ... : following names are items, not modules *)
+      gPendingFrom := FALSE;
+      gFromSeen := FALSE
+    ELSE gInImport := TRUE
+    END;
+    gSawHead := FALSE;
+    gWantName := FALSE;
+    RETURN
+  END;
+  IF Cmp(tok, "DEFINITION") = 0 THEN
+    gSawHead := TRUE;
+    gPendingFrom := FALSE;
+    gInImport := FALSE;
+    gWantName := FALSE;
+    gFromSeen := FALSE;
+    RETURN
+  END;
+  IF Cmp(tok, "IMPLEMENTATION") = 0 THEN
+    gSawHead := TRUE;
+    gPendingFrom := FALSE;
+    gInImport := FALSE;
+    gWantName := FALSE;
+    gFromSeen := FALSE;
+    RETURN
+  END;
+  IF Cmp(tok, "MODULE") = 0 THEN
+    IF gSawHead THEN gWantName := FALSE
+    ELSE gWantName := TRUE
+    END;
+    gSawHead := FALSE;
+    gPendingFrom := FALSE;
+    gInImport := FALSE;
+    gFromSeen := FALSE;
+    RETURN
+  END;
+  IF gPendingFrom THEN
+    AddDepName(midx, tok);
+    gPendingFrom := FALSE;
+    gFromSeen := TRUE;
+    gSawHead := FALSE;
+    RETURN
+  END;
+  IF gInImport THEN
+    AddDepName(midx, tok);
+    gSawHead := FALSE;
+    gFromSeen := FALSE;
+    RETURN
+  END;
+  IF gWantName THEN
+    gWantName := FALSE;
+    IF wantProg AND (midx = mainIdx) THEN
+      Copy(tok, progName);
+      wantProg := FALSE
+    END;
+    gSawHead := FALSE;
+    gFromSeen := FALSE;
+    RETURN
+  END;
+  gSawHead := FALSE;
+  gFromSeen := FALSE
+END HandleTok;
+
+PROCEDURE ScanLine(line: ARRAY OF CHAR; midx: INTEGER);
+VAR i, n, k: CARDINAL; ch, qc: CHAR;
+    inStr: BOOLEAN; tok: Name;
+BEGIN
+  n := Len(line);
+  i := 0;
+  inStr := FALSE;
+  qc := CHR(0);
+  WHILE i < n DO
+    ch := line[i];
+    IF inStr THEN
+      IF ch = qc THEN
+        IF (i + 1 < n) AND (line[i + 1] = qc) THEN i := i + 2
+        ELSE inStr := FALSE; INC(i)
+        END
+      ELSE INC(i)
+      END
+    ELSIF inComment > 0 THEN
+      IF (ch = "(") AND (i + 1 < n) AND (line[i + 1] = "*") THEN
+        INC(inComment); i := i + 2
+      ELSIF (ch = "*") AND (i + 1 < n) AND (line[i + 1] = ")") THEN
+        DEC(inComment); i := i + 2
+      ELSE INC(i)
+      END
+    ELSE
+      IF (ch = "(") AND (i + 1 < n) AND (line[i + 1] = "*") THEN
+        INC(inComment); i := i + 2
+      ELSIF (ch = "/") AND (i + 1 < n) AND (line[i + 1] = "/") THEN
+        i := n
+      ELSIF (ch = "'") OR (ch = '"') THEN
+        inStr := TRUE; qc := ch; INC(i)
+      ELSIF ch = ";" THEN
+        gPendingFrom := FALSE;
+        gInImport := FALSE;
+        gSawHead := FALSE;
+        gWantName := FALSE;
+        gFromSeen := FALSE;
+        INC(i)
+      ELSIF IsLetter(ch) THEN
+        k := i;
+        WHILE (k < n) AND (IsLetter(line[k]) OR IsDig(line[k])) DO
+          INC(k)
+        END;
+        Sub(line, i, k - i, tok);
+        i := k;
+        HandleTok(tok, midx)
+      ELSE INC(i)
+      END
+    END
+  END
+END ScanLine;
+
+PROCEDURE ScanFile(path: ARRAY OF CHAR; midx: INTEGER);
+VAR h: M2makeOS.RdFile; line: Line; ok: BOOLEAN;
+BEGIN
+  h := M2makeOS.OpenRead(path);
+  IF h = NIL THEN
+    Cat("M2make: cannot open ", path, tmpCmd);
+    Die(tmpCmd, 1)
+  END;
+  LOOP
+    Zero(line);
+    ok := M2makeOS.ReadLine(h, line, HIGH(line) + 1);
+    IF NOT ok THEN EXIT END;
+    ScanLine(line, midx)
+  END;
+  M2makeOS.CloseRead(h)
+END ScanFile;
+
+PROCEDURE ScanModule(m: CARDINAL);
+VAR dp, mp: Path;
+BEGIN
+  IF NOT modIsMain[m] THEN
+    Zero(dp);
+    Zero(mp);
+    GetName(m, tmpN);
+    IF NOT ResolveModule(tmpN, dp, mp) THEN
+      modExternal[m] := TRUE;
+      modScanned[m] := TRUE;
+      IF verbose THEN
+        Out("M2make: external module ");
+        Out(tmpN);
+        OutLn
+      END;
+      RETURN
+    END;
+    Copy(dp, tmpP);
+    PutDef(m, tmpP);
+    Copy(mp, tmpP);
+    PutSrc(m, tmpP)
+  END;
+  inComment := 0;
+  gPendingFrom := FALSE;
+  gInImport := FALSE;
+  gSawHead := FALSE;
+  gWantName := FALSE;
+  gFromSeen := FALSE;
+  GetDef(m, tmpP);
+  IF Len(tmpP) > 0 THEN ScanFile(tmpP, VAL(INTEGER, m)) END;
+  GetSrc(m, tmpP);
+  IF Len(tmpP) > 0 THEN ScanFile(tmpP, VAL(INTEGER, m)) END;
+  modScanned[m] := TRUE
+END ScanModule;
+
+PROCEDURE ResolveAll;
+VAR i: CARDINAL;
+BEGIN
+  i := 0;
+  WHILE i < nMods DO
+    IF NOT modScanned[i] THEN ScanModule(i) END;
+    INC(i)
+  END
+END ResolveAll;
+
+(* ---------------- topological sort (iterative DFS) ---------------- *)
+
+PROCEDURE TopoSort;
+VAR stack: ARRAY [0..263] OF CARDINAL;
+    top, cur: CARDINAL; d: INTEGER; dd: CARDINAL; i: CARDINAL;
+BEGIN
+  i := 0;
+  WHILE i < nMods DO
+    state[i] := 0; childPos[i] := 0; INC(i)
+  END;
+  nOrder := 0;
+  top := 0;
+  stack[0] := VAL(CARDINAL, mainIdx);
+  state[VAL(CARDINAL, mainIdx)] := 1;
+  LOOP
+    cur := stack[top];
+    IF childPos[cur] < nDep[cur] THEN
+      d := depTab[cur * MaxDeps + childPos[cur]];
+      INC(childPos[cur]);
+      IF d < 0 THEN Die("M2make: bad dependency", 1) END;
+      dd := VAL(CARDINAL, d);
+      IF NOT modExternal[dd] THEN
+        IF state[dd] = 0 THEN
+          state[dd] := 1;
+          INC(top);
+          IF top > HIGH(stack) THEN Die("M2make: stack overflow", 1) END;
+          stack[top] := dd
+        ELSIF state[dd] = 1 THEN
+          GetName(dd, tmpN);
+          Out("M2make: cyclic import involving ");
+          Out(tmpN);
+          OutLn;
+          M2makeOS.ExitNow(1)
+        END
+      END
+    ELSE
+      state[cur] := 2;
+      order[nOrder] := cur;
+      INC(nOrder);
+      IF top = 0 THEN EXIT END;
+      DEC(top)
+    END
+  END
+END TopoSort;
+
+(* ---------------- staleness ---------------- *)
+
+PROCEDURE SrcNewer(src: ARRAY OF CHAR; outT: LONGINT): BOOLEAN;
+VAR t: LONGINT;
+BEGIN
+  IF Len(src) = 0 THEN RETURN FALSE END;
+  IF NOT M2makeOS.FileMTime(src, t) THEN RETURN TRUE END;
+  RETURN t > outT
+END SrcNewer;
+
+PROCEDURE NeedBuild(out: ARRAY OF CHAR; VAR missing: BOOLEAN): BOOLEAN;
+VAR outT: LONGINT; k: CARDINAL; m: CARDINAL;
+BEGIN
+  missing := FALSE;
+  IF NOT M2makeOS.FileExists(out) THEN
+    missing := TRUE;
+    RETURN TRUE
+  END;
+  IF NOT M2makeOS.FileMTime(out, outT) THEN RETURN TRUE END;
+  k := 0;
+  WHILE k < nOrder DO
+    m := order[k];
+    GetDef(m, tmpP);
+    IF SrcNewer(tmpP, outT) THEN RETURN TRUE END;
+    GetSrc(m, tmpP);
+    IF SrcNewer(tmpP, outT) THEN RETURN TRUE END;
+    INC(k)
+  END;
+  RETURN FALSE
+END NeedBuild;
+
+(* ---------------- command construction ---------------- *)
+
+PROCEDURE AppendTok(VAR cmd: ARRAY OF CHAR; tok: ARRAY OF CHAR);
+BEGIN
+  IF Len(cmd) > 0 THEN
+    Cat(cmd, " ", segA);
+    Copy(segA, cmd)
+  END;
+  Cat(cmd, tok, segA);
+  Copy(segA, cmd)
+END AppendTok;
+
+PROCEDURE IFlags(VAR flags: ARRAY OF CHAR);
+VAR di: CARDINAL;
+BEGIN
+  Zero(flags);
+  di := 0;
+  WHILE di < nDirs DO
+    GetDir(di, tmpP);
+    IF Len(tmpP) > 0 THEN
+      AppendTok(flags, "-I");
+      AppendTok(flags, tmpP)
+    END;
+    INC(di)
+  END
+END IFlags;
+
+PROCEDURE AppendCh(VAR s: ARRAY OF CHAR; ch: CHAR);
+VAR n: CARDINAL;
+BEGIN
+  n := Len(s);
+  IF n <= HIGH(s) THEN
+    s[n] := ch;
+    IF n + 1 <= HIGH(s) THEN s[n + 1] := CHR(0) END
+  END
+END AppendCh;
+
+PROCEDURE BaseObj(src: ARRAY OF CHAR; VAR obj: ARRAY OF CHAR);
+(* "some/dir/Calc.mod" -> "Calc.o" (gm2 -c drops objects in the cwd). *)
+VAR i, n, slash, dot, k: CARDINAL;
+BEGIN
+  Zero(obj);
+  n := Len(src);
+  slash := 0;
+  i := 0;
+  WHILE i < n DO
+    IF src[i] = "/" THEN slash := i + 1 END;
+    INC(i)
+  END;
+  dot := n;
+  i := slash;
+  WHILE i < n DO
+    IF src[i] = "." THEN dot := i END;
+    INC(i)
+  END;
+  IF dot <= slash THEN dot := n END;
+  k := 0;
+  i := slash;
+  WHILE (i < dot) AND (k <= HIGH(obj)) DO
+    obj[k] := src[i]; INC(k); INC(i)
+  END;
+  Cat(obj, ".o", tmpA);
+  Copy(tmpA, obj)
+END BaseObj;
+
+PROCEDURE ObjStale(m: CARDINAL; obj: ARRAY OF CHAR): BOOLEAN;
+VAR ot: LONGINT;
+BEGIN
+  IF NOT M2makeOS.FileExists(obj) THEN RETURN TRUE END;
+  IF NOT M2makeOS.FileMTime(obj, 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;
+  RETURN FALSE
+END ObjStale;
+
+PROCEDURE ObjNewerThan(obj, out: ARRAY OF CHAR;
+                       outT: LONGINT): BOOLEAN;
+VAR t: LONGINT;
+BEGIN
+  IF Len(obj) = 0 THEN RETURN FALSE END;
+  IF NOT M2makeOS.FileMTime(obj, t) THEN RETURN TRUE END;
+  RETURN t > outT
+END ObjNewerThan;
+
+PROCEDURE CheckLen(cmd: ARRAY OF CHAR);
+BEGIN
+  IF Len(cmd) + 32 > HIGH(cmd) THEN
+    Die("M2make: command too long", 1)
+  END
+END CheckLen;
+
+PROCEDURE AppendCmd(VAR cmd: ARRAY OF CHAR; seg: ARRAY OF CHAR);
+BEGIN
+  IF Len(cmd) > 0 THEN
+    Cat(cmd, " && ", segA);
+    Copy(segA, cmd)
+  END;
+  Cat(cmd, seg, segA);
+  Copy(segA, cmd)
+END AppendCmd;
+
+PROCEDURE AppendSeg(VAR cmd: ARRAY OF CHAR; seg: ARRAY OF CHAR);
+(* Direct concatenation (segments carry their own spacing).
+   segA is the dedicated Cmd-sized scratch: always distinct from
+   cmd and seg, and big enough for whole command lines. *)
+BEGIN
+  Cat(cmd, seg, segA);
+  Copy(segA, cmd)
+END AppendSeg;
+
+PROCEDURE BuildM2(out: ARRAY OF CHAR);
+VAR cmd: Cmd; k: CARDINAL; m: CARDINAL; rc: INTEGER;
+BEGIN
+  Zero(cmd);
+  AppendSeg(cmd, "mkdir -p gen_ssa && ");
+  AppendSeg(cmd, m2bin);
+  k := 0;
+  WHILE k < nOrder DO
+    m := order[k];
+    GetDef(m, tmpP);
+    IF Len(tmpP) > 0 THEN AppendTok(cmd, tmpP) END;
+    GetSrc(m, tmpP);
+    IF Len(tmpP) > 0 THEN AppendTok(cmd, tmpP) END;
+    INC(k)
+  END;
+  AppendSeg(cmd, " 2>&1 | tee m2make.log | grep -q Parsed");
+  AppendSeg(cmd, " && qbe -o gen_ssa/");
+  AppendSeg(cmd, progName);
+  AppendSeg(cmd, ".s gen_ssa/");
+  AppendSeg(cmd, progName);
+  AppendSeg(cmd, ".ssa");
+  AppendSeg(cmd, " && cc gen_ssa/");
+  AppendSeg(cmd, progName);
+  AppendSeg(cmd, ".s ");
+  AppendTok(cmd, shimPath);
+  AppendSeg(cmd, " -o ");
+  AppendTok(cmd, out);
+  AppendSeg(cmd, " -lm");
+  CheckLen(cmd);
+  Out(cmd);
+  OutLn;
+  IF dryRun THEN RETURN END;
+  rc := M2makeOS.ExecCmd(cmd);
+  IF rc # 0 THEN
+    Out("M2make: build failed");
+    OutLn;
+    M2makeOS.ExitNow(1)
+  END
+END BuildM2;
+
+PROCEDURE BuildGm2(out: ARRAY OF CHAR);
+VAR cmd, seg: Cmd; flags: Path; obj: Path;
+    k, m: CARDINAL; rc: INTEGER; outT: LONGINT;
+    compiledAny, linkNeeded: BOOLEAN;
+BEGIN
+  Zero(cmd);
+  IFlags(flags);
+  compiledAny := FALSE;
+  k := 0;
+  WHILE k < nOrder DO
+    m := order[k];
+    GetSrc(m, tmpP);
+    IF (NOT modIsMain[m]) AND (Len(tmpP) > 0) THEN
+      BaseObj(tmpP, obj);
+      IF ObjStale(m, obj) THEN
+        Zero(seg);
+        Cat("gm2 -fiso ", flags, seg);
+        IF Len(flags) > 0 THEN
+          Cat(seg, " ", segA);
+          Copy(segA, seg)
+        END;
+        Cat(seg, "-c ", segA);
+        Copy(segA, seg);
+        Cat(seg, tmpP, segA);
+        Copy(segA, seg);
+        AppendCmd(cmd, seg);
+        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, tmpP);
+        IF (NOT modIsMain[m]) AND (Len(tmpP) > 0) THEN
+          BaseObj(tmpP, obj);
+          IF ObjNewerThan(obj, out, outT) THEN linkNeeded := TRUE END
+        END;
+        INC(k)
+      END
+    END
+  END;
+  IF linkNeeded THEN
+    Zero(seg);
+    Cat("gm2 -fiso ", flags, seg);
+    IF Len(flags) > 0 THEN
+      Cat(seg, " ", segA);
+      Copy(segA, seg)
+    END;
+    Cat(seg, "-o ", segA);
+    Copy(segA, seg);
+    Cat(seg, out, segA);
+    Copy(segA, seg);
+    Cat(seg, " ", segA);
+    Copy(segA, seg);
+    GetSrc(VAL(CARDINAL, mainIdx), tmpP);
+    Cat(seg, tmpP, segA);
+    Copy(segA, seg);
+    k := 0;
+    WHILE k < nOrder DO
+      m := order[k];
+      GetSrc(m, tmpP);
+      IF (NOT modIsMain[m]) AND (Len(tmpP) > 0) THEN
+        BaseObj(tmpP, obj);
+        Cat(seg, " ", segA);
+        Copy(segA, seg);
+        Cat(seg, obj, segA);
+        Copy(segA, seg)
+      END;
+      INC(k)
+    END;
+    AppendCmd(cmd, seg)
+  END;
+  IF Len(cmd) = 0 THEN
+    Out("M2make: ");
+    Out(out);
+    Out(" up to date");
+    OutLn;
+    RETURN
+  END;
+  CheckLen(cmd);
+  Out(cmd);
+  OutLn;
+  IF dryRun THEN RETURN END;
+  rc := M2makeOS.ExecCmd(cmd);
+  IF rc # 0 THEN
+    Out("M2make: build failed");
+    OutLn;
+    M2makeOS.ExitNow(1)
+  END
+END BuildGm2;
+
+PROCEDURE PrintOrder;
+VAR k: CARDINAL; m: CARDINAL;
+BEGIN
+  Out("M2make: build order");
+  OutLn;
+  k := 0;
+  WHILE k < nOrder DO
+    m := order[k];
+    Out("  ");
+    GetName(m, tmpN);
+    Out(tmpN);
+    GetDef(m, tmpP);
+    IF Len(tmpP) > 0 THEN
+      Out(" : ");
+      Out(tmpP)
+    END;
+    GetSrc(m, tmpP);
+    IF Len(tmpP) > 0 THEN
+      GetDef(m, tmpQ);
+      IF Len(tmpQ) > 0 THEN Out(" ") ELSE Out(" : ") END;
+      Out(tmpP)
+    END;
+    OutLn;
+    INC(k)
+  END
+END PrintOrder;
+
+(* ---------------- argument handling ---------------- *)
+
+PROCEDURE ParseArgs;
+VAR na: CARDINAL; s: Path; ok: BOOLEAN;
+BEGIN
+  dryRun := FALSE;
+  verbose := FALSE;
+  useGm2 := FALSE;
+  outGiven := FALSE;
+  Zero(mainPath);
+  Zero(outName);
+  Copy("M2", m2bin);
+  Copy("shim.c", shimPath);
+  nDirs := 0;
+  na := 0;
+  LOOP
+    IF na >= MaxArgs THEN Die("M2make: too many arguments", 2) END;
+    ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
+    IF NOT ok THEN EXIT END;
+    INC(na);
+    IF Cmp(s, "-n") = 0 THEN dryRun := TRUE
+    ELSIF Cmp(s, "-v") = 0 THEN verbose := TRUE
+    ELSIF Cmp(s, "-c") = 0 THEN
+      ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
+      IF NOT ok THEN
+        Out("M2make: -c expects m2 or gm2"); OutLn;
+        M2makeOS.ExitNow(2)
+      END;
+      INC(na);
+      IF Cmp(s, "gm2") = 0 THEN useGm2 := TRUE
+      ELSIF Cmp(s, "m2") = 0 THEN useGm2 := FALSE
+      ELSE
+        Out("M2make: -c expects m2 or gm2"); OutLn;
+        M2makeOS.ExitNow(2)
+      END
+    ELSIF Cmp(s, "-I") = 0 THEN
+      ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
+      IF NOT ok THEN
+        Out("M2make: -I expects a directory"); OutLn;
+        M2makeOS.ExitNow(2)
+      END;
+      INC(na);
+      IF nDirs >= MaxDirs THEN Die("M2make: too many -I dirs", 2) END;
+      Copy(s, tmpP);
+      PutDir(nDirs, tmpP);
+      INC(nDirs)
+    ELSIF Cmp(s, "-o") = 0 THEN
+      ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
+      IF NOT ok THEN
+        Out("M2make: -o expects a name"); OutLn;
+        M2makeOS.ExitNow(2)
+      END;
+      INC(na);
+      Copy(s, outName);
+      outGiven := TRUE
+    ELSIF Cmp(s, "--m2bin") = 0 THEN
+      ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
+      IF NOT ok THEN
+        Out("M2make: --m2bin expects a path"); OutLn;
+        M2makeOS.ExitNow(2)
+      END;
+      INC(na);
+      Copy(s, m2bin)
+    ELSIF Cmp(s, "--shim") = 0 THEN
+      ok := M2makeOS.GetArg(na, s, HIGH(s) + 1);
+      IF NOT ok THEN
+        Out("M2make: --shim expects a path"); OutLn;
+        M2makeOS.ExitNow(2)
+      END;
+      INC(na);
+      Copy(s, shimPath)
+    ELSIF (Cmp(s, "-h") = 0) OR (Cmp(s, "--help") = 0) THEN
+      Usage;
+      M2makeOS.ExitNow(0)
+    ELSIF s[0] = "-" THEN
+      Out("M2make: unknown option ");
+      Out(s);
+      OutLn;
+      Usage;
+      M2makeOS.ExitNow(2)
+    ELSE
+      IF Len(mainPath) > 0 THEN
+        Out("M2make: only one main file"); OutLn;
+        Usage;
+        M2makeOS.ExitNow(2)
+      END;
+      Copy(s, mainPath)
+    END
+  END;
+  IF Len(mainPath) = 0 THEN
+    Usage;
+    M2makeOS.ExitNow(2)
+  END
+END ParseArgs;
+
+(* ---------------- main ---------------- *)
+
+VAR
+  missing: BOOLEAN;
+  rebuild: BOOLEAN;
+
+BEGIN
+  nMods := 0;
+  nOrder := 0;
+  wantProg := TRUE;
+  Zero(progName);
+  ParseArgs;
+  IF NOT M2makeOS.FileExists(mainPath) THEN
+    Cat("M2make: main file not found ", mainPath, tmpCmd);
+    Die(tmpCmd, 2)
+  END;
+  DirOf(mainPath, tmpA);
+  IF nDirs = 0 THEN
+    Copy(tmpA, tmpP);
+    PutDir(0, tmpP);
+    nDirs := 1
+  ELSE
+    (* main dir becomes an extra search dir when -I was given *)
+    IF nDirs >= MaxDirs THEN Die("M2make: too many -I dirs", 2) END;
+    Copy(tmpA, tmpP);
+    PutDir(nDirs, tmpP);
+    INC(nDirs)
+  END;
+  mainIdx := AddMod("M2makeMain");
+  modIsMain[VAL(CARDINAL, mainIdx)] := TRUE;
+  Copy(mainPath, tmpP);
+  PutSrc(VAL(CARDINAL, mainIdx), tmpP);
+  ResolveAll;
+  IF Len(progName) = 0 THEN
+    Die("M2make: no MODULE declaration in main file", 1)
+  END;
+  IF NOT outGiven THEN Copy(progName, outName) END;
+  TopoSort;
+  IF verbose OR dryRun THEN PrintOrder END;
+  rebuild := NeedBuild(outName, missing);
+  IF NOT rebuild THEN
+    Out("M2make: ");
+    Out(outName);
+    Out(" up to date");
+    OutLn
+  ELSE
+    IF useGm2 THEN BuildGm2(outName)
+    ELSE BuildM2(outName)
+    END
+  END
+END M2make.

+ 15 - 0
tools/m2make/M2makeC.def

@@ -0,0 +1,15 @@
+DEFINITION MODULE FOR "C" M2makeC;
+(* GNU Modula-2 binding of the NUL-string (_s) half of m2make_os.c.
+   gm2-only file: imported solely by M2makeOS_gm2.mod. *)
+FROM SYSTEM IMPORT ADDRESS;
+EXPORT UNQUALIFIED m2osExistS, m2osMtimeS, m2osSystemS, m2osBye,
+                   m2osOpenreadS, m2osReadlineS, m2osClose;
+PROCEDURE m2osExistS(s: ARRAY OF CHAR): BOOLEAN;
+PROCEDURE m2osMtimeS(s: ARRAY OF CHAR; VAR t: LONGINT): BOOLEAN;
+PROCEDURE m2osSystemS(s: ARRAY OF CHAR): INTEGER;
+PROCEDURE m2osBye(code: INTEGER);
+PROCEDURE m2osOpenreadS(s: ARRAY OF CHAR): ADDRESS;
+PROCEDURE m2osReadlineS(h: ADDRESS; VAR buf: ARRAY OF CHAR;
+                          cap: CARDINAL): BOOLEAN;
+PROCEDURE m2osClose(h: ADDRESS);
+END M2makeC.

+ 52 - 0
tools/m2make/M2makeOS.def

@@ -0,0 +1,52 @@
+DEFINITION MODULE M2makeOS;
+(* OS bridge for M2make: file existence/timestamps, command execution,
+   process exit, and sequential text input.
+   This definition module is compiler-neutral (plain ISO Modula-2 plus
+   SYSTEM.ADDRESS, available in both GNU Modula-2 and the V3 M2
+   compiler).  The two implementations share no source:
+     M2makeOS_gm2.mod  binds m2make_os.c via M2makeC (FOR "C"),
+     M2makeOS_v3.mod   binds m2make_os.c via EXTERNAL.
+   Strings are NUL-terminated buffers on both sides (V3 fixed buffers
+   are NUL-padded with count == capacity), so no length bookkeeping
+   crosses this boundary. *)
+
+FROM SYSTEM IMPORT ADDRESS;
+
+TYPE
+  RdFile = ADDRESS;  (* opaque read handle; NIL when OpenRead fails *)
+
+PROCEDURE FileExists(VAR name: ARRAY OF CHAR): BOOLEAN;
+(* TRUE when name can be statted. *)
+
+PROCEDURE FileMTime(VAR name: ARRAY OF CHAR; VAR t: LONGINT): BOOLEAN;
+(* t := modification time (seconds since the epoch) of name.
+   FALSE when the file cannot be statted. *)
+
+PROCEDURE ExecCmd(VAR cmd: ARRAY OF CHAR): INTEGER;
+(* Runs cmd via /bin/sh; returns the system() status (0 = success). *)
+
+PROCEDURE ExitNow(code: INTEGER);
+(* Terminates the process with the given exit code. Does not return. *)
+
+PROCEDURE OpenRead(VAR name: ARRAY OF CHAR): RdFile;
+(* Opens name for sequential text input; NIL on failure. *)
+
+PROCEDURE ArgCount(): CARDINAL;
+(* Number of command-line arguments, not counting the program name. *)
+
+PROCEDURE GetArg(i: CARDINAL; VAR s: ARRAY OF CHAR;
+                 cap: CARDINAL): BOOLEAN;
+(* Copies argument i (0-based, program name excluded) into s, truncated
+   to cap-1 characters plus NUL.  FALSE when there is no such
+   argument or cap is 0. *)
+
+PROCEDURE ReadLine(h: RdFile; VAR buf: ARRAY OF CHAR;
+                   cap: CARDINAL): BOOLEAN;
+(* Reads the next physical line (newline stripped) into buf, truncated
+   to cap-1 characters plus NUL.  FALSE at end of file (a final line
+   without a newline still returns TRUE once). *)
+
+PROCEDURE CloseRead(h: RdFile);
+(* Closes a handle from OpenRead (NIL is ignored). *)
+
+END M2makeOS.

+ 120 - 0
tools/m2make/M2makeOS_gm2.mod

@@ -0,0 +1,120 @@
+IMPLEMENTATION MODULE M2makeOS;
+(* GNU Modula-2 implementation: binds the NUL-string (_s) half of
+   m2make_os.c plus the shared m2osBye/m2osClose via M2makeC.
+   Arguments come from the host FileIO (which already skips argv[0])
+   and are cached in a static table.  Compiled only by gm2 (staged
+   as M2makeOS.mod). *)
+FROM SYSTEM IMPORT ADDRESS;
+IMPORT M2makeC, FileIO, IOChan, StdChans;
+
+TYPE
+  AStr = ARRAY [0..255] OF CHAR;
+
+VAR
+  argTab: ARRAY [0..63] OF AStr;
+  argN: CARDINAL;
+  argDrained: BOOLEAN;
+
+PROCEDURE CopyArg(src: ARRAY OF CHAR; VAR dst: ARRAY OF CHAR);
+VAR i: CARDINAL;
+BEGIN
+  i := 0;
+  WHILE (i <= HIGH(src)) AND (i <= HIGH(dst)) DO
+    dst[i] := src[i];
+    IF src[i] = CHR(0) THEN RETURN END;
+    INC(i)
+  END;
+  dst[HIGH(dst)] := CHR(0)
+END CopyArg;
+
+PROCEDURE Drain;
+VAR s: AStr; i: CARDINAL;
+BEGIN
+  IF argDrained THEN RETURN END;
+  argDrained := TRUE;
+  argN := 0;
+  i := 0;
+  WHILE i <= HIGH(s) DO s[i] := CHR(0); INC(i) END;
+  LOOP
+    FileIO.NextParameter(s);
+    IF s[0] = CHR(0) THEN EXIT END;
+    IF argN > HIGH(argTab) THEN EXIT END;
+    CopyArg(s, argTab[argN]);
+    INC(argN)
+  END
+END Drain;
+
+PROCEDURE FileExists(VAR name: ARRAY OF CHAR): BOOLEAN;
+BEGIN
+  RETURN M2makeC.m2osExistS(name)
+END FileExists;
+
+PROCEDURE FileMTime(VAR name: ARRAY OF CHAR; VAR t: LONGINT): BOOLEAN;
+BEGIN
+  RETURN M2makeC.m2osMtimeS(name, t)
+END FileMTime;
+
+PROCEDURE ExecCmd(VAR cmd: ARRAY OF CHAR): INTEGER;
+BEGIN
+  RETURN M2makeC.m2osSystemS(cmd)
+END ExecCmd;
+
+PROCEDURE ExitNow(code: INTEGER);
+BEGIN
+  (* Flush gm2's channel buffers: raw exit() would drop the tail. *)
+  IOChan.Flush(StdChans.StdOutChan());
+  M2makeC.m2osBye(code)
+END ExitNow;
+
+PROCEDURE OpenRead(VAR name: ARRAY OF CHAR): RdFile;
+BEGIN
+  RETURN M2makeC.m2osOpenreadS(name)
+END OpenRead;
+
+PROCEDURE ArgCount(): CARDINAL;
+BEGIN
+  Drain;
+  RETURN argN
+END ArgCount;
+
+PROCEDURE ZeroArg(VAR s: ARRAY OF CHAR);
+VAR k: CARDINAL;
+BEGIN
+  k := 0;
+  WHILE k <= HIGH(s) DO s[k] := CHR(0); INC(k) END
+END ZeroArg;
+
+PROCEDURE GetArg(i: CARDINAL; VAR s: ARRAY OF CHAR;
+                 cap: CARDINAL): BOOLEAN;
+VAR k: CARDINAL; done: BOOLEAN;
+BEGIN
+  Drain;
+  IF cap = 0 THEN RETURN FALSE END;
+  IF i >= argN THEN RETURN FALSE END;
+  ZeroArg(s);
+  k := 0;
+  done := FALSE;
+  WHILE (NOT done) AND (k + 1 < cap) AND (k <= HIGH(argTab[i]))
+    AND (k <= HIGH(s)) DO
+    IF argTab[i][k] = CHR(0) THEN done := TRUE
+    ELSE s[k] := argTab[i][k]; INC(k)
+    END
+  END;
+  RETURN TRUE
+END GetArg;
+
+PROCEDURE ReadLine(h: RdFile; VAR buf: ARRAY OF CHAR;
+                   cap: CARDINAL): BOOLEAN;
+BEGIN
+  RETURN M2makeC.m2osReadlineS(h, buf, cap)
+END ReadLine;
+
+PROCEDURE CloseRead(h: RdFile);
+BEGIN
+  M2makeC.m2osClose(h)
+END CloseRead;
+
+BEGIN
+  argDrained := FALSE;
+  argN := 0
+END M2makeOS.

+ 75 - 0
tools/m2make/M2makeOS_v3.mod

@@ -0,0 +1,75 @@
+IMPLEMENTATION MODULE M2makeOS;
+(* V3 (self-hosted M2) implementation: binds the descriptor (_d) half
+   of m2make_os.c plus the shared m2osBye/m2osClose via EXTERNAL.
+   Compiled only by the V3 M2 driver (staged as M2makeOS.mod). *)
+FROM SYSTEM IMPORT ADDRESS;
+
+PROCEDURE m2osExistD(VAR s: ARRAY OF CHAR): BOOLEAN;
+  EXTERNAL;
+PROCEDURE m2osMtimeD(VAR s: ARRAY OF CHAR; VAR t: LONGINT): BOOLEAN;
+  EXTERNAL;
+PROCEDURE m2osSystemD(VAR s: ARRAY OF CHAR): INTEGER;
+  EXTERNAL;
+PROCEDURE m2osBye(code: INTEGER);
+  EXTERNAL;
+PROCEDURE m2osOpenreadD(VAR s: ARRAY OF CHAR): ADDRESS;
+  EXTERNAL;
+PROCEDURE m2osArgcD () : CARDINAL;
+  EXTERNAL;
+PROCEDURE m2osArgD(i: CARDINAL; VAR s: ARRAY OF CHAR;
+                     cap: CARDINAL): BOOLEAN;
+  EXTERNAL;
+PROCEDURE m2osReadlineD(h: ADDRESS; VAR buf: ARRAY OF CHAR;
+                          cap: CARDINAL): BOOLEAN;
+  EXTERNAL;
+PROCEDURE m2osClose(h: ADDRESS);
+  EXTERNAL;
+
+PROCEDURE FileExists(VAR name: ARRAY OF CHAR): BOOLEAN;
+BEGIN
+  RETURN m2osExistD(name)
+END FileExists;
+
+PROCEDURE FileMTime(VAR name: ARRAY OF CHAR; VAR t: LONGINT): BOOLEAN;
+BEGIN
+  RETURN m2osMtimeD(name, t)
+END FileMTime;
+
+PROCEDURE ExecCmd(VAR cmd: ARRAY OF CHAR): INTEGER;
+BEGIN
+  RETURN m2osSystemD(cmd)
+END ExecCmd;
+
+PROCEDURE ExitNow(code: INTEGER);
+BEGIN
+  m2osBye(code)
+END ExitNow;
+
+PROCEDURE OpenRead(VAR name: ARRAY OF CHAR): RdFile;
+BEGIN
+  RETURN m2osOpenreadD(name)
+END OpenRead;
+
+PROCEDURE ArgCount () : CARDINAL;
+BEGIN
+  RETURN m2osArgcD()
+END ArgCount;
+
+PROCEDURE GetArg(i: CARDINAL; VAR s: ARRAY OF CHAR;
+                 cap: CARDINAL): BOOLEAN;
+BEGIN
+  RETURN m2osArgD(i, s, cap)
+END GetArg;
+
+PROCEDURE ReadLine(h: RdFile; VAR buf: ARRAY OF CHAR;
+                   cap: CARDINAL): BOOLEAN;
+BEGIN
+  RETURN m2osReadlineD(h, buf, cap)
+END ReadLine;
+
+PROCEDURE CloseRead(h: RdFile);
+BEGIN
+  m2osClose(h)
+END CloseRead;
+
+END M2makeOS.

+ 83 - 0
tools/m2make/README.md

@@ -0,0 +1,83 @@
+# M2make
+
+Build-order front-end for the M2 (V3) and GNU Modula-2 compilers.
+Given a main file (`M2make main.mod`), it follows `IMPORT`/`FROM`
+imports transitively, topologically sorts the modules, skips the
+build when the output is newer than every source, and otherwise
+invokes the selected compiler. Exit codes: 0 success (or nothing
+to do), 1 build failure (incl. import cycles), 2 usage error.
+
+```sh
+M2make [-n] [-v] [-c m2|gm2] [-I dir] [-o out]
+       [--m2bin path] [--shim path] main.mod
+```
+
+- `-n`: dry run (print the build order and the command only).
+- `-v`: verbose (external modules, build order).
+- `-c m2|gm2`: compiler to drive (default `m2`).
+- `-I dir`: extra module search directory (repeatable; the main
+  file's own directory is always searched).
+- `-o out`: output binary name (default: the program module name
+  as declared in the main file).
+- `--m2bin path`: M2 driver binary (default `M2`).
+- `--shim path`: C runtime shim for `m2` mode (default `shim.c`).
+
+`m2` mode runs one session — `<m2bin> <ordered .def/.mod files>`
+then `qbe` then `cc <image>.s <shim> -o <out> -lm` — and gates on
+M2's `Parsed correctly` verdict (M2 exits 0 even on failure).
+`gm2` mode recompiles only stale units (`gm2 -fiso -c`, objects
+land in the cwd as `<Base>.o`) and links the main file with the
+objects in topological order.
+
+## Building M2make itself
+
+The portable core is `M2make.mod` + `M2makeOS.def` (plain ISO
+Modula-2 importing only `FileIO` and `M2makeOS`). Everything
+non-portable lives in the per-compiler layer:
+
+- `m2make_os.c`: `stat`/`system`/`exit`/text-input/`/proc` argv
+  helpers, with descriptor (`_d`, V3) and NUL-string (`_s`, gm2)
+  variants.
+- gm2 side: `M2makeC.def` (`FOR "C"` binding) + `M2makeOS_gm2.mod`.
+- V3 side: `M2makeOS_v3.mod` (`EXTERNAL` binding).
+
+```sh
+./build_gm2.sh   # gm2 -fiso -> b_gm2/M2make
+./build_m2.sh    # M2 session + qbe + cc -> b_v3/M2make
+./tests/run_tests.sh
+```
+
+## Source conventions (both compilers must accept the code)
+
+`M2make.mod` is restricted to the intersection the two compilers
+actually implement (all learned the hard way, via probes):
+
+- No `_` in identifiers, no `0C` (use `CHR(0)`), empty parameter
+  lists need a space (`PROCEDURE P () : T`).
+- Declaration before use (V3 is single-pass; gm2 tolerates
+  forward references).
+- All strings NUL-terminated; own `Len/Copy/Cmp/Cat` on top of
+  indexed access (the two `FileIO` string layers differ).
+- Flat 1D arrays only: V3 rejects `a[i][j]` loads (233) and
+  miscompiles nested-array elements passed as open-array actuals
+  (double load → segfault).
+- Never rely on `AND`/`OR` short-circuit: V3 evaluates both sides
+  and traps out-of-range indexing, so guards must dominate
+  accesses structurally.
+- `LONGINT` only via `VAR` out-parameters (V3 rejects `LONGINT`
+  function results in calls); never print negative `INTEGER`s.
+- No `ExitCode` global (gm2 has none): all exits via
+  `M2makeOS.ExitNow`. No `SYSTEM`/`Storage` in portable code.
+
+## Limitations
+
+- File names must match module names (exact or lowercase) with a
+  `.def`/`.mod` extension; unknown modules are treated as external
+  (system) libraries and skipped.
+- One main file per invocation; no spaces in paths; physical lines
+  past ~2047 characters are split across reads.
+- `m2` mode rebuilds the whole session image when any source is
+  stale (M2's session model); per-file incrementality is a `gm2`
+  -mode feature.
+- `gm2 -c` drops `<Base>.o` in the cwd: two same-named modules in
+  different directories collide.

+ 24 - 0
tools/m2make/build_gm2.sh

@@ -0,0 +1,24 @@
+#!/bin/sh
+# Builds M2make with GNU Modula-2 (gm2 -fiso).
+# Usage: ./build_gm2.sh   (from the tools/m2make directory)
+# Output: b_gm2/M2make
+set -e
+cd "$(dirname "$0")"
+SRC=../../compiler/src
+rm -rf b_gm2
+mkdir -p b_gm2
+cp M2make.mod M2makeOS.def M2makeC.def m2make_os.c b_gm2/
+cp M2makeOS_gm2.mod b_gm2/M2makeOS.mod
+cp "$SRC/FileIO.def" "$SRC/FileIO.mod" b_gm2/
+cd b_gm2
+echo "=== Compiling (gm2) ==="
+for m in FileIO M2makeOS M2make; do
+  gm2 -fiso -c "$m.mod" || exit 1
+done
+echo "=== Generating module list ==="
+gm2 -fiso -fgen-module-list=modules.lst -o /dev/null \
+    FileIO.o M2makeOS.o M2make.mod m2make_os.c || exit 1
+echo "=== Linking ==="
+gm2 -fiso -fuse-list=modules.lst -o M2make \
+    FileIO.o M2makeOS.o M2make.mod m2make_os.c || exit 1
+echo "=== b_gm2/M2make built ==="

+ 30 - 0
tools/m2make/build_m2.sh

@@ -0,0 +1,30 @@
+#!/bin/sh
+# Builds M2make with the V3 M2 compiler itself.
+# Usage: ./build_m2.sh   (from the tools/m2make directory)
+# Output: b_v3/M2make (a V3-built binary)
+#
+# Session: SysShim + FileIO (runtime/syslib) + M2makeOS + M2make,
+# then qbe + cc with the runtime shim and m2make_os.c.
+set -e
+cd "$(dirname "$0")"
+M2=../../compiler/M2
+SYS=../../runtime/syslib
+rm -rf b_v3
+mkdir -p b_v3
+cp M2make.mod M2makeOS.def m2make_os.c b_v3/
+cp M2makeOS_v3.mod b_v3/M2makeOS.mod
+cd b_v3
+mkdir -p gen_ssa
+echo "=== Compiling (M2 session) ==="
+# NB: M2 exits 0 even when it reports "Incorrect source", so gate on
+# the verdict text instead of the exit code.
+"../$M2" \
+    "../$SYS/SysShim.def" "../$SYS/SysShim.mod" \
+    "../$SYS/FileIO.def" "../$SYS/FileIO.mod" \
+    M2makeOS.def M2makeOS.mod M2make.mod \
+    2>&1 | tee m2.log | grep -q Parsed || exit 1
+echo "=== Assembling and linking ==="
+qbe -o gen_ssa/M2make.s gen_ssa/M2make.ssa || exit 1
+cc gen_ssa/M2make.s "../$SYS/shim.c" m2make_os.c \
+    -o M2make -lm || exit 1
+echo "=== b_v3/M2make built ==="

+ 247 - 0
tools/m2make/m2make_os.c

@@ -0,0 +1,247 @@
+/* M2make OS helpers (C).
+ *
+ * Dual-ABI file/time/process primitives for the M2make front-end.
+ *
+ * Two string ABIs exist and both are served here:
+ *   - V3 (m2compiler-V3):  ARRAY OF CHAR is a descriptor (LONGCARD
+ *     count + bytes (see runtime/syslib/shim.c).  V3 buffers handed
+ *     to M2make are NUL-padded with count == capacity (verified by
+ *     probe), so the _d variants scan for NUL bounded by the count.
+ *   - GNU Modula-2: ARRAY OF CHAR is a NUL-terminated C buffer;
+ *     the _s variants use it directly.
+ *
+ * Handle-only calls (close) are ABI-neutral and shared.
+ * Only the string-taking calls need _d/_s pairs.
+ * Readline never overruns: Modula-2 passes cap (HIGH(buf)+1) and at
+ * most cap-1 bytes plus NUL are stored.  Physical lines longer than
+ * the C temp (2048) continue on the next call; the Modula-2 scanner
+ * keeps comment state across lines, so this only splits very long
+ * lines (documented in the README).
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#define M2OS_TMP 2048
+
+/* Copy a V3 descriptor to a NUL-terminated buffer (NUL scan bounded
+   by the descriptor count, then by cap). */
+static void desc2c(long *desc, char *buf, unsigned long cap)
+{
+    long count = desc[0];
+    unsigned long bound;
+    unsigned long i = 0;
+
+    if (count < 0)
+        count = 0;
+    bound = (unsigned long)count;
+    if (bound >= cap)
+        bound = cap - 1;
+    while (i < bound && ((char *)(desc + 1))[i] != 0) {
+        buf[i] = ((char *)(desc + 1))[i];
+        i++;
+    }
+    buf[i] = 0;
+}
+
+/* ---------------- existence ---------------- */
+
+long m2osExistD(long *desc)
+{
+    char buf[4096];
+    struct stat st;
+
+    desc2c(desc, buf, sizeof buf);
+    return (stat(buf, &st) == 0);
+}
+
+long m2osExistS(char *s)
+{
+    struct stat st;
+
+    return (stat(s, &st) == 0);
+}
+
+/* ---------------- modification time ----------------
+ * 1 with *t set on success, 0 when the file cannot be statted. */
+
+long m2osMtimeD(long *desc, long long *t)
+{
+    char buf[4096];
+    struct stat st;
+
+    desc2c(desc, buf, sizeof buf);
+    if (stat(buf, &st) != 0)
+        return 0;
+    *t = (long long)st.st_mtime;
+    return 1;
+}
+
+long m2osMtimeS(char *s, long long *t)
+{
+    struct stat st;
+
+    if (stat(s, &st) != 0)
+        return 0;
+    *t = (long long)st.st_mtime;
+    return 1;
+}
+
+/* ---------------- process execution ----------------
+ * Runs cmd via /bin/sh, returns the raw system() code. */
+
+long m2osSystemD(long *desc)
+{
+    char buf[4096];
+
+    desc2c(desc, buf, sizeof buf);
+    return (long)system(buf);
+}
+
+long m2osSystemS(char *s)
+{
+    return (long)system(s);
+}
+
+/* ---------------- process exit (ABI-neutral) ---------------- */
+
+void m2os_flush(void)
+{
+    fflush(NULL);
+}
+
+void m2osBye(long code)
+{
+    fflush(NULL);
+    exit((int)code);
+}
+
+/* ---------------- command-line arguments (V3 side) ----------------
+ * Skips argv[0] (the program name), unlike the raw SysShim.arg. */
+
+static char *m2os_args[1024];
+static long m2os_nargs = -1;
+
+static void m2os_argv_init(void)
+{
+    static char cmd[65536];
+    FILE *f;
+    long i, n;
+
+    if (m2os_nargs >= 0)
+        return;
+    m2os_nargs = 0;
+    f = fopen("/proc/self/cmdline", "rb");
+    if (f == NULL)
+        return;
+    n = (long)fread(cmd, 1, sizeof cmd - 1, f);
+    fclose(f);
+    if (n <= 0)
+        return;
+    cmd[n] = 0;
+    i = 0;
+    while (i < n && m2os_nargs < 1024) {
+        while (i < n && cmd[i] == 0)
+            i++;
+        if (i >= n)
+            break;
+        m2os_args[m2os_nargs++] = &cmd[i];
+        while (i < n && cmd[i] != 0)
+            i++;
+    }
+}
+
+long m2osArgcD(void)
+{
+    m2os_argv_init();
+    if (m2os_nargs <= 0)
+        return 0;
+    return m2os_nargs - 1;
+}
+
+long m2osArgD(long i, long *desc, unsigned long cap)
+{
+    const char *a;
+    unsigned long n, k;
+    char *out;
+
+    m2os_argv_init();
+    if (cap == 0)
+        return 0;
+    if (i < 0 || i + 1 >= m2os_nargs)
+        return 0;
+    a = m2os_args[i + 1];
+    n = (unsigned long)strlen(a);
+    if (n >= cap)
+        n = cap - 1;
+    out = (char *)(desc + 1);
+    for (k = 0; k < n; k++)
+        out[k] = a[k];
+    out[n] = 0;
+    return 1;
+}
+
+/* ---------------- sequential text input ---------------- */
+
+long m2osOpenreadD(long *desc)
+{
+    char buf[4096];
+
+    desc2c(desc, buf, sizeof buf);
+    return (long)fopen(buf, "r");
+}
+
+long m2osOpenreadS(char *s)
+{
+    return (long)fopen(s, "r");
+}
+
+/* Shared readline core: 1 with a line stored, 0 at end of file.
+ * The trailing newline (and a preceding carriage return) is
+ * stripped.  A final line without a newline still returns 1. */
+
+static long readline_core(FILE *fp, char *out, unsigned long cap)
+{
+    static char tmp[M2OS_TMP];
+    unsigned long n, i;
+
+    if (cap == 0)
+        return 0;
+    if (fgets(tmp, sizeof tmp, fp) == NULL)
+        return 0;
+    n = (unsigned long)strlen(tmp);
+    while (n > 0 && (tmp[n - 1] == 10 || tmp[n - 1] == 13))
+        n--;
+    if (n >= cap)
+        n = cap - 1;
+    for (i = 0; i < n; i++)
+        out[i] = tmp[i];
+    out[n] = 0;
+    return 1;
+}
+
+long m2osReadlineD(long h, long *desc, unsigned long cap)
+{
+    char *out;
+
+    if (h == 0 || cap == 0)
+        return 0;
+    out = (char *)(desc + 1);
+    return readline_core((FILE *)h, out, cap);
+}
+
+long m2osReadlineS(long h, char *buf, unsigned long cap)
+{
+    if (h == 0)
+        return 0;
+    return readline_core((FILE *)h, buf, cap);
+}
+
+long m2osClose(long h)
+{
+    if (h != 0)
+        fclose((FILE *)h);
+    return 0;
+}

+ 6 - 0
tools/m2make/tests/Calc.def

@@ -0,0 +1,6 @@
+DEFINITION MODULE Calc;
+(* Sample library for M2make tests: file name matches module name. *)
+
+PROCEDURE Add(a, b: INTEGER): INTEGER;
+
+END Calc.

+ 9 - 0
tools/m2make/tests/Calc.mod

@@ -0,0 +1,9 @@
+IMPLEMENTATION MODULE Calc;
+(* Sample library implementation. *)
+
+PROCEDURE Add(a, b: INTEGER): INTEGER;
+BEGIN
+  RETURN a + b
+END Add;
+
+END Calc.

+ 5 - 0
tools/m2make/tests/CycA.mod

@@ -0,0 +1,5 @@
+MODULE CycA;
+(* Cycle test: A imports B. *)
+IMPORT CycB;
+BEGIN
+END CycA.

+ 5 - 0
tools/m2make/tests/CycB.mod

@@ -0,0 +1,5 @@
+MODULE CycB;
+(* Cycle test: B imports A. *)
+IMPORT CycA;
+BEGIN
+END CycB.

+ 12 - 0
tools/m2make/tests/Main.mod

@@ -0,0 +1,12 @@
+MODULE Main;
+(* Sample program for M2make tests (V3 flavor: SysIO + ExitCode). *)
+FROM Calc IMPORT Add;
+IMPORT SysIO;
+VAR ExitCode: INTEGER;
+VAR r: INTEGER;
+BEGIN
+  r := Add(20, 22);
+  SysIO.WriteInt(r);
+  SysIO.WriteLn;
+  ExitCode := 0
+END Main.

+ 12 - 0
tools/m2make/tests/Main2.mod

@@ -0,0 +1,12 @@
+MODULE Main2;
+(* Sample program for M2make tests (gm2 flavor: StrIO). *)
+FROM Calc IMPORT Add;
+IMPORT StrIO;
+VAR r: INTEGER;
+BEGIN
+  r := Add(20, 22);
+  IF r = 42 THEN StrIO.WriteString("ok42")
+  ELSE StrIO.WriteString("bad")
+  END;
+  StrIO.WriteLn
+END Main2.

+ 107 - 0
tools/m2make/tests/run_tests.sh

@@ -0,0 +1,107 @@
+#!/bin/sh
+# M2make regression tests. Builds M2make with both compilers (via the
+# sibling build scripts) and exercises ordering, up-to-date checks,
+# rebuilds, cycles, and real builds.
+# Usage: ./run_tests.sh   (from the tools/m2make directory)
+pass=0; fail=0
+
+ok() { pass=$((pass+1)); echo "PASS: $1"; }
+no() { fail=$((fail+1)); echo "FAIL: $1"; }
+
+# --- bootstrapping: both compilers must build M2make ---
+./build_gm2.sh > /tmp/m2make_t.log 2>&1
+if [ -x b_gm2/M2make ]; then ok "gm2 builds M2make"; else no "gm2 builds M2make"; fi
+./build_m2.sh > /tmp/m2make_t.log 2>&1
+if [ -x b_v3/M2make ]; then ok "M2 builds M2make"; else no "M2 builds M2make"; fi
+
+GM2=b_gm2/M2make
+V3M=b_v3/M2make
+
+# --- dry-run ordering (both binaries must agree) ---
+for bin in "$GM2" "$V3M"; do
+  out=$($bin -n -c gm2 tests/Main2.mod 2>&1)
+  calc_line=$(printf '%s\n' "$out" | grep -n "Calc :" | cut -d: -f1)
+  main_line=$(printf '%s\n' "$out" | grep -n "M2makeMain" | cut -d: -f1)
+  if [ -n "$calc_line" ] && [ -n "$main_line" ] && [ "$calc_line" -lt "$main_line" ]; then
+    ok "$bin order (Calc before Main2)"
+  else
+    no "$bin order (Calc before Main2): $out"
+  fi
+  case "$out" in
+    *"Add"*) no "$bin leaks FROM-items as modules: $out" ;;
+    *) ok "$bin ignores FROM-items" ;;
+  esac
+done
+
+# --- cycle detection ---
+for bin in "$GM2" "$V3M"; do
+  out=$($bin -n -c gm2 tests/CycA.mod 2>&1); rc=$?
+  case "$out" in
+    *"cyclic import"*) ok "$bin reports cycle" ;;
+    *) no "$bin reports cycle: $out" ;;
+  esac
+  if [ "$rc" = "1" ]; then ok "$bin cycle exit code 1"; else no "$bin cycle exit code ($rc)"; fi
+done
+
+# --- real gm2 build, run, up-to-date, rebuild ---
+rm -f Main2 *.o
+if $GM2 -c gm2 -o Main2 tests/Main2.mod > /tmp/m2make_t.log 2>&1; then
+  ok "gm2-mode real build"
+else
+  no "gm2-mode real build"
+fi
+if [ "$(./Main2 2>/dev/null)" = "ok42" ]; then
+  ok "gm2-built program runs (ok42)"
+else
+  no "gm2-built program runs"
+fi
+out=$($GM2 -c gm2 -o Main2 tests/Main2.mod 2>&1)
+case "$out" in
+  *"up to date"*) ok "gm2-mode up-to-date skip" ;;
+  *) no "gm2-mode up-to-date skip: $out" ;;
+esac
+sleep 1
+touch tests/Calc.mod
+out=$($GM2 -c gm2 -o Main2 tests/Main2.mod 2>&1)
+case "$out" in
+  *"-c tests/Calc.mod"*) ok "gm2-mode recompiles touched dep" ;;
+  *) no "gm2-mode recompiles touched dep: $out" ;;
+esac
+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
+  ok "m2-mode real build (V3 M2make)"
+else
+  no "m2-mode real build (V3 M2make)"
+fi
+if ./Main 2>/dev/null | grep -q "42"; then
+  ok "m2-built program runs (42)"
+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)
+case "$out" in
+  *"up to date"*) ok "m2-mode up-to-date skip" ;;
+  *) no "m2-mode up-to-date skip: $out" ;;
+esac
+
+# --- 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 \
+     && ./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 -rf gen_ssa
+rm -f m2make.log
+
+echo "--- $pass passed, $fail failed ---"
+[ "$fail" -eq 0 ]