FN-126: fix inert sync-lane validation

Prevent inert synchronization lanes from bypassing merge-gate validation while preserving task-lane cache and archive lifecycle behavior.

- Reject sync-resolved lane conversions in the static validator.
- Preserve task-lane cache emissions and active-session cleanup across task mutations and archival.
- Add regression coverage, baseline updates, documentation, and a release changeset.

Files changed:
 .changeset/fn-126-inert-sync-lane.md               |   7 +
 .../a-falling-count-is-not-evidence.md             |  12 +
 .../task-lane-cache-emitter-preservation.test.ts   | 269 +++++++++++++++++++++
 .../core/src/task-store/archive-lifecycle-2.ts     |   3 +-
 packages/core/src/task-store/moves.ts              |   3 +-
 packages/core/src/task-store/task-artifacts-ops.ts |   3 +-
 packages/core/src/task-store/task-update.ts       |   3 +-
 packages/core/src/task-store/update-task-deps.ts   |   3 +-
 ...xecutor-archive-releases-active-session.test.ts |  80 ++++++
 packages/engine/src/executor.ts                    |   4 +-
 .../src/executor/executor-side-effect-hosts.ts     |   2 +-
 .../executor/is-backward-move-out-of-planning.ts   |   7 +-
 .../src/executor/task-executor-graph-facades.ts    |   2 +-
 .../engine/src/executor/task-executor-imports.ts   |   1 -
 .../engine/src/executor/wire-executor-lifecycle.ts |  18 +-
 .../check-inert-sync-lane-conversions.test.mjs     |  83 +++++--
 scripts/check-inert-sync-lane-conversions.mjs      |  19 +-
 scripts/lib/inert-sync-lane-baseline.json          |   6 +-
 18 files changed, 479 insertions(+), 46 deletions(-)

Fusion-Task-Id: FN-126

Fusion-Task-Lineage: 0c7a1a3a-9446-44f8-955d-df6c402dfd31

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-22 01:39:23 +00:00
parent 8b681775ae
commit 33f4797ceb
18 changed files with 479 additions and 46 deletions

View File

