From 03cc2f409af124d438ac6d6ac4ce4c7f11a7b678 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:34:28 +0000 Subject: [PATCH 1/2] fix(cli): the flow-region gate never passes a flow it could not judge, and the boot banner counts refused flows - findFlowRegionRefusals: a FlowSchema parse failure is a located VALIDATION_ERROR row instead of a silent return. - authoring-rule-command-parity: the OUTSIDE_THE_TABLE pin also asserts the doors' own gate refuses its fixture with the marker; docblock says it measures the registry; test-budget header counts ten rule cases. - validate-flow-region-shape: an assembled-artifact packages[] fixture, asserting exit code, path and that build writes no dist/. - os serve banner: declared flows the engine refused at registration are counted beside the declared count, from getFlowRegistration. Claude-Session: https://claude.ai/code/session_01ADHkmQBbn1k9FtQeh2nJSs Co-authored-by: Claude --- .../29059-flow-region-gate-hardening.md | 11 ++ .../commands/serve-automation-refused.test.ts | 120 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 28 +++- .../cli/src/utils/flow-region-shape.test.ts | 48 +++++++ packages/cli/src/utils/flow-region-shape.ts | 33 ++++- .../utils/format.boot-warning-classes.test.ts | 1 + .../format.flow-contender-agreement.test.ts | 1 + packages/cli/src/utils/format.ts | 21 +++ .../authoring-rule-command-parity.test.ts | 33 ++++- .../test/validate-flow-region-shape.test.ts | 43 +++++++ 10 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 .changeset/29059-flow-region-gate-hardening.md create mode 100644 packages/cli/src/commands/serve-automation-refused.test.ts create mode 100644 packages/cli/src/utils/flow-region-shape.test.ts diff --git a/.changeset/29059-flow-region-gate-hardening.md b/.changeset/29059-flow-region-gate-hardening.md new file mode 100644 index 00000000000..b797e894b35 --- /dev/null +++ b/.changeset/29059-flow-region-gate-hardening.md @@ -0,0 +1,11 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): the os validate / os build flow-region check never passes a flow it could not judge, and the boot banner counts flows refused at registration + +Clause-②: no +Metadata-protocol: none + +- **No silent pass.** The flow-region check `os validate` and `os build` run (the automation engine's registration verdict, ADR-0031) parses each flow with `FlowSchema` before it judges the regions. When that parse failed it used to skip the flow with no row, so the flow passed unjudged. It now refuses it with a `VALIDATION_ERROR` row at the flow's `path`, naming the flow and the schema's issues. No authored stack reaches this branch today: the stack schema has already parsed every flow the check reads, and `FlowSchema` accepts its own output. Nothing refused or accepted today changes. +- **Refused flows in the boot banner.** When the automation engine refuses a declared flow at registration, boot still continues and the per-flow reason is still under Boot diagnostics. The `os serve` / `os dev` banner now also prints one line beside the declared count, for example `⚠ Flows: 2 flow(s) declared, 1 refused at registration`, read from the engine's own record (`getFlowRegistration`). It prints even when every declared flow was refused and nothing registered. A boot with nothing refused prints no such line. diff --git a/packages/cli/src/commands/serve-automation-refused.test.ts b/packages/cli/src/commands/serve-automation-refused.test.ts new file mode 100644 index 00000000000..54b2a477f12 --- /dev/null +++ b/packages/cli/src/commands/serve-automation-refused.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#29059] The boot banner counts declared flows the engine REFUSED at +// registration, beside the declared count. +// +// A flow `registerFlow` refuses is logged once at WARN and the boot goes on +// without it (#28893's second ask; that is unchanged here). Before this the +// banner's Flows line counted only what armed, so a refused flow showed only +// as a per-flow line under Boot diagnostics, and a boot whose every declared +// flow was refused printed no Flows line at all. +// +// The count is read off the engine's own record: the host's sync calls +// `recordFlowRefusal` when `registerFlow` throws, and the banner asks +// `getFlowRegistration(name)` for each declared name, the read the metadata +// door already serves. So the engine here is the REAL one (dist, already in +// `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`), refusing a real +// malformed region, not a fake that answers `refused` by fiat. Locally: build +// `@objectstack/service-automation` before running this. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AutomationEngine } from '@objectstack/service-automation'; +import { collectAutomationSummary } from './serve.js'; +import { printServerReady, type ServerReadyOptions } from '../utils/format.js'; + +const quiet = () => ({ debug() {}, info() {}, warn() {}, error() {} }) as never; + +const kernelWith = (automation: unknown) => ({ + getService(name: string) { + if (name === 'automation') return automation; + throw new Error(`Service '${name}' not found`); + }, +}); + +const n = (id: string, type = 'assignment', extra: Record = {}) => ({ id, type, label: id, ...extra }); +const e = (id: string, source: string, target: string) => ({ id, source, target, type: 'default' }); + +/** An autolaunched flow: start → → end. */ +const flow = (name: string, middle: Record) => ({ + name, label: name, type: 'autolaunched', status: 'active', + nodes: [n('start', 'start'), middle, n('end', 'end')], + edges: [e('e1', 'start', String(middle.id)), e('e2', String(middle.id), 'end')], +}); + +/** A `parallel` with one branch: `validateControlFlow` refuses it at registration. */ +const refusedFlow = (name: string) => flow(name, n('fan', 'parallel', { config: { branches: [{ nodes: [n('only')], edges: [] }] } })); +const goodFlow = (name: string) => flow(name, n('work')); + +/** Register each flow the way the host's sync does: a refusal is recorded, never thrown on. */ +function engineWith(flows: Array<{ name: string }>): AutomationEngine { + const engine = new AutomationEngine(quiet()); + for (const def of flows) { + try { + engine.registerFlow(def.name, def as never); + } catch (err) { + engine.recordFlowRefusal(def.name, err); + } + } + return engine; +} + +const base: ServerReadyOptions = { + externalBaseOrigin: 'http://localhost:3000', + httpServer: 'composed', + configFile: 'objectstack.config.ts', + isDev: true, + pluginCount: 1, +}; + +describe('[#29059] the boot banner counts flows refused at registration', () => { + let lines: string[]; + let spy: ReturnType; + beforeEach(() => { + lines = []; + // stderr, not stdout (#7915) — the whole banner is a diagnostic. + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ')); + }); + }); + afterEach(() => spy.mockRestore()); + + const flowsLines = () => lines.filter((l) => l.includes('Flows:')); + + it('one of two declared flows refused: the count rides beside the declared count', () => { + const declared = [goodFlow('ok_flow'), refusedFlow('fan_flow')]; + const engine = engineWith(declared); + // The engine really refused it: the record is its own, not the test's. + expect(engine.getFlowRegistration('fan_flow').state).toBe('refused'); + + const summary = collectAutomationSummary(kernelWith(engine), declared.length, declared.map((f) => f.name))!; + expect(summary).toMatchObject({ declaredFlowCount: 2, refusedCount: 1, flowCount: 1 }); + + printServerReady({ ...base, automation: summary }); + expect(flowsLines().filter((l) => l.includes('2 flow(s) declared, 1 refused at registration'))).toHaveLength(1); + }); + + it('every declared flow refused: the banner still says so, though nothing registered', () => { + const declared = [refusedFlow('fan_flow')]; + const summary = collectAutomationSummary(kernelWith(engineWith(declared)), 1, ['fan_flow'])!; + expect(summary).toMatchObject({ declaredFlowCount: 1, refusedCount: 1, flowCount: 0 }); + + printServerReady({ ...base, automation: summary }); + expect(flowsLines().filter((l) => l.includes('1 flow(s) declared, 1 refused at registration'))).toHaveLength(1); + }); + + it('CONTROL: nothing refused prints no refused line', () => { + const declared = [goodFlow('ok_flow')]; + const summary = collectAutomationSummary(kernelWith(engineWith(declared)), 1, ['ok_flow'])!; + expect(summary.refusedCount).toBe(0); + + printServerReady({ ...base, automation: summary }); + expect(lines.filter((l) => l.includes('refused at registration'))).toEqual([]); + expect(flowsLines()).toHaveLength(1); + }); + + it('an engine that does not answer the read degrades to a zero count, not a crash', () => { + const older = { getFlowRuntimeStates: () => [{ name: 'ok_flow', enabled: true, bound: false }] }; + const summary = collectAutomationSummary(kernelWith(older), 2, ['ok_flow', 'fan_flow'])!; + expect(summary.refusedCount).toBe(0); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6df3659aba8..0355826c247 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5705,9 +5705,13 @@ export default class Serve extends Command { // [#22288] Counted by `resolveStackCollection`'s rule, so a flow a // package declares is counted: the "declared but the automation engine is // not enabled" line used to stay silent for every multi-package app. + const declaredFlows = resolveStackCollection(config, 'flows'); const automationSummary = collectAutomationSummary( kernel, - resolveStackCollection(config, 'flows').length, + declaredFlows.length, + declaredFlows + .map((f) => (f as { name?: unknown } | null)?.name) + .filter((name): name is string => typeof name === 'string' && name !== ''), ); // ── Seed outcome summary (#3415/#3430) ───────────────────────── @@ -7241,6 +7245,11 @@ export function resolveBannerConfigRow(opts: { export function collectAutomationSummary( kernel: any, declaredFlowCount: number, + /** + * [#29059] The declared flows' names, for the refused-at-registration count. + * Optional so a caller with only a count keeps its summary; its count is 0. + */ + declaredFlowNames: readonly string[] = [], ): AutomationReadySummary | undefined { let automation: any; try { automation = kernel?.getService?.('automation'); } catch { /* not registered */ } @@ -7250,6 +7259,7 @@ export function collectAutomationSummary( ? { enabled: false, declaredFlowCount, + refusedCount: 0, flowCount: 0, boundCount: 0, triggerTypes: [], @@ -7292,6 +7302,21 @@ export function collectAutomationSummary( try { states = automation.getFlowRuntimeStates?.() ?? []; } catch { /* older engine */ } if (states.length === 0 && declaredFlowCount === 0) return undefined; + // [#29059] Declared flows the engine refused at registration, read off the + // engine's OWN record through the read it already serves the metadata door + // (`IAutomationService.getFlowRegistration`), one declared name at a time. + // No new engine surface: the probe is optional and feature-detected like the + // ones beside it. The record is written by the `kernel:ready` bind, which + // has settled by the time the banner prints. + let refusedCount = 0; + if (typeof automation.getFlowRegistration === 'function') { + for (const name of new Set(declaredFlowNames)) { + try { + if (automation.getFlowRegistration(name)?.state === 'refused') refusedCount++; + } catch { /* older engine */ } + } + } + let triggerTypes: string[] = []; try { triggerTypes = automation.getRegisteredTriggerTypes?.() ?? []; } catch { /* older engine */ } @@ -7328,6 +7353,7 @@ export function collectAutomationSummary( return { enabled: true, declaredFlowCount, + refusedCount, flowCount: states.length, boundCount: states.filter((s) => s.bound).length, triggerTypes, diff --git a/packages/cli/src/utils/flow-region-shape.test.ts b/packages/cli/src/utils/flow-region-shape.test.ts new file mode 100644 index 00000000000..29b46230948 --- /dev/null +++ b/packages/cli/src/utils/flow-region-shape.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29059] `findFlowRegionRefusals` never passes a flow it could not judge. + * + * The walk parses each flow with `FlowSchema` before it asks + * `validateControlFlow` for the region verdict. The stack schema has already + * parsed every flow it reads, and `FlowSchema` is idempotent on its own output, + * so no authored stack reaches the refused-parse branch today; the cases below + * hand the walk bodies the schema refuses, which is the only way to force it. + * Before #29059 the branch was a bare `return`: no row, so `os validate` and + * `os build` passed a flow whose region shape nobody had judged. + * + * What the door does with a row is pinned end to end in + * `test/validate-flow-region-shape.test.ts`; here the subject is the row. + */ + +import { describe, expect, it } from 'vitest'; + +import { findFlowRegionRefusals } from './flow-region-shape.js'; + +/** A body `FlowSchema` refuses: `nodes` must be an array. */ +const unparseable = (name?: string) => ({ ...(name === undefined ? {} : { name }), label: 'Broken', type: 'autolaunched', nodes: 'start', edges: [] }); + +const located = (rows: ReturnType) => rows.map(({ path, code, flow }) => ({ path, code, flow })); + +describe('[#29059] a flow the flow schema refuses is a row, never a silent pass', () => { + it('the stack\'s own flows: the row names the flow and its path', () => { + const rows = findFlowRegionRefusals({ flows: [unparseable('rgn_broken')] }); + expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]); + expect(rows[0]!.message).toContain("'rgn_broken'"); + // The schema's own issue rides the row, so the author is told what it refused. + expect(rows[0]!.message).toContain('nodes'); + }); + + it('a body with no string name is named by its path', () => { + const rows = findFlowRegionRefusals({ flows: [unparseable()] }); + expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: 'flows[0]' }]); + }); + + it('an artifact\'s packages[] entry: located under its own entry', () => { + const rows = findFlowRegionRefusals({ + flows: [unparseable('rgn_top_level_not_walked')], + packages: [{ manifest: { id: 'com.example.a', flows: [] } }, { manifest: { id: 'com.example.b', flows: [unparseable('rgn_broken')] } }], + }); + expect(located(rows)).toEqual([{ path: 'packages[1].manifest.flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]); + }); +}); diff --git a/packages/cli/src/utils/flow-region-shape.ts b/packages/cli/src/utils/flow-region-shape.ts index 955c9e0b5e6..e6ebf20c648 100644 --- a/packages/cli/src/utils/flow-region-shape.ts +++ b/packages/cli/src/utils/flow-region-shape.ts @@ -19,8 +19,15 @@ * * Walked the way `findExtensionFieldSuffixRefusals` (`./extension-field-suffix.ts`) * walks: each `packages[]` entry's flows, or the stack's own flows when it - * declares no packages. A flow the schema refuses is left to the schema step, - * which has already reported it. + * declares no packages. + * + * A flow `FlowSchema` refuses HERE is a row, never a skip (#29059). The stack + * schema has already parsed every flow this walk reads, and `FlowSchema` is + * idempotent on its own output today, so the branch cannot be reached by an + * author. If that ever stops being true the region verdict was never computed, + * and a silent `return` would pass the flow: the false-clean direction this + * gate exists to close. The row says the flow could not be judged, with the + * schema's issues, and the door refuses on it like any other row. * * Not judged here: a cycle in the flow's TOP-LEVEL graph. The engine refuses it * with `detectCycles`, which lives in `@objectstack/service-automation` and is @@ -37,9 +44,12 @@ export interface FlowRegionRefusalRow { path: string; /** The code the CLI's other author-time refusals carry. */ code: 'VALIDATION_ERROR'; - /** The flow's name. */ + /** The flow's name; its `path` when the body carries no string name. */ flow: string; - /** `Flow '' …: ` followed by the engine's refusal, verbatim. */ + /** + * `Flow '' …: ` followed by the engine's refusal, verbatim — or, when + * `FlowSchema` refuses the parsed body, the schema's issues (#29059). + */ message: string; } @@ -54,7 +64,20 @@ export function findFlowRegionRefusals(parsed: Record): FlowReg if (!Array.isArray(flows)) continue; flows.forEach((raw, i) => { const result = FlowSchema.safeParse(raw); - if (!result.success) return; + if (!result.success) { + const declared = (raw as { name?: unknown } | null)?.name; + const name = typeof declared === 'string' && declared !== '' ? declared : `${at}flows[${i}]`; + const issues = result.error.issues + .map((issue) => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`) + .join('; '); + rows.push({ + path: `${at}flows[${i}]`, + code: 'VALIDATION_ERROR', + flow: name, + message: `Flow '${name}' could not be judged for its region shape: the flow schema refused the parsed body (${issues})`, + }); + return; + } try { validateControlFlow(result.data); } catch (err) { diff --git a/packages/cli/src/utils/format.boot-warning-classes.test.ts b/packages/cli/src/utils/format.boot-warning-classes.test.ts index cff15927db4..1cba5240700 100644 --- a/packages/cli/src/utils/format.boot-warning-classes.test.ts +++ b/packages/cli/src/utils/format.boot-warning-classes.test.ts @@ -238,6 +238,7 @@ const policyRefused = (names: string[], triggerType = 'schedule'): AutomationRea const summary = (over: Partial): AutomationReadySummary => ({ enabled: true, declaredFlowCount: 0, + refusedCount: 0, flowCount: 10, boundCount: 0, triggerTypes: ['schedule', 'record_change'], diff --git a/packages/cli/src/utils/format.flow-contender-agreement.test.ts b/packages/cli/src/utils/format.flow-contender-agreement.test.ts index 4ea4db721c9..ec8d6177af4 100644 --- a/packages/cli/src/utils/format.flow-contender-agreement.test.ts +++ b/packages/cli/src/utils/format.flow-contender-agreement.test.ts @@ -77,6 +77,7 @@ const base: ServerReadyOptions = { const summaryWith = (armed: Contender, shadowedCount = 1): AutomationReadySummary => ({ enabled: true, declaredFlowCount: 1, + refusedCount: 0, flowCount: 1, boundCount: 1, triggerTypes: ['record_change'], diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 7b135df99a5..caac37c190c 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1011,6 +1011,15 @@ export interface AutomationReadySummary { enabled: boolean; /** Flows declared in the stack config (used when the engine is absent). */ declaredFlowCount: number; + /** + * [#29059] Declared flows the engine REFUSED at registration, from its own + * record (`getFlowRegistration(name).state === 'refused'`), counted by + * distinct declared name. A refused flow is absent from `flowCount` and from + * every list below, so without this the banner counts only what armed and + * the refusal shows only as a per-flow line under Boot diagnostics. + * 0 when the engine is absent or does not answer the read. + */ + refusedCount: number; /** Flows registered in the engine (0 when `enabled` is false). */ flowCount: number; /** Flows bound to a trigger. */ @@ -1713,6 +1722,18 @@ function printAutomationSummary(a: AutomationReadySummary): string[] { } return restated; } + // [#29059] Printed BEFORE the `flowCount === 0` return: a boot whose every + // declared flow was refused registers none, and would otherwise print no + // Flows line at all. Boot continued either way; this only says so. The + // engine's per-flow reason stays where it is, under Boot diagnostics. + if (a.refusedCount > 0) { + console.error( + chalk.yellow( + ` ⚠ Flows: ${a.declaredFlowCount} flow(s) declared, ${a.refusedCount} refused at registration — ` + + `refused flows never run; each one's reason is under Boot diagnostics ('failed to register flow')`, + ), + ); + } if (a.flowCount === 0) return restated; const parts = [`${a.flowCount} flow(s)`, `${a.boundCount} bound to triggers`]; diff --git a/packages/cli/test/authoring-rule-command-parity.test.ts b/packages/cli/test/authoring-rule-command-parity.test.ts index 795a6b1c020..51d0574e2d0 100644 --- a/packages/cli/test/authoring-rule-command-parity.test.ts +++ b/packages/cli/test/authoring-rule-command-parity.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 20 — spawns the real producer once per single-cause fixture: three spawns of the source CLI (os lint, os validate, os build) over one planted-defect stack, every one a distinct triple whose exit code is the contract; the nine rule cases run in process; slowest CI reading 12.5 s (run 41271604267) of the five scheduled CI runs 41199450735, 41217827347, 41237094739, 41255489006, 41271604267, x 1.25, rounded up +// test-budget: 20 — spawns the real producer once per single-cause fixture: three spawns of the source CLI (os lint, os validate, os build) over one planted-defect stack, every one a distinct triple whose exit code is the contract; the ten rule cases run in process; slowest CI reading 12.5 s (run 41271604267) of the five scheduled CI runs 41199450735, 41217827347, 41237094739, 41255489006, 41271604267, x 1.25, rounded up /** * The BEHAVIOURAL half of #4409, next to the structural ratchet in @@ -32,6 +32,7 @@ import { fileURLToPath } from 'node:url'; import { AUTHORING_COMMANDS, runAuthoringRules, type AuthoringCommand } from '@objectstack/lint'; import { childEnv } from './helpers/serve-process.js'; import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; +import { findFlowRegionRefusals } from '../src/utils/flow-region-shape.js'; const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', 'run-dev.js'); @@ -145,20 +146,34 @@ const CASES: ReadonlyArray<{ rule: string; blindTo: readonly AuthoringCommand[]; /** * Checks `os validate` and `os build` gate on OUTSIDE the rule table, so * `os lint`, which runs only the table, is blind to them. `blindTo` is the same - * documentation as above; the pin below asserts the blindness, so closing it - * (registering the check as an authoring rule) reds here and the case moves - * into `CASES`. + * documentation as above; `gate` is the check the two doors call. + * + * The pin below measures two things on the SAME stack, in process: `gate` + * refuses it with a row carrying `marker` (so the fixture really plants the + * defect), and the rule registry, run as each `blindTo` command runs it, + * reports no finding carrying `marker`. It measures the registry, not the + * `os lint` command. Closing the gap (registering the check as an authoring + * rule) reds the second half here and the case moves into `CASES`; a fixture + * that stops planting the defect reds the first half (#29059) instead of + * leaving the blindness assertion vacuously green. * * - flow region shape (#28893): a `try_catch` region that is not single-exit. * validate and build refuse it with the automation engine's own registration - * verdict (`utils/flow-region-shape.ts`, pinned by - * `validate-flow-region-shape.test.ts`); `os lint` passes it. + * verdict (`findFlowRegionRefusals`, `utils/flow-region-shape.ts`, pinned + * end to end by `validate-flow-region-shape.test.ts`). */ -const OUTSIDE_THE_TABLE: ReadonlyArray<{ check: string; marker: string; blindTo: readonly AuthoringCommand[]; stack: Record }> = [ +const OUTSIDE_THE_TABLE: ReadonlyArray<{ + check: string; + marker: string; + blindTo: readonly AuthoringCommand[]; + gate: (stack: Record) => ReadonlyArray<{ message: string }>; + stack: Record; +}> = [ { check: 'flow region shape (#28893)', marker: 'single-exit', blindTo: ['lint'], + gate: findFlowRegionRefusals, stack: withBaseline({ objects: [{ name: 'parity_task', label: 'Task', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }], flows: [{ @@ -208,6 +223,10 @@ describe('every authoring command reaches the same verdict (#4409)', () => { }); it.each(OUTSIDE_THE_TABLE.map((c) => [c.check, c] as const))('%s: a known gap, os lint is still blind to it', (_check, testCase) => { + // The fixture plants the defect: the doors' own gate refuses it with the + // marker, or the blindness below would hold of a stack nothing refuses. + const refused = testCase.gate(testCase.stack).filter((r) => r.message.includes(testCase.marker)); + expect(refused, JSON.stringify(testCase.gate(testCase.stack), null, 2)).not.toEqual([]); for (const command of testCase.blindTo) { const findings = runAuthoringRules(command, { normalized: testCase.stack, diff --git a/packages/cli/test/validate-flow-region-shape.test.ts b/packages/cli/test/validate-flow-region-shape.test.ts index 848e29bf9b3..d5f427f9c59 100644 --- a/packages/cli/test/validate-flow-region-shape.test.ts +++ b/packages/cli/test/validate-flow-region-shape.test.ts @@ -94,6 +94,26 @@ const stackWith = (flows: unknown[]) => ({ flows, }); +/** + * [#29059] An assembled artifact (ADR-0130 D4): two `packages[]` entries, the + * malformed flow in the SECOND, so the row's `path` has to carry the entry's + * own index (`packages[1].manifest.flows[0]`) and a walk that read only + * `packages[0]`, or the stack's empty top level, would pass it. + */ +const assembledWith = (good: unknown, bad: unknown) => ({ + manifest: { id: 'com.example.rgn', namespace: 'rgn', version: '1.0.0', name: 'Rgn', type: 'app', engines: { protocol: '^18' } }, + packages: [ + { + manifest: { + id: 'com.example.rgn', namespace: 'rgn', version: '1.0.0', name: 'Rgn', type: 'app', + objects: [{ name: OBJECT, label: 'Visa', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], + flows: [good], + }, + }, + { manifest: { id: 'com.example.rgn.ops', namespace: 'rgn', version: '1.0.0', name: 'Ops', type: 'module', flows: [bad] } }, + ], +}); + const CASES: Record; refusal?: string }> = { duly: { stack: stackWith([flow('rgn_lead_time', guard(fanOut(false)))]), @@ -115,6 +135,10 @@ const CASES: Record; refusal?: string } refusal: "parallel 'fan': a parallel block needs at least 2 branches", }, joined: { stack: stackWith([flow('rgn_lead_time', guard(fanOut(true)))]) }, + assembled: { + stack: assembledWith(flow('rgn_ok', guard(fanOut(true))), flow('rgn_ops_lead_time', guard(fanOut(false)))), + refusal: "try_catch 'guard' catch: region must be single-exit but has 3: x, y, z", + }, }; let root = ''; @@ -160,6 +184,25 @@ describe('[#28893] validate and build refuse a flow region the engine refuses, w }, 60_000); } + it('[#29059] an assembled artifact: the refused flow is located in its own packages[] entry, and build writes nothing', async () => { + const { refusal } = CASES.assembled; + const expected = { path: 'packages[1].manifest.flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_ops_lead_time' }; + + const validated = await validate('assembled', '--json'); + expect(validated.code, validated.stdout).toBe(1); + const errors = json(validated, 'validate assembled').errors as Array>; + const rows = errors.filter((r) => String(r.message).includes(refusal!)); + expect(rows.map(({ path, code, flow }) => ({ path, code, flow })), JSON.stringify(errors, null, 2)).toEqual([expected]); + + const built = await build('assembled', '--json'); + expect(built.code, built.stdout).toBe(1); + const payload = json(built, 'build assembled'); + expect(payload.success).toBe(false); + const builtRows = (payload.errors as Array>).filter((r) => String(r.message).includes(refusal!)); + expect(builtRows.map(({ path, code, flow }) => ({ path, code, flow }))).toEqual([expected]); + expect(existsSync(fixtures.assembled.dist)).toBe(false); + }, 60_000); + it('CONTROL: the same flow with the join restored validates and builds', async () => { const validated = await validate('joined', '--json'); expect(validated.code, validated.stdout).toBe(0); From 48c3a64cc7946b4fd0c0024e0bda22382947619e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:43:29 +0000 Subject: [PATCH 2/2] fix(cli): the refused-at-registration banner line follows the Flows count line Claude-Session: https://claude.ai/code/session_01ADHkmQBbn1k9FtQeh2nJSs Co-authored-by: Claude --- packages/cli/src/utils/format.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index caac37c190c..17716318e08 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1722,24 +1722,30 @@ function printAutomationSummary(a: AutomationReadySummary): string[] { } return restated; } - // [#29059] Printed BEFORE the `flowCount === 0` return: a boot whose every - // declared flow was refused registers none, and would otherwise print no - // Flows line at all. Boot continued either way; this only says so. The + // [#29059] Declared flows the engine refused at registration, beside the + // declared count. Boot continued either way; this only says so. The // engine's per-flow reason stays where it is, under Boot diagnostics. - if (a.refusedCount > 0) { + const printRefused = (): void => { + if (a.refusedCount === 0) return; console.error( chalk.yellow( ` ⚠ Flows: ${a.declaredFlowCount} flow(s) declared, ${a.refusedCount} refused at registration — ` + `refused flows never run; each one's reason is under Boot diagnostics ('failed to register flow')`, ), ); + }; + // A boot whose every declared flow was refused registers none: it prints + // the refused line alone rather than no Flows line at all. + if (a.flowCount === 0) { + printRefused(); + return restated; } - if (a.flowCount === 0) return restated; const parts = [`${a.flowCount} flow(s)`, `${a.boundCount} bound to triggers`]; if (a.triggerTypes.length > 0) parts.push(`(${a.triggerTypes.join(', ')})`); if (a.draftCount > 0) parts.push(`· ${a.draftCount} draft`); console.error(chalk.dim(` Flows: ${parts.join(' ')}`)); + printRefused(); // #12028 — printed FIRST among the warnings because it re-reads every line // above it: the counts describe the ARMED bodies only, so when a name is