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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"total": 2,
|
||||
"byFile": {
|
||||
"packages/engine/src/executor.ts": 2
|
||||
}
|
||||
"total": 0,
|
||||
"byFile": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user