Skip to content

Commit dd4a21f

Browse files
fix(cli)!: os validate and os build refuse a flow with an undeclared top-level cycle, as the engine refuses it at registration (#29595)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9BEQHr4NaK8w2SRWffi5xaT)_ Fixes #29051 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — `skills/objectstack-automation/references/state-machines-and-approvals.md` already teaches that a cycle-closing edge MUST be typed `'back'` and that an unmarked cycle is refused; that stays true, and the CLI now refuses the same flow before it ships. ## 维护者速读 - 改了什么:`os validate` 和 `os build` 现在拒绝顶层图里有环、且环上没有一条 `type: 'back'` 边的流程(ADR-0044),退出码 1,build 不写产物;报错定位到该流程,正文是引擎注册时那句 "Flow contains a cycle: …"。 - 为什么改:这种流程以前本地校验、构建都通过,发布后引擎拒绝注册,流程从不运行。#29226 已让发布门拒绝它,CLI 是最后一扇没关的门。 - 风险与回滚:收窄(BREAKING),以前能 build 的这类流程现在 build 失败,但它们本来就从未运行过。判定直接调用 `@objectstack/metadata-core` 的 `findUndeclaredFlowCycle`,与引擎注册、发布门是同一个函数,没有第二份环检测。回滚即还原本 PR,无数据迁移。 - 先看:`packages/cli/src/utils/flow-undeclared-cycle.ts`(新的遍历,一次调用),`packages/cli/src/commands/validate.ts` 第 3a-vii 步与 `compile.ts` 第 3b-iii-quater 步(新的门禁步骤),`packages/cli/test/validate-flow-region-shape.test.ts` 末尾(用同一份 `FLOW_CYCLE_CORPUS` 驱动 validate 与 build)。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run src/utils/flow-undeclared-cycle.test.ts test/validate-flow-region-shape.test.ts`,预期 16 个用例全绿;在 main 上环相关用例全红(validate 答 `valid: true`)。 - 需要你做的:无,席位落地。 ## What changes - **The walk** (new `packages/cli/src/utils/flow-undeclared-cycle.ts`): `findFlowCycleRefusals` walks each `packages[]` entry's flows (or the stack's own flows when it declares no packages), parses each with `FlowSchema`, and asks `findUndeclaredFlowCycle` from `@objectstack/metadata-core` (exported by #29226), the judge the engine's registration and the runtime publish gate call. No copy of the DFS. A refused flow is one `VALIDATION_ERROR` row located at the flow (`flows[N]` or `packages[K].manifest.flows[N]`), message `Flow 'NAME' would be refused at registration: ` followed by the judge's sentence verbatim. `@objectstack/cli` already depended on `@objectstack/metadata-core`. - **The doors** (`packages/cli/src/commands/validate.ts` step 3a-vii, `compile.ts` step 3b-iii-quater): one gate step each, the same shape as the CLI's other walks (`--json`: `errors` rows and exit 1; text: a heading naming ADR-0044 and the rows). The build writes no artifact. - **Why a CLI step and not a rule-table entry.** #29495 moved the ADR-0031 region check into `@objectstack/lint` (`flow-region-shape-invalid`). `@objectstack/lint` does not depend on `@objectstack/metadata-core`, so the rule table cannot reach this judge without a new package edge, and the ruling forbids a copy (seat claim amendment 6584349353 declares this file surface). So `os lint` does not give this verdict; `os validate` and `os build` do. - **Changeset** `.changeset/29051-cli-flow-undeclared-cycle.md`: `@objectstack/cli` patch, BREAKING banner with before/now and the remedy, `adr-0087: not-required (no-migration-prescription)`. - **Test-only config**: `packages/cli/vitest.config.ts` gains one anchored alias, `@objectstack/metadata-core/testing` → `../metadata-core/src/testing.ts`, which `check:test-source-alias` names for the new corpus import. The bare `@objectstack/metadata-core` specifier is not aliased; its ledger entry is unchanged. Not touched: `packages/metadata-protocol/**`, `packages/spec/**`, `packages/lint/**`, `ui/**`. ## Tests - `packages/cli/src/utils/flow-undeclared-cycle.test.ts` (new): one case per `FLOW_CYCLE_CORPUS` entry (`@objectstack/metadata-core/testing`, the corpus the judge, the engine's registration and the publish gate are driven from). A refused case gives exactly one row at `flows[0]` whose message is `Flow 'NAME' would be refused at registration: ` + `undeclaredFlowCycleMessage(cycle)`. A back-typed or acyclic case gives none. Plus an assembled artifact: the refused flow in the second `packages[]` entry is located at `packages[1].manifest.flows[1]`, and the stack's top level is not walked. - `packages/cli/test/validate-flow-region-shape.test.ts` (new block at the end, in-process `Command.run`, unit tier): the whole corpus in one stack. `os validate --json` and `os build --json` each exit 1, with exactly one row per refused corpus flow at its own `flows[N]` carrying the judge's sentence; the build writes no `dist/`. CONTROL: the corpus's back-typed and acyclic flows alone validate and build (exit 0). - **Forced test edit:** `packages/cli/test/validate-build-gate-parity.test.ts`. Its ledger classifies every call site in `compile.ts` and `validate.ts`, so `findFlowCycleRefusals` joins `SHARED_NON_REGISTRY_GATES` with the reason it is not a registry rule. ## Acceptance notes - **Order differs from the engine for a flow with two faults.** The engine judges the top-level cycle before the ADR-0031 region shape. The commands run the rule table (`flow-region-shape-invalid`) first, so a flow with both a malformed region and an undeclared cycle is refused for its region first, then for the cycle once the region is fixed. Both are refusals; only which one an author reads first differs. - **`os lint` does not give this verdict** (see What changes). Closing that would need a lint → metadata-core edge, or the judge moving to a package lint already depends on; a separate decision, not taken here. - The CLI row is located at the flow, as the card's acceptance asks. The publish gate (#29226) locates its finding on the closing edge (`edges.N`). The judge returns `closingEdgeIndex`, so the CLI row could carry the edge later; not done here, to keep the row shape the CLI's other flow walks use. - A flow `FlowSchema` refuses is not judged by this walk: `flow-region-shape-invalid` refuses it at the rule-table step, which exits before this one. ## Verification Head `5b16eb34` (origin/main merged at `a5b8e9bf`, after #29495). `5b16eb34` is a comment-only commit over `1f72080c` (the `@objectstack/metadata-core/testing` alias comment in `packages/cli/vitest.config.ts`); the suites and dogfood below were measured on `a5b8e9bf` / `1f72080c`, and the gate list was re-run on `5b16eb34`. <details><summary>Red first on main, green with the fix, ablation</summary> - Red, before the fix (base `aca58cdb`, the tests then in their pre-#29495 home): `Tests 6 failed | 13 passed (19)`. `os validate --json` on the corpus stack answered `"valid": true` with exit 0. Every refused corpus case was `expected [] to deeply equal [ { path: 'flows[0]', … } ]`. - Green on `a5b8e9bf`: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/flow-undeclared-cycle.test.ts test/validate-flow-region-shape.test.ts` → `Test Files 2 passed (2) · Tests 16 passed (16)`. - Ablation on `a5b8e9bf`, through `scripts/ablation-replace.mjs`: anchor `const cycle = findUndeclaredFlowCycle(result.data);` replaced with a null verdict. Output: `anchor x1 -> x0`, `ok mutation landed: anchor 1 -> 0, blob 5f733ce -> 9d51706e9b08`. Run: 6 red (the 4 refused corpus cases, the assembled-artifact case, the validate/build door case). Then `ok restored: blob == HEAD … git diff HEAD empty`. The CLI tests import source, so no dist leg. </details> <details><summary>Suites</summary> - `pnpm --filter @objectstack/cli typecheck`: VERDICT command-exit 0 (on `a5b8e9bf`; `1f72080c` adds one ledger row to a test file). - `pnpm --filter @objectstack/cli exec vitest run --project unit` on `a5b8e9bf`: `Tests 1 failed | 5281 passed (5282)`. The one failure was `validate-build-gate-parity` naming the unclassified `findFlowCycleRefusals`, the forced edit above (`1f72080c`). On `1f72080c`: `validate-build-gate-parity`, `authoring-rule-command-parity`, `flow-undeclared-cycle`, `validate-flow-region-shape` → `Test Files 4 passed (4) · Tests 61 passed (61)`. - `eslint --no-inline-config` over the 7 touched source and test files: exit 0. - Full dogfood on `a5b8e9bf` (`pnpm --filter @objectstack/dogfood test`, after `pnpm install --frozen-lockfile && pnpm build`): VERDICT command-exit 0 · `Test Files 397 passed | 3 skipped (400)` · `Tests 3277 passed | 28 skipped (3305)`. `1f72080c` changes one CLI test file only. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `1f72080c`, and again on `5b16eb34` (62 families, all exit 0, `--ran` the same derived zero): 62 families, each run with its exit code captured before any pipe, all exit 0. `--ran` reconciliation: `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)`. `check:plugin-teardown-shape --self-test` needed the shallow-clone deepen (`git fetch --depth=1 origin 621a487…`) first. </details> <!-- signed: domain:server#2 · os-steve · session_01ADHkmSGEsjUPQp4S968p2X --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56076fd commit dd4a21f

8 files changed

Lines changed: 272 additions & 0 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
**BREAKING** fix(cli)!: `os validate` and `os build` refuse a flow whose top-level graph closes a cycle with no `type: 'back'` edge, as the automation engine refuses it at registration
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
Skills-docs: none — `skills/objectstack-automation/references/state-machines-and-approvals.md` already teaches that a cycle-closing edge MUST be typed `'back'` and that an unmarked cycle is refused; that stays true, and the CLI now refuses the same flow before it ships.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. The build verbs now refuse a flow body the automation engine already refused to register and the publish doors already refuse, with the engine's own sentence, so there is nothing a migration could rewrite: the author's fix is the one the refusal names. -->
12+
13+
**BREAKING** accept-set narrowing at the CLI's author-time doors (`@objectstack/cli`).
14+
15+
- **Before.** A flow whose top-level graph had a cycle with no edge typed `back` (ADR-0044), such as `start → push → check → revise → push`, passed `os validate` and `os build` with exit 0. The engine then refused to register it at boot ("Flow contains a cycle: …"), logged a warning, and ran without it.
16+
- **Now.** Both commands refuse it, exit 1, and `os build` writes no artifact. The row is `VALIDATION_ERROR`, located at the flow (`flows[N]`, or `packages[K].manifest.flows[N]` in an assembled artifact), and its message is the engine's registration refusal, word for word. The judge is `findUndeclaredFlowCycle` from `@objectstack/metadata-core`, the one the engine's registration and the runtime publish gate call, so all three refuse exactly the same graphs. It is a CLI step, not an `@objectstack/lint` rule, since lint does not depend on metadata-core; the rule table runs first, so a flow with both a malformed region (`flow-region-shape-invalid`) and an undeclared cycle is refused for the region first, and for the cycle once the region is fixed (the engine names the cycle first).
17+
- **Unchanged.** A cycle with any of its edges typed `back` validates and builds, as before. Cycles inside a `loop` / `parallel` / `try_catch` region are judged by the region check, as before.
18+
19+
**The remedy.** None for a flow that registers today. A flow this now refuses never ran: if the loop is intended, set `type: 'back'` on the cycle-closing edge (ADR-0044); otherwise remove or redirect that edge.

‎packages/cli/src/commands/compile.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ import { findOverrideDeclarationRefusals } from '../utils/override-declarations.
8484
// (`judgeExtensionFieldSuffix`).
8585
import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffix.js';
8686

87+
// [#29051] An undeclared top-level flow cycle — the walk `os validate` step
88+
// 3a-vii runs, over the automation engine's own judge.
89+
import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js';
90+
8791
export default class Compile extends Command {
8892
static override description = 'Compile ObjectStack configuration to JSON artifact';
8993

@@ -841,6 +845,27 @@ export default class Compile extends Command {
841845
// rule table at step 3b since #29049 (`flow-region-shape-invalid`),
842846
// the same verdict `os validate` and `os lint` give.
843847

848+
// 3b-iii-quater. [#29051] An undeclared top-level flow cycle (ADR-0044),
849+
// at build time — the SAME call `os validate` makes at its step
850+
// 3a-vii: the engine's registration judge (`findUndeclaredFlowCycle`).
851+
// Before this the build emitted a flow the engine then refused at
852+
// boot; now the door that ships refuses it and writes no artifact.
853+
const cycleRefusals = findFlowCycleRefusals(result.data as Record<string, unknown>);
854+
if (cycleRefusals.length > 0) {
855+
if (flags.json) {
856+
await emitJson({ success: false, errors: cycleRefusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
857+
this.exit(1);
858+
}
859+
const n = cycleRefusals.length;
860+
console.log('');
861+
printError(`The automation engine would refuse to register ${n} flow${n > 1 ? 's' : ''} for a cycle no back edge declares (ADR-0044)`);
862+
printBulletList(
863+
cycleRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`),
864+
{ noun: 'flow cycle refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
865+
);
866+
this.exit(1);
867+
}
868+
844869
// 3b-quater. ADR-0141 D2, author side — every `manifest.dependencies`
845870
// range of the packages one install act would deliver, judged by the
846871
// install's own predicate (`utils/dependency-range-check.ts`).

‎packages/cli/src/commands/validate.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffi
8181
// [ADR-0146 D10, second rule] The field-patch target check's walk; the verdict
8282
// is the marketplace install door's own judge.
8383
import { findFieldPatchTargetRefusals } from '../utils/field-patch-targets.js';
84+
// [#29051] An undeclared top-level flow cycle (ADR-0044): the automation
85+
// engine's own judge (`findUndeclaredFlowCycle`, `@objectstack/metadata-core`).
86+
import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js';
8487
// A field `picklist` that names no picklist the stack declares — refused, or
8588
// reported when the declaring package depends on packages outside the stack.
8689
// Walked the way the load path registers, like the refusal above.
@@ -756,6 +759,36 @@ export default class Validate extends Command {
756759
// `@objectstack/lint`'s `validate-flow-region-shape.ts`), so all
757760
// three authoring commands give the engine's registration verdict.
758761

762+
// 3a-vii. [#29051] An undeclared cycle in a flow's TOP-LEVEL graph
763+
// (ADR-0044): a cycle that survives setting the `type: 'back'` edges
764+
// aside. The verdict is the engine's registration judge
765+
// (`findUndeclaredFlowCycle`, `@objectstack/metadata-core`), the
766+
// one the publish doors run too, so a flow green here is one boot
767+
// registers; see `utils/flow-undeclared-cycle.ts`. It is a CLI step,
768+
// not a rule-table entry, because `@objectstack/lint` does not
769+
// depend on metadata-core.
770+
const cycleRefusals = findFlowCycleRefusals(result.data as Record<string, unknown>);
771+
if (cycleRefusals.length > 0) {
772+
if (flags.json) {
773+
await emitJson({
774+
valid: false,
775+
errors: cycleRefusals,
776+
warnings: warningsSoFar(),
777+
conversions: conversionNotices,
778+
duration: timer.elapsed(),
779+
});
780+
this.exit(1);
781+
}
782+
const n = cycleRefusals.length;
783+
console.log('');
784+
printError(`The automation engine would refuse to register ${n} flow${n > 1 ? 's' : ''} for a cycle no back edge declares (ADR-0044)`);
785+
printBulletList(
786+
cycleRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`),
787+
{ noun: 'flow cycle refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
788+
);
789+
this.exit(1);
790+
}
791+
759792
// 3b. [#3366] Installable-provider preflight — the shift-left of the
760793
// `serve`-time capability check. `os validate` previously only checked
761794
// the `requires` tokens against the vocabulary (ADR-0066), never
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29051] `findFlowCycleRefusals`: an undeclared cycle in a flow's TOP-LEVEL
5+
* graph (ADR-0044) is a row, located at the flow, in the engine's sentence.
6+
*
7+
* The cases are the judge's own corpus (`@objectstack/metadata-core/testing`),
8+
* the one the engine's registration and the runtime publish gate are driven
9+
* from, so the CLI refuses exactly the graphs they refuse. What the doors do
10+
* with a row is pinned end to end in `test/validate-flow-region-shape.test.ts`;
11+
* here the subject is the row.
12+
*/
13+
14+
import { describe, expect, it } from 'vitest';
15+
16+
import { undeclaredFlowCycleMessage } from '@objectstack/metadata-core';
17+
import { FLOW_CYCLE_CORPUS } from '@objectstack/metadata-core/testing';
18+
19+
import { findFlowCycleRefusals } from './flow-undeclared-cycle.js';
20+
21+
const located = (rows: ReturnType<typeof findFlowCycleRefusals>) => rows.map(({ path, code, flow }) => ({ path, code, flow }));
22+
23+
describe('[#29051] a top-level cycle not declared back is refused, as the engine refuses it', () => {
24+
for (const c of FLOW_CYCLE_CORPUS) {
25+
it(c.title, () => {
26+
const rows = findFlowCycleRefusals({ flows: [c.flow] });
27+
if (c.cycle === null) {
28+
expect(rows).toEqual([]);
29+
return;
30+
}
31+
expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: c.flow.name }]);
32+
expect(rows[0]!.message).toBe(`Flow '${c.flow.name}' would be refused at registration: ${undeclaredFlowCycleMessage(c.cycle)}`);
33+
});
34+
}
35+
36+
it('an artifact\'s packages[] entry: located under its own entry, and the top level is not walked', () => {
37+
const refused = FLOW_CYCLE_CORPUS.find((c) => c.cycle !== null)!;
38+
const passes = FLOW_CYCLE_CORPUS.find((c) => c.cycle === null)!;
39+
const rows = findFlowCycleRefusals({
40+
flows: [refused.flow],
41+
packages: [
42+
{ manifest: { id: 'com.example.a', flows: [passes.flow] } },
43+
{ manifest: { id: 'com.example.b', flows: [passes.flow, refused.flow] } },
44+
],
45+
});
46+
expect(located(rows)).toEqual([{ path: 'packages[1].manifest.flows[1]', code: 'VALIDATION_ERROR', flow: refused.flow.name }]);
47+
});
48+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #29051 — an undeclared cycle in a flow's TOP-LEVEL graph, at `os validate`
5+
* and `os build` (ADR-0044).
6+
*
7+
* The automation engine refuses to register a flow whose graph, once its
8+
* `type: 'back'` edges are set aside, still has a cycle. It asks
9+
* `findUndeclaredFlowCycle` (`@objectstack/metadata-core`) at registration, and
10+
* so does the runtime authoring gate every publish door runs. Before this
11+
* module `os validate` and `os build` passed such a flow with exit 0; it then
12+
* published or booted, the engine refused it, and it never ran.
13+
*
14+
* The verdict here is that same call on the same parsed body (`FlowSchema`,
15+
* then the judge), so there is no second copy of the DFS, and the message is
16+
* the engine's sentence, verbatim, behind the flow's name. It lives in the CLI,
17+
* not in the `@objectstack/lint` rule table beside `flow-region-shape-invalid`,
18+
* because lint does not depend on `@objectstack/metadata-core`.
19+
*
20+
* Order: the engine judges the cycle before the ADR-0031 region shape. The
21+
* commands run the rule table first, so a flow that has both a malformed
22+
* region and an undeclared cycle is refused for its region; once that is
23+
* fixed, this step refuses the cycle. Both are refusals; only which one an
24+
* author reads first differs.
25+
*
26+
* Walked the way the other CLI flow walks are: each `packages[]` entry's flows,
27+
* or the stack's own flows when it declares no packages.
28+
*
29+
* A flow `FlowSchema` refuses here is not judged: `flow-region-shape-invalid`
30+
* already refused it at the rule-table step, which exits before this one, so
31+
* no flow reaches this walk unjudged.
32+
*/
33+
34+
import { findUndeclaredFlowCycle } from '@objectstack/metadata-core';
35+
import { FlowSchema } from '@objectstack/spec/automation';
36+
37+
import { artifactPackages } from './artifact-packages.js';
38+
39+
/** One refused flow, located. */
40+
export interface FlowCycleRefusalRow {
41+
/** Where the flow sits in the parsed stack, e.g. `flows[0]` or `packages[1].manifest.flows[0]`. */
42+
path: string;
43+
/** The code the CLI's other author-time refusals carry. */
44+
code: 'VALIDATION_ERROR';
45+
/** The flow's name. */
46+
flow: string;
47+
/** `Flow '<name>' would be refused at registration: ` followed by the engine's refusal, verbatim. */
48+
message: string;
49+
}
50+
51+
/** Every flow of the parsed stack the engine would refuse to register for an undeclared top-level cycle. */
52+
export function findFlowCycleRefusals(parsed: Record<string, unknown>): FlowCycleRefusalRow[] {
53+
const entries = artifactPackages(parsed);
54+
const subjects = entries.length > 0
55+
? entries.map(({ index, body }) => ({ flows: body.flows, at: `packages[${index}].manifest.` }))
56+
: [{ flows: parsed.flows, at: '' }];
57+
const rows: FlowCycleRefusalRow[] = [];
58+
for (const { flows, at } of subjects) {
59+
if (!Array.isArray(flows)) continue;
60+
flows.forEach((raw, i) => {
61+
const result = FlowSchema.safeParse(raw);
62+
if (!result.success) return;
63+
const cycle = findUndeclaredFlowCycle(result.data);
64+
if (!cycle) return;
65+
const name = result.data.name;
66+
rows.push({
67+
path: `${at}flows[${i}]`,
68+
code: 'VALIDATION_ERROR',
69+
flow: name,
70+
message: `Flow '${name}' would be refused at registration: ${cycle.message}`,
71+
});
72+
});
73+
}
74+
return rows;
75+
}

‎packages/cli/test/validate-build-gate-parity.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [
213213
// (#28727), so the row moved here — deleted there rather than reworded, for
214214
// the reason the `runPerPackageAuthoringRules` row above gives.
215215
'findExtensionFieldSuffixRefusals',
216+
// [#29051] An undeclared cycle in a flow's top-level graph (ADR-0044),
217+
// judged by the automation engine's own registration judge
218+
// (`findUndeclaredFlowCycle`, `@objectstack/metadata-core`), which the
219+
// publish doors run too. Not a registry rule: `@objectstack/lint` does not
220+
// depend on metadata-core, so the rule table cannot reach the judge without
221+
// a new package edge, and a copy of the walk is what the ruling forbids. So
222+
// `os lint` does not give this verdict; validate and build do.
223+
'findFlowCycleRefusals',
216224
];
217225

218226
/**

‎packages/cli/test/validate-flow-region-shape.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { fileURLToPath } from 'node:url';
3131
import Compile from '../src/commands/compile';
3232
import Lint from '../src/commands/lint';
3333
import Validate from '../src/commands/validate';
34+
import { undeclaredFlowCycleMessage } from '@objectstack/metadata-core';
35+
import { FLOW_CYCLE_CORPUS } from '@objectstack/metadata-core/testing';
3436
import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js';
3537

3638
const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
@@ -151,6 +153,10 @@ const CASES: Record<string, { stack: Record<string, unknown>; refusal?: string }
151153
stack: assembledWith(flow('rgn_ok', guard(fanOut(true))), flow('rgn_ops_lead_time', guard(fanOut(false)))),
152154
refusal: "try_catch 'guard' catch: region must be single-exit but has 3: x, y, z",
153155
},
156+
// [#29051] The flow-cycle judge's whole corpus in one stack, and the cases it
157+
// must pass on their own.
158+
cycles: { stack: stackWith(FLOW_CYCLE_CORPUS.map((c) => c.flow)) },
159+
cyclesDeclared: { stack: stackWith(FLOW_CYCLE_CORPUS.filter((c) => c.cycle === null).map((c) => c.flow)) },
154160
};
155161

156162
let root = '';
@@ -251,3 +257,48 @@ describe('[#28893] validate, build and lint refuse a flow region the engine refu
251257
expect(issues.filter((i) => i.rule === RULE)).toEqual([]);
252258
}, 60_000);
253259
});
260+
261+
/**
262+
* [#29051] A flow whose TOP-LEVEL graph closes a cycle with no `type: 'back'`
263+
* edge (ADR-0044). The engine refuses it at registration with
264+
* `findUndeclaredFlowCycle` (`@objectstack/metadata-core`); before this,
265+
* `os validate` and `os build` passed it with exit 0 and the flow never ran.
266+
* Driven from the judge's own corpus, so the doors refuse exactly the cases
267+
* the engine and the publish gate refuse, each at its own flow, in the
268+
* engine's sentence.
269+
*/
270+
describe('[#29051] validate and build refuse an undeclared top-level flow cycle, with the engine\'s sentence', () => {
271+
const refused = FLOW_CYCLE_CORPUS
272+
.map((c, i) => ({ c, i }))
273+
.filter(({ c }) => c.cycle !== null)
274+
.map(({ c, i }) => ({ path: `flows[${i}]`, code: 'VALIDATION_ERROR', flow: c.flow.name, cycle: undeclaredFlowCycleMessage(c.cycle!) }));
275+
const rowsOf = (errors: Array<Record<string, unknown>>) =>
276+
errors.filter((r) => String(r.message).includes('Flow contains a cycle'));
277+
const expectRefused = (errors: Array<Record<string, unknown>>) => {
278+
const rows = rowsOf(errors);
279+
expect(rows.map(({ path, code, flow }) => ({ path, code, flow })), JSON.stringify(errors, null, 2))
280+
.toEqual(refused.map(({ path, code, flow }) => ({ path, code, flow })));
281+
rows.forEach((row, k) => expect(String(row.message)).toContain(refused[k]!.cycle));
282+
};
283+
284+
it('validate exits 1 with one row per refused corpus flow; build refuses the same and writes nothing', async () => {
285+
const validated = await validate('cycles', '--json');
286+
expect(validated.code, validated.stdout).toBe(1);
287+
expectRefused(json(validated, 'validate cycles').errors as Array<Record<string, unknown>>);
288+
289+
const built = await build('cycles', '--json');
290+
expect(built.code, built.stdout).toBe(1);
291+
const payload = json(built, 'build cycles');
292+
expect(payload.success).toBe(false);
293+
expectRefused(payload.errors as Array<Record<string, unknown>>);
294+
expect(existsSync(fixtures.cycles.dist)).toBe(false);
295+
}, 60_000);
296+
297+
it('CONTROL: the corpus flows whose loops are declared back (and the acyclic ones) validate and build', async () => {
298+
const validated = await validate('cyclesDeclared', '--json');
299+
expect(validated.code, validated.stdout).toBe(0);
300+
const built = await build('cyclesDeclared', '--json');
301+
expect(json(built, 'build cyclesDeclared').success, built.stdout).toBe(true);
302+
expect(built.code).toBe(0);
303+
}, 60_000);
304+
});

‎packages/cli/vitest.config.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,19 @@ export default defineConfig({
875875
find: /^create-objectstack\/created-summary$/,
876876
replacement: path.resolve(__dirname, '../create-objectstack/src/created-summary.ts'),
877877
},
878+
// [#29051] `FLOW_CYCLE_CORPUS`, the case table the flow-cycle judge, the
879+
// engine's registration and the publish gate are driven from, pins
880+
// `os validate` / `os build` to the same graphs
881+
// (`src/utils/flow-undeclared-cycle.test.ts`,
882+
// `test/validate-flow-region-shape.test.ts`). The subpath's `exports`
883+
// entry is built from `src/testing.ts`; aliased so the corpus the tests
884+
// read is the checkout's, not a stale `dist/testing.js`. The bare
885+
// `@objectstack/metadata-core` specifier has its own anchored entry
886+
// below (the #20158 block); this one covers only the `/testing` subpath.
887+
{
888+
find: /^@objectstack\/metadata-core\/testing$/,
889+
replacement: path.resolve(__dirname, '../metadata-core/src/testing.ts'),
890+
},
878891
// `test/serve-node-env-production-default.e2e.test.ts` (#11113) writes a
879892
// FIXTURE config file whose text is `import { AuthPlugin } from
880893
// '@objectstack/plugin-auth'` — real code, but code the fixture's own

0 commit comments

Comments
 (0)