From a99fdfa374e38fc5c6d739f35abf4ea8989c2440 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:28:18 +0000 Subject: [PATCH 1/5] fix(lint): warn on a flow start input declared a record of the object-less placeholder, or one id the flow reads as a list Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW --- .../validate-flow-trigger-readiness.test.ts | 167 ++++++++++++++++++ .../src/validate-flow-trigger-readiness.ts | 138 +++++++++++++++ 2 files changed, 305 insertions(+) diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 7e9e78fe850..399bab51da5 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -6,6 +6,8 @@ import { validateFlowTriggerReadiness as validateFlowTriggerReadinessUnrecorded, FLOW_TRIGGER_UNKNOWN_OBJECT, FLOW_INPUT_UNKNOWN_OBJECT, + FLOW_INPUT_OBJECT_LESS, + FLOW_INPUT_RECORD_READ_AS_LIST, FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, @@ -1618,6 +1620,49 @@ describe('validateFlowTriggerReadiness', () => { flows: [recordFlow()], }, ], + // A declared start input that can never work as written: advisory. + [ + FLOW_INPUT_OBJECT_LESS, + 'warning', + { + objects: [candidateObject], + flows: [ + { + name: 'object_less_input', + type: 'screen', + status: 'active', + variables: [{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' }], + nodes: [{ id: 'start', type: 'start' }], + }, + ], + }, + ], + [ + FLOW_INPUT_RECORD_READ_AS_LIST, + 'warning', + { + objects: [candidateObject], + flows: [ + { + name: 'one_id_read_as_list', + type: 'screen', + status: 'active', + variables: [{ name: 'candidateIds', type: 'text', isInput: true, objectName: 'app_candidate' }], + nodes: [ + { id: 'start', type: 'start' }, + { + id: 'load', + type: 'get_record', + config: { + objectName: 'app_candidate', + filter: { id: { $in: { dialect: 'cel', source: 'candidateIds' } } }, + }, + }, + ], + }, + ], + }, + ], ]; for (const [rule, severity, stack] of provoke) { @@ -1995,6 +2040,128 @@ describe('[#30009] api trigger readiness reads the hook\'s one verifier (config. }); }); +describe('a start input whose record declaration can never work as written', () => { + const cel = (source: string) => ({ dialect: 'cel', source }); + const getRow = (filter: Record, label = 'Load candidates') => ({ + id: 'load', + type: 'get_record', + label, + config: { objectName: 'app_candidate', filter }, + }); + const inputFlow = (variables: unknown[], nodes: unknown[] = []) => ({ + name: 'review_candidates', + type: 'screen', + status: 'active', + variables, + nodes: [{ id: 'start', type: 'start' }, ...nodes], + }); + const lint = (flow: Record, objects: unknown[] = [candidateObject]) => + validateFlowTriggerReadiness({ objects, flows: [flow] }); + + describe(`${FLOW_INPUT_OBJECT_LESS} — objectName names the object-less placeholder`, () => { + it("warns on 'global', at the entry the author wrote, beside the unknown-object verdict it already had", () => { + const findings = lint(inputFlow([ + { name: 'note', type: 'text', isInput: true }, + { name: 'recordId', type: 'text', isInput: true, objectName: 'global' }, + ])); + expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([ + [FLOW_INPUT_UNKNOWN_OBJECT, 'warning', 'flow "review_candidates" › input "recordId"', 'flows[0].variables[1].objectName'], + [FLOW_INPUT_OBJECT_LESS, 'warning', 'flow "review_candidates" › input "recordId"', 'flows[0].variables[1].objectName'], + ]); + const objectLess = findings[1]; + expect(objectLess.message).toBe( + "declares input 'recordId' a record of 'global', the object-less placeholder, so no start loads it " + + 'from any object and its value reaches the run unjudged', + ); + expect(objectLess.hint).not.toContain('\n'); + }); + + it("warns even where the stack defines an object named 'global': the load skips the key, not the object", () => { + const findings = lint( + inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' }]), + [candidateObject, { name: 'global', label: 'Global', fields: {} }], + ); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[FLOW_INPUT_OBJECT_LESS, 'flows[0].variables[0].objectName']]); + }); + + it("warns on the other object-less key, '*', which only a pre-parse stack can carry", () => { + const findings = lint(inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: '*' }])); + expect(findings.filter((f) => f.rule === FLOW_INPUT_OBJECT_LESS).map((f) => f.path)).toEqual([ + 'flows[0].variables[0].objectName', + ]); + }); + + it('is silent on a declared object the stack defines', () => { + expect(lint(inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: 'app_candidate' }]))).toEqual([]); + }); + }); + + describe(`${FLOW_INPUT_RECORD_READ_AS_LIST} — one declared id the flow reads as a list`, () => { + const declared = { name: 'candidateIds', type: 'text', isInput: true, objectName: 'app_candidate' }; + + it('warns at the $in read, naming the input, the node and both objects', () => { + const findings = lint(inputFlow([declared], [getRow({ id: { $in: cel('candidateIds') } })])); + expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([ + [ + FLOW_INPUT_RECORD_READ_AS_LIST, + 'warning', + 'flow "review_candidates" › input "candidateIds"', + 'flows[0].nodes[1].config.filter.id.$in', + ], + ]); + expect(findings[0].message).toBe( + "declares input 'candidateIds' one record id of 'app_candidate', and get_record \"Load candidates\" " + + "reads it as a list of 'app_candidate' ids (filter.id.$in), so no value is both and every start " + + 'that supplies the input is refused', + ); + expect(findings[0].hint).not.toContain('\n'); + }); + + it('reads a `vars.` hand-off and a node nested in a region, located at the nested node', () => { + const findings = lint(inputFlow([declared], [ + { + id: 'guard', + type: 'try_catch', + config: { + try: { nodes: [{ id: 'drop', type: 'delete_record', config: { objectName: 'app_candidate', filter: { id: { $in: cel('vars.candidateIds') } } } }] }, + catch: { nodes: [] }, + }, + }, + ])); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [FLOW_INPUT_RECORD_READ_AS_LIST, 'flows[0].nodes[1].config.try.nodes[0].config.filter.id.$in'], + ]); + expect(findings[0].message).toContain('delete_record "drop"'); + }); + + it('is silent where admission reads one id, or does not read the declared input at all', () => { + const silent: Array<[string, Record]> = [ + ['filter.id is the input', inputFlow([declared], [getRow({ id: cel('candidateIds') })])], + ['filter.id.$eq is the input', inputFlow([declared], [getRow({ id: { $eq: cel('candidateIds') } })])], + ['the $in reads an input with no declaration', inputFlow( + [{ name: 'candidateIds', type: 'text', isInput: true }], + [getRow({ id: { $in: cel('candidateIds') } })], + )], + ['the declared variable is not an input', inputFlow( + [{ ...declared, isInput: false }], + [getRow({ id: { $in: cel('candidateIds') } })], + )], + ['the $in source is computed from the input', inputFlow([declared], [getRow({ id: { $in: cel('[candidateIds]') } })])], + ['a loop rebinds the name before the read', inputFlow([declared], [ + { id: 'each', type: 'loop', config: { collection: cel('rows'), iteratorVariable: 'candidateIds', body: { nodes: [] } } }, + getRow({ id: { $in: cel('candidateIds') } }), + ])], + ['the node is not a row-addressing CRUD node', inputFlow([declared], [ + { id: 'mk', type: 'create_record', config: { objectName: 'app_candidate', filter: { id: { $in: cel('candidateIds') } } } }, + ])], + ]; + for (const [label, flow] of silent) { + expect(lint(flow).filter((f) => f.rule === FLOW_INPUT_RECORD_READ_AS_LIST), label).toEqual([]); + } + }); + }); +}); + describe('[#22161] one-line verdicts — the never-fire family', () => { it('every verdict the cases above fired is one line of at most 200 characters', () => { // The coverage control first: each of the five ids fired at least once, so diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 129bc13a1d6..187a375653a 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -174,6 +174,12 @@ // start input declared a record reference (`variables[].objectName`) names // an object this stack does not define. It is not a never-fire verdict — // a start that omits the input still runs — so it sits with the hedged id. +// - `warning` — `flow-input-object-less` and `flow-input-record-read-as-list` +// (1j), two declarations of a start input that can never work as written: +// one names the object-less placeholder and loads nothing, the other is +// one id the flow also reads as a list, so every start supplying it is +// refused. Neither is a never-fire verdict, and both keep the +// declaration's existing verdicts as they were. // - `warning` — `flow-draft-status-ambiguous`. Draft flows DO fire, so this is // an ambiguity of intent, not a dead flow. Gating it would refuse a stack // whose flows all work. @@ -207,6 +213,7 @@ import { apiTriggerVerifierOf, resolveFlowTriggerKind, } from '@objectstack/spec/automation'; +import { walkFlowNodes } from './flow-walk.js'; import { closureDependencyRemedy, recordsOf, suggestName } from './object-graph.js'; export type FlowTriggerReadinessSeverity = 'error' | 'warning'; @@ -234,6 +241,24 @@ export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object'; * package closure, an error under one. */ export const FLOW_INPUT_UNKNOWN_OBJECT = 'flow-input-unknown-object'; +/** + * A start input declared a record reference names the object-less + * placeholder (`objectName: 'global'`). The pattern accepts it, but the start + * doors' load treats an object-less key as nothing to load + * (`loadActionSubjectRecord`, `packages/runtime/src/action-execution.ts`, + * through `isObjectLessActionKey`), so the value reaches the run unjudged: a + * declaration that loads nothing. Advisory: it refuses no start. + */ +export const FLOW_INPUT_OBJECT_LESS = 'flow-input-object-less'; +/** + * A start input declared ONE record id (`variables[].objectName`) that a + * row-addressing node of the same flow reads as a LIST of ids + * (`filter.id.$in`). Start admission judges the input once per reading + * (`refuseUnreadableFlowStartRecords`): one id for the declaration, a list for + * the `$in` read, and no value is both, so every start that supplies the input + * is refused `400`. Advisory: the failure is loud, only late. + */ +export const FLOW_INPUT_RECORD_READ_AS_LIST = 'flow-input-record-read-as-list'; export const FLOW_DRAFT_STATUS_AMBIGUOUS = 'flow-draft-status-ambiguous'; export const FLOW_TRIGGER_UNKNOWN_EVENT = 'flow-trigger-unknown-event'; /** @@ -497,6 +522,42 @@ function descriptorRefusal(issues: ReadonlyArray<{ code: string; path: ReadonlyA } /** The start node of a flow definition, if any. */ +/** + * The object-less action keys, as `isObjectLessActionKey` + * (`packages/objectql/src/action-governance.ts`) answers them for a non-empty + * name: `'global'` (`GLOBAL_ACTION_OBJECT_KEY`) and `'*'`. Spelled here rather + * than imported because lint depends on `@objectstack/spec` and never on a + * runtime package; kept in lockstep with that predicate by hand, as + * `stack.zod.ts`'s `GLOBAL_ACTION_SCOPE` is. + */ +const OBJECT_LESS_KEYS: ReadonlySet = new Set(['global', '*']); + +/** The three CRUD node types start admission reads a row id from (`filter.id`). */ +const ROW_ADDRESSING_CRUD_NODE_TYPES: ReadonlySet = new Set(['get_record', 'update_record', 'delete_record']); + +/** + * The variable a value slot hands on unchanged, or `undefined` — start + * admission's own reading (`handedOnVariable`, + * `packages/runtime/src/flow-start-admission.ts`): a CEL envelope whose whole + * source is one variable name, bare or through `vars`. Anything computed is + * not a hand-off, and is not judged here either. + */ +function handedOnVariable(slot: unknown): string | undefined { + if (slot === null || typeof slot !== 'object' || Array.isArray(slot)) return undefined; + const { dialect, source } = slot as { dialect?: unknown; source?: unknown }; + if (dialect !== 'cel' || typeof source !== 'string') return undefined; + const match = /^(?:vars\.)?([A-Za-z_][A-Za-z0-9_]*)$/.exec(source.trim()); + return match ? match[1] : undefined; +} + +function plainRecord(value: unknown): AnyRec | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as AnyRec) : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; const index = nodes.findIndex((n) => n?.type === 'start'); @@ -1068,6 +1129,83 @@ export function validateFlowTriggerReadiness( ); }); + // 1j. Two declarations of a start input the schema accepts and the start + // doors can never honour as written. Both are additive to 1i: its + // verdict on the same name is left exactly as it was. + // + // - The object-less placeholder: the doors' load skips an object-less + // key, so the declared input is loaded from nothing and its value runs + // unjudged. + // - One id the flow also reads as a list: start admission judges the + // input once per reading — one id for the declaration, a list for a + // CRUD node's `filter.id.$in` — and no value is both, so every start + // supplying it is refused `400`. Read the way admission reads it + // (`flowDeclaredRecordReads`): only an `isInput` variable, only a + // CRUD node with a literal `objectName`, only a `$in` that hands the + // input on as it is, and not where a `loop` or `map` rebinds the name. + // A list read through a `subflow` or `map` hand-off is not judged here. + const declaredInputs = new Map(); + (Array.isArray(flow.variables) ? (flow.variables as unknown[]) : []).forEach((entry, variableIndex) => { + const variable = plainRecord(entry); + const objectName = nonEmptyString(variable?.objectName); + const variableName = nonEmptyString(variable?.name); + if (!variable || !objectName) return; + if (OBJECT_LESS_KEYS.has(objectName)) { + const shown = variableName ?? `#${variableIndex}`; + findings.push({ + severity: 'warning', + rule: FLOW_INPUT_OBJECT_LESS, + where: `flow "${flowName}" › input "${shown}"`, + path: `flows[${flowIndex}].variables[${variableIndex}].objectName`, + message: + `declares input '${shown}' a record of '${objectName}', the object-less placeholder, so no start ` + + `loads it from any object and its value reaches the run unjudged`, + hint: + `Set the input's objectName to the object whose record id the input carries, or remove ` + + `objectName if the input is not a record id`, + }); + } + if (variable.isInput === true && variableName && !declaredInputs.has(variableName)) { + declaredInputs.set(variableName, objectName); + } + }); + if (declaredInputs.size > 0) { + const walked = walkFlowNodes(flow, `flows[${flowIndex}]`); + // A `loop` or `map` binds its item (and index) variable over the run's + // own variables, so a read of that name is not a read of the start input. + for (const { node } of walked) { + const config = plainRecord(node.config); + if (!config || (node.type !== 'loop' && node.type !== 'map')) continue; + const iterator = nonEmptyString(config.iteratorVariable) ?? (node.type === 'map' ? 'item' : undefined); + if (iterator !== undefined) declaredInputs.delete(iterator); + const index = nonEmptyString(config.indexVariable); + if (index !== undefined) declaredInputs.delete(index); + } + for (const { node, path } of walked) { + if (typeof node.type !== 'string' || !ROW_ADDRESSING_CRUD_NODE_TYPES.has(node.type)) continue; + const config = plainRecord(node.config); + const readObject = nonEmptyString(config?.objectName); + const idOperators = plainRecord(plainRecord(config?.filter)?.id); + const listed = handedOnVariable(idOperators?.$in); + if (!readObject || listed === undefined || !declaredInputs.has(listed)) continue; + const declaredObject = declaredInputs.get(listed); + const nodeLabel = nonEmptyString(node.label) ?? nonEmptyString(node.id) ?? path; + findings.push({ + severity: 'warning', + rule: FLOW_INPUT_RECORD_READ_AS_LIST, + where: `flow "${flowName}" › input "${listed}"`, + path: `${path}.config.filter.id.$in`, + message: + `declares input '${listed}' one record id of '${declaredObject}', and ${node.type} "${nodeLabel}" ` + + `reads it as a list of '${readObject}' ids (filter.id.$in), so no value is both and every start ` + + `that supplies the input is refused`, + hint: + `Remove the input's objectName if it carries a list of ids, or read it as one id with filter.id ` + + `or filter.id.$eq`, + }); + } + } + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) { From 989f3456c7d97e9667f26e6f6f052e9cd8df6526 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:30:44 +0000 Subject: [PATCH 2/5] chore(changeset): lint patch for the two flow start-input declaration warnings Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW --- .changeset/30633-flow-input-declaration-lint.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/30633-flow-input-declaration-lint.md diff --git a/.changeset/30633-flow-input-declaration-lint.md b/.changeset/30633-flow-input-declaration-lint.md new file mode 100644 index 00000000000..0a972437ae9 --- /dev/null +++ b/.changeset/30633-flow-input-declaration-lint.md @@ -0,0 +1,15 @@ +--- +'@objectstack/lint': patch +--- + +fix(lint): `os validate` warns on a flow start input whose record declaration (`objectName`) can never work as written + +Clause-②: no +Metadata-protocol: none + +Two new warnings from the flow trigger-readiness rule, beside `flow-input-unknown-object`. Both are advisory: no build or publish that passed before is refused. + +- **`flow-input-object-less`** — a start input declares `objectName: 'global'`, the object-less placeholder. The pattern accepts it, but the start doors load nothing for an object-less key, so the value reaches the run unjudged. Located at `flows[N].variables[M].objectName`. Fix: name the object whose record id the input carries, or remove `objectName`. +- **`flow-input-record-read-as-list`** — a start input declares one record id, and a `get_record`, `update_record` or `delete_record` node in the same flow reads it as a list (`filter: { id: { $in: } }`). Start admission judges the input both ways and no value is both, so every start that supplies it is refused `400`. Located at the node's `config.filter.id.$in`. Fix: remove the input's `objectName` if it carries a list of ids, or read it as one id with `filter.id` or `filter.id.$eq`. + +`flow-input-unknown-object` answers exactly as before, including for `'global'` when the stack defines no object of that name. From 25f1613e5bb2bc69cabb20a706390b624359c231 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:08:06 +0000 Subject: [PATCH 3/5] fix(lint): export the two new flow start-input rule ids, and say the start doors enforce the declaration Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW --- packages/lint/src/index.ts | 2 ++ .../src/validate-flow-trigger-readiness.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 60ff3d3a56b..cf24059319f 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -139,6 +139,8 @@ export { validateFlowApiTriggerSecret, FLOW_TRIGGER_UNKNOWN_OBJECT, FLOW_INPUT_UNKNOWN_OBJECT, + FLOW_INPUT_OBJECT_LESS, + FLOW_INPUT_RECORD_READ_AS_LIST, FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 187a375653a..3389c6f897e 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -233,10 +233,10 @@ export interface FlowTriggerReadinessFinding { export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object'; /** * #29442 (#29425 ruling A) — a start input declared a record reference - * (`variables[].objectName`) names an object this stack does not define. Once - * the start doors enforce the declaration (planned), every door that starts the - * flow by name will load such an input from that object, so a wrong name will - * refuse every start that supplies the input. The same hedge and + * (`variables[].objectName`) names an object this stack does not define. The + * start doors enforce the declaration (PR #30628): every door that starts the + * flow by name loads such an input from that object, so a wrong name + * refuses every start that supplies the input. The same hedge and * the same closure verdict as `flow-trigger-unknown-object`: advisory with no * package closure, an error under one. */ @@ -1092,10 +1092,10 @@ export function validateFlowTriggerReadiness( // family has a use for. // 1i. #29442 (#29425 ruling A) — a start input declared a record reference - // names an object this stack does not define. Once the start doors - // enforce the declaration (planned), every door that starts the flow - // by name will load the input from exactly that name in the caller's - // scope, so a wrong name will refuse every start that supplies the + // names an object this stack does not define. The start doors + // enforce the declaration (PR #30628): every door that starts the flow + // by name loads the input from exactly that name in the caller's + // scope, so a wrong name refuses every start that supplies the // input. Judged like the trigger's object (1 and 1b-i): a `sys_` // name is a platform object and is not judged, and the verdict is the // closure's when one is stated. The schema owns the declaration's @@ -1117,8 +1117,8 @@ export function validateFlowTriggerReadiness( { message: `declares input '${variableName}' the id of a record of object '${objectName}', which this ` + - `stack does not define — if the name is wrong, every start that supplies the input will be ` + - `refused once the start doors enforce the declaration.` + + `stack does not define — if the name is wrong, every start that supplies the input is ` + + `refused.` + suggestName(objectName, objectNames), hint: `Set the input's objectName to the object's exact registered name, or ignore this if another ` + From d3774afe31bd57373ac9a99fafcf1df3a2105f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:44:03 +0000 Subject: [PATCH 4/5] fix(lint): the flow-input-unknown-object explanation says the start doors enforce the declaration Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW --- packages/lint/src/rule-explanations.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 3596b0a140f..61092c4a1b9 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2170,10 +2170,10 @@ const FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = { covers: 'why an unknown start-input object warns, and when it refuses', paragraphs: [ 'A flow input may declare that its value is the id of a record of a named object, with `objectName` on ' + - 'the variable. The start doors are to load such an input from exactly that name in the caller\'s own ' + - 'scope before the run exists, so a name no object answers to would refuse every start that supplies ' + - 'the input. Names match exactly — `app_candidate`, not `candidate`. The declaration is planned: no ' + - 'door reads it yet, and the property lint says so separately.', + 'the variable. The start doors load such an input from exactly that name in the caller\'s own ' + + 'scope before the run exists, so a name no object answers to refuses every start that supplies ' + + 'the input. Names match exactly — `app_candidate`, not `candidate`. Every door that starts the flow ' + + 'by name enforces the declaration.', 'The rule asks whether this stack defines the object. One it does not define may be declared by ' + 'another installed package, which a build of this stack cannot see, so the finding is a warning and ' + 'can be ignored when such a package declares the object; a `sys_` name is a platform object and is ' + From 922bc3dd66cce58dc28d81dd512c4f651f6ff610 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:18:12 +0000 Subject: [PATCH 5/5] fix(lint): declare the barrel widening in the changeset, and put startNodeOf's docblock back above it Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW --- .changeset/30633-flow-input-declaration-lint.md | 2 +- packages/lint/src/validate-flow-trigger-readiness.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/30633-flow-input-declaration-lint.md b/.changeset/30633-flow-input-declaration-lint.md index 0a972437ae9..565470f208f 100644 --- a/.changeset/30633-flow-input-declaration-lint.md +++ b/.changeset/30633-flow-input-declaration-lint.md @@ -4,7 +4,7 @@ fix(lint): `os validate` warns on a flow start input whose record declaration (`objectName`) can never work as written -Clause-②: no +Clause-②: yes (widening) — `@objectstack/lint` exports two new rule-id constants, `FLOW_INPUT_OBJECT_LESS` and `FLOW_INPUT_RECORD_READ_AS_LIST`; nothing that passed before is refused. Metadata-protocol: none Two new warnings from the flow trigger-readiness rule, beside `flow-input-unknown-object`. Both are advisory: no build or publish that passed before is refused. diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 3389c6f897e..08c1cd7dfc5 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -521,7 +521,6 @@ function descriptorRefusal(issues: ReadonlyArray<{ code: string; path: ReadonlyA return `${at}${schemaRefusalHead(first.message)}${moreIssues(issues.length)}`; } -/** The start node of a flow definition, if any. */ /** * The object-less action keys, as `isObjectLessActionKey` * (`packages/objectql/src/action-governance.ts`) answers them for a non-empty @@ -558,6 +557,7 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value !== '' ? value : undefined; } +/** The start node of a flow definition, if any. */ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; const index = nodes.findIndex((n) => n?.type === 'start');