## Summary Wave 17 organizes Fusion into **domain folders** (stacks on #2397). ### Layout - **core/types/** — board, task, agents, settings, merge, workflow, mesh, … - **core/src/** — agents, ai, async-stores, workflows, tasks, config, db, … - **dashboard/app/api/** — client, tasks, agents, git, missions, planning, … - **engine/src/** — agents, auth, execution, merge, missions, overseer, worktree, … Root keepers retained for large entrypoints (`store.ts`, `executor.ts`, `merger.ts`, …). Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy) stay stable. ## Test plan - [x] `@fusion/core` typecheck - [x] `@fusion/engine` typecheck (pre-existing playwright-core noise only) - [ ] CI merge gate **Stack:** #2394 → #2397 → **this PR**
237 lines
9.3 KiB
JavaScript
237 lines
9.3 KiB
JavaScript
/*
|
|
FNXC:WorkflowCapacity 2026-07-28-22:30 (PR #2488 review — ratchet rebuilt):
|
|
|
|
WHY THIS IS AN AST CHECK AND NOT A REGEX.
|
|
|
|
The first version of this guard matched `?? DEFAULT_WORKFLOW_POOL_ID` on one line.
|
|
It therefore MISSED `?? "builtin:coding"` — the actual defect the whole change
|
|
exists to fix — and passed green on the reintroduced bug (verified, not assumed).
|
|
A guard that reports success without checking is worse than no guard, because it
|
|
stops anyone looking. The claim being made is structural ("no capacity pool id is
|
|
derived except through the resolver"), so it is checked structurally.
|
|
|
|
TWO COMPLEMENTARY RULES. Neither alone is sufficient:
|
|
|
|
RULE 1 — THE SINK RULE (the one that catches the original defect).
|
|
Every argument that flows into a capacity counter's `workflowId` must be
|
|
`resolveCapacityPoolId(...)`, or a local whose initializer is that call. The
|
|
original defect passed `effectiveWorkflowIdForMove`, a local initialized from a
|
|
`??` literal — so this rule fires on it regardless of WHICH literal was used, on
|
|
one line or twenty. This is the rule that expresses the real invariant: the
|
|
banned thing is not a spelling, it is an underived value reaching the counter.
|
|
|
|
RULE 2 — THE SENTINEL RULE (cheap, catches restatements of the convention).
|
|
No `??` whose right-hand side is the pool sentinel — under ANY qualification
|
|
depth (`X.Y.Z.DEFAULT_WORKFLOW_POOL_ID`) or as its raw string value
|
|
("__default-workflow__") — outside the module that owns the convention. Being
|
|
AST-based, a fallback split across lines is the same node and is caught.
|
|
|
|
WHY `?? "builtin:coding"` IS NOT BANNED OUTRIGHT. That literal is the legitimate
|
|
default for a WORKFLOW id in ~8 places (scheduler's IR-resolution key,
|
|
task-creation, analytics). It is only a bug when it reaches a CAPACITY POOL, and
|
|
that distinction is exactly what Rule 1 encodes. Banning the spelling everywhere
|
|
would be cargo-culting the rule past the thing it protects, and would have to be
|
|
suppressed so often it would rot.
|
|
|
|
FAIL CLOSED. An unreadable or unparseable file is reported as a violation, never
|
|
skipped — "could not inspect" must not render as "inspected and clean", which is
|
|
the same failure shape as the regex that could not see the defect.
|
|
*/
|
|
import ts from "typescript";
|
|
|
|
/** The module that owns the convention; the resolver's own `??` lives here. */
|
|
// FNXC:CodeOrganization 2026-08-02-22:30: Convention owner moved under workflows/ domain folder (wave17); keep this path in sync so the sentinel-fallback rule still exempts the resolver body.
|
|
export const CONVENTION_OWNER = "packages/core/src/workflows/workflow-capacity.ts";
|
|
|
|
/** The canonical resolver every pool-id derivation must go through. */
|
|
export const RESOLVER = "resolveCapacityPoolId";
|
|
|
|
/** Capacity counters whose `workflowId` input is a pool id. */
|
|
export const CAPACITY_SINKS = new Set([
|
|
"countActiveInCapacitySlotAsync",
|
|
"countActiveInCapacitySlotSync",
|
|
"countActiveInCapacitySlot",
|
|
"countCapacitySlot",
|
|
]);
|
|
|
|
/** `countCapacitySlot(allTasks, byTask, budgetColumns, workflowId, countPending)` */
|
|
const POSITIONAL_SINKS = { countCapacitySlot: 3 };
|
|
|
|
const SENTINEL_CONST = "DEFAULT_WORKFLOW_POOL_ID";
|
|
const SENTINEL_VALUE = "__default-workflow__";
|
|
|
|
/** Right-most name of a possibly-qualified reference, at any depth. */
|
|
function tailName(node) {
|
|
let cur = node;
|
|
while (ts.isPropertyAccessExpression(cur)) cur = cur.name;
|
|
return ts.isIdentifier(cur) ? cur.text : undefined;
|
|
}
|
|
|
|
function isResolverCall(node) {
|
|
return (
|
|
node &&
|
|
ts.isCallExpression(node) &&
|
|
tailName(node.expression) === RESOLVER
|
|
);
|
|
}
|
|
|
|
/** True when `expr` is the resolver call, or a local initialized from one. */
|
|
function isDerivedThroughResolver(expr, resolverLocals) {
|
|
if (!expr) return false;
|
|
if (isResolverCall(expr)) return true;
|
|
if (ts.isIdentifier(expr)) return resolverLocals.has(expr.text);
|
|
// `cond ? resolver(a) : resolver(b)` is still derived through the resolver.
|
|
if (ts.isConditionalExpression(expr)) {
|
|
return (
|
|
isDerivedThroughResolver(expr.whenTrue, resolverLocals) &&
|
|
isDerivedThroughResolver(expr.whenFalse, resolverLocals)
|
|
);
|
|
}
|
|
if (ts.isAsExpression(expr) || ts.isParenthesizedExpression(expr)) {
|
|
return isDerivedThroughResolver(expr.expression, resolverLocals);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Analyse one source file.
|
|
* @returns {Array<{file:string,line:number,rule:string,text:string}>}
|
|
*/
|
|
export function findViolationsInSource(file, text) {
|
|
const violations = [];
|
|
/*
|
|
FNXC:WorkflowCapacity 2026-07-28-23:30 (PR #2488 review):
|
|
Parse failure is detected via `parseDiagnostics`, NOT via a try/catch.
|
|
`ts.createSourceFile` is error-TOLERANT: given `function )( {` it returns a
|
|
source file carrying diagnostics rather than throwing, so the catch this
|
|
replaced was unreachable and the "unparseable" rule could never fire. That made
|
|
the guard's own fail-closed claim overstated in exactly the way this PR is
|
|
about — a check advertising a capability it did not have. A file whose syntax
|
|
did not parse yields a partial AST, so its `??` nodes and sink calls may simply
|
|
be absent: reporting it clean would be reporting "not inspected" as "inspected".
|
|
The defensive catch is kept for a genuine internal error, but detection is the
|
|
diagnostics check.
|
|
*/
|
|
let sf;
|
|
try {
|
|
sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
} catch (err) {
|
|
return [{ file, line: 0, rule: "unparseable", text: `parser threw: ${String(err && err.message)}` }];
|
|
}
|
|
const parseErrors = sf.parseDiagnostics ?? [];
|
|
if (parseErrors.length > 0) {
|
|
const first = ts.flattenDiagnosticMessageText(parseErrors[0].messageText, " ");
|
|
return [
|
|
{
|
|
file,
|
|
line: 0,
|
|
rule: "unparseable",
|
|
text: `${parseErrors.length} syntax error(s), first: ${first} — a file that did not parse was NOT inspected`,
|
|
},
|
|
];
|
|
}
|
|
|
|
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
const snippet = (node) => node.getText(sf).replace(/\s+/g, " ").slice(0, 140);
|
|
|
|
// Locals whose initializer is a resolver call — collected first so a sink that
|
|
// reads one is accepted regardless of declaration order within the file.
|
|
const resolverLocals = new Set();
|
|
const collect = (node) => {
|
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
if (isDerivedThroughResolver(node.initializer, resolverLocals)) resolverLocals.add(node.name.text);
|
|
}
|
|
ts.forEachChild(node, collect);
|
|
};
|
|
collect(sf);
|
|
// Second pass: a local initialized from another resolver local.
|
|
collect(sf);
|
|
|
|
const visit = (node) => {
|
|
// ── RULE 2: sentinel restated in a `??` fallback ──────────────────────────
|
|
if (
|
|
ts.isBinaryExpression(node) &&
|
|
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken &&
|
|
file !== CONVENTION_OWNER
|
|
) {
|
|
const rhs = node.right;
|
|
const isSentinelConst = tailName(rhs) === SENTINEL_CONST;
|
|
const isSentinelValue = ts.isStringLiteral(rhs) && rhs.text === SENTINEL_VALUE;
|
|
if (isSentinelConst || isSentinelValue) {
|
|
violations.push({
|
|
file,
|
|
line: lineOf(node),
|
|
rule: "sentinel-fallback",
|
|
text: snippet(node),
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── RULE 1: something underived reaching a capacity counter ───────────────
|
|
if (ts.isCallExpression(node)) {
|
|
const callee = tailName(node.expression);
|
|
if (callee && CAPACITY_SINKS.has(callee)) {
|
|
// Object-literal form: `count...({ workflowId: <expr> })`
|
|
for (const arg of node.arguments) {
|
|
if (!ts.isObjectLiteralExpression(arg)) continue;
|
|
for (const prop of arg.properties) {
|
|
if (!ts.isPropertyAssignment(prop)) continue;
|
|
if (tailName(prop.name) !== "workflowId") continue;
|
|
if (!isDerivedThroughResolver(prop.initializer, resolverLocals)) {
|
|
violations.push({
|
|
file,
|
|
line: lineOf(prop),
|
|
rule: "unresolved-pool-into-capacity-sink",
|
|
text: snippet(prop),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Positional form.
|
|
const idx = POSITIONAL_SINKS[callee];
|
|
if (idx !== undefined && node.arguments.length > idx) {
|
|
const arg = node.arguments[idx];
|
|
if (!isDerivedThroughResolver(arg, resolverLocals)) {
|
|
violations.push({
|
|
file,
|
|
line: lineOf(arg),
|
|
rule: "unresolved-pool-into-capacity-sink",
|
|
text: snippet(arg),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sf);
|
|
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* @param {Array<{file:string, read:() => string}>} entries
|
|
* @returns {Array<{file:string,line:number,rule:string,text:string}>}
|
|
*/
|
|
export function findViolations(entries) {
|
|
const out = [];
|
|
for (const entry of entries) {
|
|
let text;
|
|
try {
|
|
text = entry.read();
|
|
} catch (err) {
|
|
// FAIL CLOSED: an uninspectable file is a violation, not a pass.
|
|
out.push({
|
|
file: entry.file,
|
|
line: 0,
|
|
rule: "unreadable",
|
|
text: `could not read (${String(err && err.message)}) — a file that cannot be inspected must not report as clean`,
|
|
});
|
|
continue;
|
|
}
|
|
out.push(...findViolationsInSource(entry.file, text));
|
|
}
|
|
return out;
|
|
}
|