IC: bugfixes and test cases

This commit is contained in:
Araq
2026-06-30 11:05:30 +02:00
parent a9b241ff05
commit 32989998e6
11 changed files with 625 additions and 3 deletions

1
.gitignore vendored
View File

@@ -87,6 +87,7 @@ tweeter_test.db
/tests/megatest.nim
/tests/ic/*_temp.nim
/tests/ic/*_mm/
/tests/navigator/*_temp.nim

View File

@@ -956,7 +956,12 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
result = nimcache / c.nodes[0].files[0].modname & ".backend.build.nif"
let mainNif = c.nodes[0].files[0].nimFile
let exeFile = changeFileExt(c.nodes[0].files[0].nimFile, ExeExt)
# Honor `--out`/`--outdir`: `cmdIc`'s `setOutFile` populated `conf.outFile`
# (the user's `--out`, or the default `<project><exeExt>`), so `absOutFile` is
# the final link target — exactly what a whole-program `nim c` would produce.
# The `link` child computes its own output from its project name, so the path
# is also forwarded to it below.
let exeFile = string(c.config.absOutFile)
let mergeFile = nimcache / MergeDecisionFile
# Per-node output paths.
@@ -1117,6 +1122,11 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
b.addIdent "nim_nifc"
b.withTree "args":
b.addStrLit "--icBackendStage:link"
# The link child is its own `cmdNifC` process whose project is the main
# module, so it would default the binary to `<maindir>/<main><exeExt>`.
# Forward the resolved target so it writes exactly `exeFile` (`--out`'s
# path splits back into outDir+outFile in the child).
b.addStrLit "--out:" & exeFile
for i in 0 ..< c.nodes.len:
if live[i]: inputStr cFiles[i]
outputStr exeFile

View File

@@ -436,6 +436,9 @@ proc mainCommand*(graph: ModuleGraph) =
# Generate .build.nif for nifmake
setUseIc(true)
wantMainModule(conf)
# Resolve the output binary path (honoring `--out`) up front, like cmdNifC:
# the backend build file derives the link target from `conf.absOutFile`.
setOutFile(conf)
when not defined(nimKochBootstrap):
commandIc(conf)
else:

View File

@@ -13,7 +13,7 @@
# included from testament.nim
import important_packages
import std/[strformat, strutils]
import std/[strformat, strutils, tables]
from std/sequtils import filterIt
const
@@ -488,6 +488,221 @@ proc testNimblePackages(r: var TResults; cat: Category; packageFilter: string) =
# ---------------- IC tests ---------------------------------------------
# ---- Metamorphic IC tests --------------------------------------------------
#
# A metamorphic IC test drives a *sequence of edits across several modules*
# through `nim ic` in a fixed build directory (same absolute paths throughout,
# which is what keeps the cache content-stable) and asserts the invariants the
# incremental backend is supposed to guarantee — see doc/ic_ideas.md:
#
# * clean build == incremental build (a fresh in-place rebuild of the
# final sources is byte-identical to
# the binary and full cache set the
# incremental edits converged to)
# * a no-op edit changes no artifact (`noop`)
# * a body-only edit touches no interface (`body-edit`: no `*.iface.bif`
# cookie changes -> no importer re-sem)
# * an interface edit propagates to (`iface-edit`: an `*.iface.bif`
# importers cookie changes and >= 2 modules'
# `*.s.bif` codegen is rebuilt)
#
# File format (a `tests/ic/t*.nim` whose body, after the spec header, contains a
# line `#? metamorphic`):
#
# #? metamorphic
# #!FILE a.nim
# proc greet*(): string = "hi"
# #!FILE main.nim # `main.nim` is always the build root
# import a
# echo greet()
# #!STEP expect: hi
# #!FILE a.nim # re-emit a module to "edit" it
# proc greet*(): string = "hi" # identical content
# #!STEP expect: hi; noop
#
# `#!FILE <name>` blocks (re)write a module in the virtual file system; the
# accumulated file set is materialised before each `#!STEP`. A `#!STEP`'s
# attributes are `;`-separated, each either `key: value` or a bare flag:
# expect: <stdout> noop body-edit iface-edit modules: <n> clean
# The last step always also runs the clean==incremental check.
type MetamorphicError = object of CatchableError
resultKind: TResultEnum
expected, given: string
proc mmRaise(kind: TResultEnum, expected, given: string) =
var e = newException(MetamorphicError, given)
e.resultKind = kind
e.expected = expected
e.given = given
raise e
proc isMetamorphicIcTest(content: string): bool =
for line in content.splitLines:
if line.strip == "#? metamorphic": return true
proc snapshotDir(dir: string): Table[string, string] =
## relative path -> raw file contents, for every file under `dir`.
result = initTable[string, string]()
if dirExists(dir):
for it in walkDirRec(dir):
result[it.relativePath(dir)] = readFile(it)
proc changedPaths(prev, cur: Table[string, string]): seq[string] =
result = @[]
for k, v in cur:
if prev.getOrDefault(k) != v: result.add k
for k in prev.keys:
if k notin cur: result.add k
proc isProvenance(path: string): bool =
## Build-provenance sidecars that legitimately differ between a fresh build and
## an edit-accumulated one (they record build history, not codegen). Excluded
## only from the cross-build clean==incremental comparison — a *no-op* edit must
## still leave even these untouched.
path.endsWith(".frontend.build.nif")
proc changedModuleCount(changed: seq[string]): int =
## distinct modules whose codegen (`*.s.bif`) was rebuilt.
var mods: seq[string] = @[]
for p in changed:
if p.endsWith(".s.bif"):
let key = p.extractFilename.split('.')[0]
if key notin mods: mods.add key
result = mods.len
proc runMetamorphicIcTest(r: var TResults; file: string; cat: Category; options: string) =
var test = TTest(cat: cat, name: file, options: options,
spec: initSpec(file), startTime: epochTime())
test.spec.targets = {targetC}
inc r.total
# Absolute paths: `nim ic` runs with `workingDir = buildDir`, so a relative
# `--nimcache` would resolve against the build dir, not where we read it back.
let buildDir = (file.changeFileExt("") & "_mm").absolutePath
let nc = buildDir / "nc"
let bin = buildDir / "prog".addFileExt(ExeExt)
removeDir(buildDir)
createDir(buildDir)
template compileIc(): untyped =
execCmdEx2(compilerPrefix, ["ic", "--hint:Conf:off", "--warnings:off",
"--nimcache:" & nc, "--out:" & bin, "main.nim"],
workingDir = buildDir)
# Parse the source into a flat op list: ("file", name, content) | ("step", attrs, "").
type OpKind = enum opFile, opStep
type Op = object
kind: OpKind
a, b: string
var ops: seq[Op] = @[]
block parse:
var curName = ""
var buf = ""
template flushFile() =
if curName.len > 0: ops.add Op(kind: opFile, a: curName, b: buf)
curName = ""; buf = ""
for raw in readFile(file).splitLines:
let s = raw.strip
if s.startsWith("#!FILE"):
flushFile()
curName = s["#!FILE".len .. ^1].strip
elif s.startsWith("#!STEP"):
flushFile()
ops.add Op(kind: opStep, a: s["#!STEP".len .. ^1].strip)
elif curName.len > 0:
buf.add raw; buf.add "\n"
let lastStep = block:
var n = 0
for o in ops:
if o.kind == opStep: inc n
n
var vfs = initTable[string, string]()
var prevSnap = initTable[string, string]()
var prevBin = ""
var stepIdx = 0
try:
for o in ops:
if o.kind == opFile:
vfs[o.a] = o.b
continue
inc stepIdx
let where = "step " & $stepIdx
# Parse step attributes.
var attrs = initTable[string, string]()
for part in o.a.split(';'):
let p = part.strip
if p.len == 0: continue
let c = p.find(':')
if c >= 0: attrs[p[0 ..< c].strip] = p[c+1 .. ^1].strip
else: attrs[p] = ""
for fn, content in vfs: writeFile(buildDir / fn, content)
let (_, cout, ccode) = compileIc()
if ccode != 0:
mmRaise(reBuildFailed, "", where & ": `nim ic` failed:\n" & cout)
let (_, rout, rcode) = execCmdEx2(bin.absolutePath, [], workingDir = buildDir)
if rcode != 0:
mmRaise(reBuildFailed, "", where & ": program exited with " & $rcode & ":\n" & rout)
if "expect" in attrs:
let want = attrs["expect"].replace("\\n", "\n")
if rout.strip == want.strip: discard
else: mmRaise(reOutputsDiffer, want, where & " output:\n" & rout.strip)
let snap = snapshotDir(nc)
let binBytes = readFile(bin)
if stepIdx > 1:
let changed = changedPaths(prevSnap, snap)
if "noop" in attrs and (changed.len != 0 or binBytes != prevBin):
mmRaise(reOutputsDiffer, "no artifact change",
where & ": no-op edit changed " & $changed.len & " cache file(s): " & changed.join(", "))
if "body-edit" in attrs:
for p in changed:
if p.endsWith(".iface.bif"):
mmRaise(reOutputsDiffer, "no interface change",
where & ": body-only edit changed an interface cookie: " & p)
if "iface-edit" in attrs:
var sawIface = false
for p in changed:
if p.endsWith(".iface.bif"): sawIface = true
if not sawIface:
mmRaise(reOutputsDiffer, "interface change", where & ": interface edit changed no `*.iface.bif` cookie")
if changedModuleCount(changed) < 2:
mmRaise(reOutputsDiffer, "propagation to importer",
where & ": interface edit did not propagate (only " & $changedModuleCount(changed) & " module rebuilt)")
if "modules" in attrs:
let want = parseInt(attrs["modules"])
let got = changedModuleCount(changed)
if got != want:
mmRaise(reOutputsDiffer, $want & " modules rebuilt", where & ": " & $got & " module(s) rebuilt")
prevSnap = snap
prevBin = binBytes
if "clean" in attrs or stepIdx == lastStep:
removeDir(nc)
let (_, cout2, ccode2) = compileIc()
if ccode2 != 0:
mmRaise(reBuildFailed, "", where & ": clean rebuild failed:\n" & cout2)
let cleanSnap = snapshotDir(nc)
let cleanBin = readFile(bin)
if cleanBin != binBytes:
mmRaise(reOutputsDiffer, "clean binary == incremental binary",
where & ": clean rebuild produced a different binary")
var diff: seq[string] = @[]
for p in changedPaths(snap, cleanSnap):
if not isProvenance(p): diff.add p
if diff.len != 0:
mmRaise(reOutputsDiffer, "clean cache == incremental cache",
where & ": clean rebuild differs in " & $diff.len & " cache file(s): " & diff.join(", "))
prevSnap = cleanSnap
prevBin = cleanBin
finishTest(r, test, targetC, "", "", "", reSuccess)
inc r.passed
except MetamorphicError:
let e = (ref MetamorphicError)(getCurrentException())
finishTest(r, test, targetC, "", e.expected, e.given, e.resultKind)
proc icTests(r: var TResults; testsDir: string, cat: Category, options: string;
isNavigatorTest: bool) =
template editedTest() =
@@ -498,11 +713,18 @@ proc icTests(r: var TResults; testsDir: string, cat: Category, options: string;
const tempExt = "_temp.nim"
for it in walkDirRec(testsDir):
# `_mm` directories hold materialised modules + nimcache for metamorphic
# tests; never collect their files as tests in their own right.
if "_mm" in it: continue
if isTestFile(it) and not it.endsWith(tempExt):
let content = readFile(it)
if isMetamorphicIcTest(content):
runMetamorphicIcTest(r, it, cat, options)
continue
let nimcache = nimcacheDir(it, options, targetC)
removeDir(nimcache)
let content = readFile(it)
for fragment in content.split("#!EDIT!#"):
let file = it.replace(".nim", tempExt)
writeFile(file, fragment)

46
tests/ic/tmeta_async.nim Normal file
View File

@@ -0,0 +1,46 @@
discard """
description: '''metamorphic IC: async/await across an edit (continuations + clean==incremental)'''
"""
# Async is the most cache-fragile area under `nim ic`: the `{.async.}` transform
# generates continuation closures and lifts environments, and those lowered
# bodies must survive incremental edits and converge to exactly what a clean
# build produces. This drives an edit to an async proc body and asserts the
# importer is NOT rebuilt (the lifted continuation stays local to its module),
# a no-op changes nothing, and the final state is byte-identical to a clean
# build. See doc/ic_ideas.md and [[ic-nimbus-test]] (async was the long pole).
#? metamorphic
#!FILE worker.nim
import std/asyncdispatch
proc compute*(x: int): Future[int] {.async.} =
await sleepAsync(0)
result = x * 2
#!FILE main.nim
import std/asyncdispatch, worker
echo waitFor compute(21)
#!STEP expect: 42
# --- edit the async proc body. The continuation env lives in `worker`, so only
# `worker` rebuilds; `main` (the caller) is left untouched -> modules: 1.
# (An async body edit does perturb `worker`'s interface cookie via the
# generated env type, so this is not asserted as a pure `body-edit`.)
#!FILE worker.nim
import std/asyncdispatch
proc compute*(x: int): Future[int] {.async.} =
await sleepAsync(0)
result = x * 3
#!STEP expect: 63; modules: 1
# --- re-emit identical content: nothing may change, lifted closures included.
#!FILE worker.nim
import std/asyncdispatch
proc compute*(x: int): Future[int] {.async.} =
await sleepAsync(0)
result = x * 3
#!STEP expect: 63; noop

View File

@@ -0,0 +1,64 @@
discard """
description: '''metamorphic IC: expression-based `let x = case ...` idiom'''
"""
# nimbus-eth2 leans on expression-style code (`let x = case ...` rather than
# statement assignment). This exercises that construct through the incremental
# path: a body edit that adds a `case` branch stays local to the module, while a
# return-type change (interface edit) propagates to the importer even though the
# importer's source is byte-identical. See doc/ic_ideas.md.
#? metamorphic
#!FILE classify.nim
proc classify*(n: int): string =
let kind = case n
of 0: "zero"
of 1, 2, 3: "small"
else: "big"
result = kind & "(" & $n & ")"
#!FILE main.nim
import classify
echo classify(0), " ", classify(2), " ", classify(99)
#!STEP expect: zero(0) small(2) big(99)
# --- body edit: add a `case` branch and tweak a label. The signature is
# unchanged, so no interface cookie changes and only `classify` rebuilds.
#!FILE classify.nim
proc classify*(n: int): string =
let kind = case n
of 0: "ZERO"
of 1, 2, 3: "small"
of 4, 5, 6: "medium"
else: "big"
result = kind & "(" & $n & ")"
#!STEP expect: ZERO(0) small(2) big(99); body-edit; modules: 1
# --- re-emit identical content: nothing may change.
#!FILE classify.nim
proc classify*(n: int): string =
let kind = case n
of 0: "ZERO"
of 1, 2, 3: "small"
of 4, 5, 6: "medium"
else: "big"
result = kind & "(" & $n & ")"
#!STEP expect: ZERO(0) small(2) big(99); noop
# --- interface edit: the expression-`case` now yields `int`, changing
# `classify`'s return type. `main`'s source is byte-identical (`echo` prints
# either) yet must re-sem & recodegen -> the cookie changes and 2 modules
# rebuild. The final step also runs the clean==incremental check.
#!FILE classify.nim
proc classify*(n: int): int =
result = case n
of 0: 0
of 1, 2, 3: 1
of 4, 5, 6: 5
else: 9
#!STEP expect: 0 1 9; iface-edit; modules: 2

View File

@@ -0,0 +1,33 @@
discard """
description: '''metamorphic IC: generic instantiation cache stability across edits'''
"""
# A generic lives in `gen` but is *instantiated* in `main` (the per-module
# backend emits instance bodies at the instantiation site). Editing the generic
# body must therefore rebuild both `gen` and `main`, and an incremental edit must
# still converge to exactly the same artifacts as a clean build. This exercises
# the static/generic-instance cache path that has historically been bug-prone.
#? metamorphic
#!FILE gen.nim
proc box*[T](x: T): seq[T] = @[x, x]
#!FILE main.nim
import gen
echo box(3).len, " ", box("hi")[0]
#!STEP expect: 2 hi
# --- edit the generic body: the importer holds the instantiations, so both the
# definer and the instantiation site rebuild (2 modules).
#!FILE gen.nim
proc box*[T](x: T): seq[T] = @[x, x, x]
#!STEP expect: 3 hi; modules: 2
# --- re-emit identical content: nothing may change.
#!FILE gen.nim
proc box*[T](x: T): seq[T] = @[x, x, x]
#!STEP expect: 3 hi; noop

61
tests/ic/tmeta_ortype.nim Normal file
View File

@@ -0,0 +1,61 @@
discard """
description: '''metamorphic IC: or-type (type-class union) idiom from nimbus forks.nim'''
"""
# nimbus-eth2 writes a lot of generic code over `A | B | C` type-class unions
# (consensus forks). This models that idiom: a `Fruit = Apple | Banana` union
# with a generic `describe[T: Fruit]` dispatched by `when T is ...`. The generic
# is instantiated in `main`, so editing its body rebuilds both modules, and the
# incremental result must match a clean build. See doc/ic_ideas.md.
#? metamorphic
#!FILE forks.nim
type
Apple* = object
weight*: int
Banana* = object
length*: int
Fruit* = Apple | Banana
proc describe*[T: Fruit](x: T): string =
when T is Apple: "apple " & $x.weight
else: "banana " & $x.length
#!FILE main.nim
import forks
echo describe(Apple(weight: 5)), " | ", describe(Banana(length: 9))
#!STEP expect: apple 5 | banana 9
# --- edit the generic body (no signature change). The union/constraint is
# untouched, so no interface cookie changes; but `main` holds the two
# instantiations, so both modules' codegen rebuilds.
#!FILE forks.nim
type
Apple* = object
weight*: int
Banana* = object
length*: int
Fruit* = Apple | Banana
proc describe*[T: Fruit](x: T): string =
when T is Apple: "APPLE " & $x.weight
else: "BANANA " & $x.length
#!STEP expect: APPLE 5 | BANANA 9; body-edit; modules: 2
# --- re-emit identical content: nothing may change.
#!FILE forks.nim
type
Apple* = object
weight*: int
Banana* = object
length*: int
Fruit* = Apple | Banana
proc describe*[T: Fruit](x: T): string =
when T is Apple: "APPLE " & $x.weight
else: "BANANA " & $x.length
#!STEP expect: APPLE 5 | BANANA 9; noop

93
tests/ic/tmeta_result.nim Normal file
View File

@@ -0,0 +1,93 @@
discard """
description: '''metamorphic IC: Result[T, E] (nim-results style) variant-object idiom'''
"""
# nimbus-eth2 threads errors through nim-results' `Result[T, E]`, a generic
# *variant* (case) object. This models a minimal hermetic version and exercises
# the generic-variant cache path across edits: a body edit of a generic accessor,
# adding a public overload (an interface edit), and a no-op — with a final
# clean==incremental check. See doc/ic_ideas.md.
#? metamorphic
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = r.e
#!FILE main.nim
import results
proc parse(s: string): Result[int, string] =
if s == "42": ok[int, string](42)
else: err[int, string]("bad: " & s)
let a = parse("42")
let b = parse("x")
echo (if a.isOk: $a.get else: a.error), " ", (if b.isOk: $b.get else: b.error)
#!STEP expect: 42 bad: x
# --- body-only edit of a generic accessor (`error`): signature unchanged, so no
# interface cookie changes; the importer holds the instantiation, so both
# modules' codegen rebuilds.
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = "ERR:" & r.e
#!STEP expect: 42 ERR:bad: x; body-edit; modules: 2
# --- re-emit identical content: nothing may change.
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = "ERR:" & r.e
#!STEP expect: 42 ERR:bad: x; noop
# --- interface edit: add a public `get` overload (a new exported signature).
# `main` doesn't call it, yet importing `results` whose interface changed
# forces a re-sem; the cookie changes and >= 2 modules rebuild. The final
# step also runs the clean==incremental check.
#!FILE results.nim
type
ResultKind = enum rOk, rErr
Result*[T, E] = object
case kind: ResultKind
of rOk: v: T
of rErr: e: E
proc ok*[T, E](x: T): Result[T, E] = Result[T, E](kind: rOk, v: x)
proc err*[T, E](x: E): Result[T, E] = Result[T, E](kind: rErr, e: x)
proc isOk*[T, E](r: Result[T, E]): bool = r.kind == rOk
proc get*[T, E](r: Result[T, E], fallback: T): T = (if r.kind == rOk: r.v else: fallback)
proc get*[T, E](r: Result[T, E]): T = r.v
proc error*[T, E](r: Result[T, E]): E = "ERR:" & r.e
#!STEP expect: 42 ERR:bad: x; iface-edit; modules: 2

50
tests/ic/tmeta_smoke.nim Normal file
View File

@@ -0,0 +1,50 @@
discard """
description: '''metamorphic IC: clean==incremental, no-op stability, body vs interface boundary'''
"""
# This is a *metamorphic* IC test (see the `#? metamorphic` marker and the
# runner in testament/categories.nim). It drives a sequence of cross-module
# edits through `nim ic` in one fixed build directory and checks the invariants
# the incremental backend must uphold (doc/ic_ideas.md).
#? metamorphic
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 41 # private, body-only churn target
proc value*(): int = secret() + 1
#!FILE main.nim
import a
echo greet(), " ", value()
#!STEP expect: hi 42
# --- body-only edit: a private body changes, no signature does.
# => no `*.iface.bif` cookie changes, exactly 1 module's codegen rebuilds,
# the importer is left untouched.
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 999
proc value*(): int = secret() + 1
#!STEP expect: hi 1000; body-edit; modules: 1
# --- no-op edit: re-emit byte-identical content. Nothing downstream may change.
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 999
proc value*(): int = secret() + 1
#!STEP expect: hi 1000; noop
# --- interface edit: `value`'s return type changes (a signature change) while
# main.nim's source stays byte-identical. The interface cookie must change
# and the importer must be re-sem'd & recodegen'd (>= 2 modules rebuilt).
# The final step also runs the clean==incremental check.
#!FILE a.nim
proc greet*(): string = "hi"
proc secret(): int = 999
proc value*(): int64 = secret().int64 + 1
#!STEP expect: hi 1000; iface-edit

View File

@@ -0,0 +1,39 @@
discard """
description: '''metamorphic IC: edit propagation across a 3-module import chain'''
"""
# Chain: main -> b -> a. Demonstrates that a body edit stays local to the edited
# module, while an interface edit propagates to its direct importer but stops
# where the next signature is unchanged. See doc/ic_ideas.md and the runner in
# testament/categories.nim.
#? metamorphic
#!FILE a.nim
proc base*(): int = 1
#!FILE b.nim
import a
proc mid*(): int = base() + 10
#!FILE main.nim
import b
echo mid()
#!STEP expect: 11
# --- body-only edit of `a.base`: no signature changes, so nothing re-sems;
# only module `a`'s own codegen rebuilds.
#!FILE a.nim
proc base*(): int = 7
#!STEP expect: 17; body-edit; modules: 1
# --- interface edit of `a.base` (return type int -> int64). `b` uses `base`, so
# `a`'s cookie change forces `b` to re-sem & recodegen; but `b.mid`'s own
# signature is unchanged, so `main` is NOT rebuilt -> exactly 2 modules.
# The final step also runs the clean==incremental check.
#!FILE a.nim
proc base*(): int64 = 7
#!STEP expect: 17; iface-edit; modules: 2