@@ -22,7 +22,7 @@ about its output.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -95,26 +95,19 @@ test("an unrecorded DROP fails, so the allowance cannot stay stale-high", () =>
assert.match(`${result.stdout}${result.stderr}`, /--update-baseline/);
});
test("a RISE still fails, and names the file it rose in", () => {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:55:
The expected filename is DERIVED, not written down. My first version asserted `scheduler.ts`, which
#3128 then took to zero inert guards — so the case failed for a reason unrelated to the gate. The
same coupling mistake as asserting the committed baseline matches the tree, one line lower.
*/
const live = liveCounts();
const [someFile] = Object.keys(live.byFile);
const result = withBaseline(
(baseline) => ({
...baseline,
total: Math.max(0, baseline.total - 1),
byFile: Object.fromEntries(Object.entries(baseline.byFile).map(([f, n]) => [f, Math.max(0, n - 1)])),
}),
runGate,
);
assert.equal(result.status, 1, "more inert conversions than the baseline must fail");
assert.match(`${result.stdout}${result.stderr}`, new RegExp(someFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
test("a RISE still fails, and names the staged file", () => {
const probe = join(REPO_ROOT, "packages/engine/src/__probe-inert-rise.ts");
writeFileSync(probe, [
`import { resolveTaskWorkflowIrSync } from "@fusion/core";`,
`function localSync(store: unknown, id: string) { return resolveTaskWorkflowIrSync(store as never, id); }`,
`export function probe(store: unknown, id: string, column: string) { const lanes = localSync(store, id); return column === lanes.hold; }`,
"",
].join("\n"));
try {
const result = withBaseline((baseline) => ({ ...baseline, total: 0, byFile: {} }), runGate);
assert.equal(result.status, 1, "more inert conversions than the baseline must fail");
assert.match(`${result.stdout}${result.stderr}`, /__probe-inert-rise\.ts/);
} finally { rmSync(probe, { force: true }); }
});
/*
@@ -122,10 +115,16 @@ ANTI-VACUITY. The two cases above mutate the baseline, so they would both keep p
stopped scanning any source at all and simply compared a number to itself. This asserts the scan
still finds the guards it is supposed to be counting.
*/
test("the gate is still actually scanning source, not just comparing numbers", () => {
const result = runGate();
assert.match(`${result.stdout}${result.stderr}`, /guard\(s\) consuming a sync-resolved lane/);
assert.ok(JSON.parse(readFileSync(BASELINE, "utf8")).total > 0, "baseline should not be empty");
test("the gate scans a staged source even with a zero baseline", () => {
const probe = join(REPO_ROOT, "packages/engine/src/__probe-inert-scan.ts");
writeFileSync(probe, [
`import { resolveTaskWorkflowIrSync } from "@fusion/core";`,
`function localSync(store: unknown, id: string) { return resolveTaskWorkflowIrSync(store as never, id); }`,
`export function probe(store: unknown, id: string, column: string) { return column === localSync(store, id).hold; }`,
"",
].join("\n"));
try { assert.equal(liveCounts().byFile["packages/engine/src/__probe-inert-scan.ts"], 1); }
finally { rmSync(probe, { force: true }); }
});
/*
@@ -303,3 +302,37 @@ test("does NOT count an object whose KEY merely shares a sync local's name", ()
rmSync(probe, { force: true });
}
});
test("counts a tainted role inside an array/filter membership receiver once", () => {
const probe = join(REPO_ROOT, "packages/engine/src/__probe-inert-membership.ts");
writeFileSync(probe, [
`import { resolveTaskWorkflowIrSync } from "@fusion/core";`,
`function localSync(store: unknown, id: string) { return resolveTaskWorkflowIrSync(store as never, id); }`,
`export function probe(store: unknown, id: string, column: string) { const lanes = localSync(store, id); return ![lanes.wip, lanes.review].filter(Boolean).includes(column); }`,
"",
].join("\n"));
try { assert.equal(liveCounts().byFile["packages/engine/src/__probe-inert-membership.ts"], 1); }
finally { rmSync(probe, { force: true }); }
});
/*
FNXC:WorkflowResolvedColumns 2026-08-22-00:30:
A zero baseline must not turn a renamed or moved reader into a permanent green. Run a copied checker
from an empty in-repository tree so its own repository-root calculation sees no source while Node can
still resolve TypeScript through this workspace's node_modules.
*/
test("fails closed when the scanned tree has no sync-lane source", () => {
const root = mkdtempSync(join(REPO_ROOT, ".inert-sync-empty-"));
const script = join(root, "scripts/check-inert-sync-lane-conversions.mjs");
try {
mkdirSync(join(root, "scripts/lib"), { recursive: true });
mkdirSync(join(root, "packages"), { recursive: true });
copyFileSync(SCRIPT, script);
writeFileSync(join(root, "scripts/lib/inert-sync-lane-baseline.json"), "{\n \"total\": 0,\n \"byFile\": {}\n}\n");
const result = spawnSync(process.execPath, [script], { cwd: root, encoding: "utf8" });
assert.equal(result.status, 1);
assert.match(`${result.stdout}${result.stderr}`, /no resolveTaskWorkflowIrSync source found under/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View File

@@ -332,6 +332,16 @@ function countInertGuards(sf, locals, sources) {
}
return undefined;
};
const consumptionWithin = (expr) => {
let found;
const walk = (candidate) => {
if (found) return;
found = consumesLocal(candidate);
if (!found) ts.forEachChild(candidate, walk);
};
walk(expr);
return found;
};
const visit = (node) => {
/* `to === parked.review` — one lane id, compared. */
if (ts.isBinaryExpression(node)) {
@@ -348,7 +358,8 @@ function countInertGuards(sf, locals, sources) {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const method = node.expression.name.getText(sf);
if (method === "has" || method === "includes") {
const hit = consumesLocal(node.expression.expression);
/* FNXC:LifecycleColumnCensus 2026-08-22-00:13: membership receivers can be array/filter expressions; inspect their contained role read and count this membership site once. */
const hit = consumptionWithin(node.expression.expression);
if (hit) {
hits.push({ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, expr: `${hit}.${method}(...)` });
}
@@ -371,6 +382,12 @@ for (const file of files) {
for (const name of syncLaneSources(sf)) sources.add(name);
}
/* A zero baseline is only meaningful while the scanner still sees a source to police. */
if (sources.size === 0) {
console.error(`inert-sync-lane: no ${SYNC_IR_READER} source found under ${join(REPO, "packages")}`);
process.exit(1);
}
/* PASS 2 — guards consuming any of them, anywhere. */
const byFile = {};
const detail = {};

View File

@@ -1,6 +1,4 @@
{
"total": 2,
"byFile": {
"packages/engine/src/executor.ts": 2
}
"total": 0,
"byFile": {}
}