Skip to content

Commit 55cd927

Browse files
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 (#30753)
Fixes #30633 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 ## 维护者速读 - 改了什么:`os validate` 的流程触发就绪检查新增两条警告——开始输入把 `objectName` 写成无对象占位符 `'global'`;或声明为单条记录 id、却被同一流程的节点按列表(`filter.id.$in`)读取。 - 为什么改:这两种写法 schema 都接受,但运行时要么根本不加载、值未经校验就进入运行,要么每次启动都被 400 拒绝,作者只有运行时才发现。 - 风险与回滚:只新增 warning 和两个公开导出的规则 id 常量(公开面扩大,Clause-② widening),不拒绝任何构建或发布;既有 `flow-input-unknown-object` 判定不变。回滚即还原本 PR。 - 先看:`packages/lint/src/validate-flow-trigger-readiness.ts` 的 1j 段(第二条警告的读取范围是否与运行时准入一致),其次同名测试文件新增的 describe;`index.ts` 只加两个导出名。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-flow-trigger-readiness.test.ts`,预期 141 个测试全部通过(整包 8183 个)。 - 需要你做的:无,席位落地。 ## What changes Two new advisory ids from `validateFlowTriggerReadiness`, beside `flow-input-unknown-object`: - **`flow-input-object-less`**: a start input declares `objectName: 'global'` (or `'*'`, which only a pre-parse stack can carry). The start doors' load skips an object-less key: `loadActionSubjectRecord` in `packages/runtime/src/action-execution.ts` loads only when `!isObjectLessActionKey(objectName)`, and the declared read that PR #30628 added (`flowDeclaredRecordReads` → `refuseUnreadableFlowStartRecords`) goes through that load. So the value reaches the run unjudged. Located at `flows[N].variables[M].objectName`. - **`flow-input-record-read-as-list`**: a start input declares one record id, and a `get_record` / `update_record` / `delete_record` node in the same flow reads it as a list (`filter.id.$in` handing the input on as it is). Admission judges the input once per reading: `oneRecordIdOrRefuse` for the declaration and `recordIdListOrRefuse` for the `$in` read. No value passes both, so every start that supplies the input is refused `400`. Located at the node's `config.filter.id.$in`, nested regions included (`walkFlowNodes`). The second rule reads the flow the way `flowDeclaredRecordReads` does: only `isInput: true` variables, only a CRUD node with a literal `objectName`, only a CEL envelope whose whole source is the input (bare or `vars.`), and not where a `loop` or `map` rebinds the name. Both ids are `warning` in every context, a stated package closure included. Also in this PR (claim amended to admit them): - `packages/lint/src/index.ts` re-exports the two new constants, because `rule-id-barrel-exports.test.ts` requires every rule id to be reachable from a published barrel. - Wording only: the `flow-input-unknown-object` message, the `FLOW_INPUT_UNKNOWN_OBJECT` docblock and the 1i comment now say the start doors enforce the declaration (PR #30628), not that they will. The message changes from "…every start that supplies the input will be refused once the start doors enforce the declaration." to "…every start that supplies the input is refused." No test pinned the old sentence. The id, severity, path, hint and closure verdict are unchanged. - Wording only, in `packages/lint/src/rule-explanations.ts` (the `flow-input-unknown-object` paragraph, admitted by a second claim amendment): "The declaration is planned: no door reads it yet" now reads "Every door that starts the flow by name enforces the declaration", and the paragraph's "are to load" / "would refuse" are now present tense. ## Tests New top-level `describe` in `validate-flow-trigger-readiness.test.ts`, plus one row per id in the severity table: - object-less: id, severity, `where` and path on `'global'`, with the existing `flow-input-unknown-object` finding still beside it; `'global'` defined as a stack object still warns; `'*'`; a silent control on a defined object. - read-as-list: the exact finding at `nodes[1].config.filter.id.$in`; a `vars.` hand-off inside a `try_catch` region, located at the nested node; seven silent controls (`filter.id`, `$eq`, undeclared input, non-input, computed source, loop rebinding, `create_record`). The existing `flow-input-unknown-object` cases are unchanged. ## Acceptance notes - Additive by design: for `'global'` on a stack that defines no object of that name, `flow-input-unknown-object` still fires beside the new id. The card asks that the existing cases stay as they are, and under a stated closure that id is a publish refusal, so replacing it would widen the gate. - Not covered: a list read reached through a `subflow` or `map` hand-off into a callee flow (admission follows those up to 8 levels deep; this rule judges the same flow only). A `map` over the input whose callee reads each item as one id also reads the input as a list, and is not judged here either. - The new ids have no `os explain` entry. Each finding carries its full verdict and fix. - The object-less key set is spelled locally, because lint depends on `@objectstack/spec` only. It has to be kept in step with `isObjectLessActionKey` by hand, as `stack.zod.ts`'s `GLOBAL_ACTION_SCOPE` already is. ## Verification All readings are at head `922bc3dd6`. <details><summary>Lint package: tests, typecheck, eslint</summary> - `pnpm --filter @objectstack/lint test`: exit 0. Test Files 195 passed (195), Tests 8183 passed (8183). - `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit && pnpm check:test-typecheck`): exit 0. - `pnpm lint` (eslint, repo-wide): exit 0. </details> <details><summary>Derived gate families: 57 commands</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 57 commands from 5 paths, merge base 06cccd3. - All 57 exited 0, `check:type-check-debt`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` included. - `--ran` reconciliation: "57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. </details> <details><summary>Ablations (scripts/ablation-replace.mjs, each restored to the HEAD blob)</summary> Taken at 989f345. The guarded lines are byte-identical at 922bc3d: the later commits touched only the barrel, wording, the changeset and the position of one existing docblock. 1. Object-less guard set to `false &&`: 4 failed, 137 passed (the severity row and the three object-less cases). 2. Read-as-list guard replaced by `continue`: 3 failed, 138 passed (the severity row, the exact-finding case, the nested-region case). The first attempt used the replacement text `continue;`, which already occurs 4 times in the file, so the tool could not prove the mutation landed and refused it; nothing ran. It was re-run with a unique marker. 3. Loop-rebinding exclusion deleted: 1 failed, 140 passed (the silent-controls case). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW)_ <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea757ff commit 55cd927

5 files changed

Lines changed: 336 additions & 14 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): `os validate` warns on a flow start input whose record declaration (`objectName`) can never work as written
6+
7+
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.
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
- **`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`.
13+
- **`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: <the input> } }`). 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`.
14+
15+
`flow-input-unknown-object` answers exactly as before, including for `'global'` when the stack defines no object of that name.

‎packages/lint/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export {
139139
validateFlowApiTriggerSecret,
140140
FLOW_TRIGGER_UNKNOWN_OBJECT,
141141
FLOW_INPUT_UNKNOWN_OBJECT,
142+
FLOW_INPUT_OBJECT_LESS,
143+
FLOW_INPUT_RECORD_READ_AS_LIST,
142144
FLOW_DRAFT_STATUS_AMBIGUOUS,
143145
FLOW_TRIGGER_UNKNOWN_EVENT,
144146
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,10 +2170,10 @@ const FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = {
21702170
covers: 'why an unknown start-input object warns, and when it refuses',
21712171
paragraphs: [
21722172
'A flow input may declare that its value is the id of a record of a named object, with `objectName` on ' +
2173-
'the variable. The start doors are to load such an input from exactly that name in the caller\'s own ' +
2174-
'scope before the run exists, so a name no object answers to would refuse every start that supplies ' +
2175-
'the input. Names match exactly — `app_candidate`, not `candidate`. The declaration is planned: no ' +
2176-
'door reads it yet, and the property lint says so separately.',
2173+
'the variable. The start doors load such an input from exactly that name in the caller\'s own ' +
2174+
'scope before the run exists, so a name no object answers to refuses every start that supplies ' +
2175+
'the input. Names match exactly — `app_candidate`, not `candidate`. Every door that starts the flow ' +
2176+
'by name enforces the declaration.',
21772177
'The rule asks whether this stack defines the object. One it does not define may be declared by ' +
21782178
'another installed package, which a build of this stack cannot see, so the finding is a warning and ' +
21792179
'can be ignored when such a package declares the object; a `sys_` name is a platform object and is ' +

‎packages/lint/src/validate-flow-trigger-readiness.test.ts‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
validateFlowTriggerReadiness as validateFlowTriggerReadinessUnrecorded,
77
FLOW_TRIGGER_UNKNOWN_OBJECT,
88
FLOW_INPUT_UNKNOWN_OBJECT,
9+
FLOW_INPUT_OBJECT_LESS,
10+
FLOW_INPUT_RECORD_READ_AS_LIST,
911
FLOW_DRAFT_STATUS_AMBIGUOUS,
1012
FLOW_TRIGGER_UNKNOWN_EVENT,
1113
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
@@ -1618,6 +1620,49 @@ describe('validateFlowTriggerReadiness', () => {
16181620
flows: [recordFlow()],
16191621
},
16201622
],
1623+
// A declared start input that can never work as written: advisory.
1624+
[
1625+
FLOW_INPUT_OBJECT_LESS,
1626+
'warning',
1627+
{
1628+
objects: [candidateObject],
1629+
flows: [
1630+
{
1631+
name: 'object_less_input',
1632+
type: 'screen',
1633+
status: 'active',
1634+
variables: [{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' }],
1635+
nodes: [{ id: 'start', type: 'start' }],
1636+
},
1637+
],
1638+
},
1639+
],
1640+
[
1641+
FLOW_INPUT_RECORD_READ_AS_LIST,
1642+
'warning',
1643+
{
1644+
objects: [candidateObject],
1645+
flows: [
1646+
{
1647+
name: 'one_id_read_as_list',
1648+
type: 'screen',
1649+
status: 'active',
1650+
variables: [{ name: 'candidateIds', type: 'text', isInput: true, objectName: 'app_candidate' }],
1651+
nodes: [
1652+
{ id: 'start', type: 'start' },
1653+
{
1654+
id: 'load',
1655+
type: 'get_record',
1656+
config: {
1657+
objectName: 'app_candidate',
1658+
filter: { id: { $in: { dialect: 'cel', source: 'candidateIds' } } },
1659+
},
1660+
},
1661+
],
1662+
},
1663+
],
1664+
},
1665+
],
16211666
];
16221667

16231668
for (const [rule, severity, stack] of provoke) {
@@ -1995,6 +2040,128 @@ describe('[#30009] api trigger readiness reads the hook\'s one verifier (config.
19952040
});
19962041
});
19972042

2043+
describe('a start input whose record declaration can never work as written', () => {
2044+
const cel = (source: string) => ({ dialect: 'cel', source });
2045+
const getRow = (filter: Record<string, unknown>, label = 'Load candidates') => ({
2046+
id: 'load',
2047+
type: 'get_record',
2048+
label,
2049+
config: { objectName: 'app_candidate', filter },
2050+
});
2051+
const inputFlow = (variables: unknown[], nodes: unknown[] = []) => ({
2052+
name: 'review_candidates',
2053+
type: 'screen',
2054+
status: 'active',
2055+
variables,
2056+
nodes: [{ id: 'start', type: 'start' }, ...nodes],
2057+
});
2058+
const lint = (flow: Record<string, unknown>, objects: unknown[] = [candidateObject]) =>
2059+
validateFlowTriggerReadiness({ objects, flows: [flow] });
2060+
2061+
describe(`${FLOW_INPUT_OBJECT_LESS} — objectName names the object-less placeholder`, () => {
2062+
it("warns on 'global', at the entry the author wrote, beside the unknown-object verdict it already had", () => {
2063+
const findings = lint(inputFlow([
2064+
{ name: 'note', type: 'text', isInput: true },
2065+
{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' },
2066+
]));
2067+
expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([
2068+
[FLOW_INPUT_UNKNOWN_OBJECT, 'warning', 'flow "review_candidates" › input "recordId"', 'flows[0].variables[1].objectName'],
2069+
[FLOW_INPUT_OBJECT_LESS, 'warning', 'flow "review_candidates" › input "recordId"', 'flows[0].variables[1].objectName'],
2070+
]);
2071+
const objectLess = findings[1];
2072+
expect(objectLess.message).toBe(
2073+
"declares input 'recordId' a record of 'global', the object-less placeholder, so no start loads it " +
2074+
'from any object and its value reaches the run unjudged',
2075+
);
2076+
expect(objectLess.hint).not.toContain('\n');
2077+
});
2078+
2079+
it("warns even where the stack defines an object named 'global': the load skips the key, not the object", () => {
2080+
const findings = lint(
2081+
inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' }]),
2082+
[candidateObject, { name: 'global', label: 'Global', fields: {} }],
2083+
);
2084+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[FLOW_INPUT_OBJECT_LESS, 'flows[0].variables[0].objectName']]);
2085+
});
2086+
2087+
it("warns on the other object-less key, '*', which only a pre-parse stack can carry", () => {
2088+
const findings = lint(inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: '*' }]));
2089+
expect(findings.filter((f) => f.rule === FLOW_INPUT_OBJECT_LESS).map((f) => f.path)).toEqual([
2090+
'flows[0].variables[0].objectName',
2091+
]);
2092+
});
2093+
2094+
it('is silent on a declared object the stack defines', () => {
2095+
expect(lint(inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: 'app_candidate' }]))).toEqual([]);
2096+
});
2097+
});
2098+
2099+
describe(`${FLOW_INPUT_RECORD_READ_AS_LIST} — one declared id the flow reads as a list`, () => {
2100+
const declared = { name: 'candidateIds', type: 'text', isInput: true, objectName: 'app_candidate' };
2101+
2102+
it('warns at the $in read, naming the input, the node and both objects', () => {
2103+
const findings = lint(inputFlow([declared], [getRow({ id: { $in: cel('candidateIds') } })]));
2104+
expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([
2105+
[
2106+
FLOW_INPUT_RECORD_READ_AS_LIST,
2107+
'warning',
2108+
'flow "review_candidates" › input "candidateIds"',
2109+
'flows[0].nodes[1].config.filter.id.$in',
2110+
],
2111+
]);
2112+
expect(findings[0].message).toBe(
2113+
"declares input 'candidateIds' one record id of 'app_candidate', and get_record \"Load candidates\" " +
2114+
"reads it as a list of 'app_candidate' ids (filter.id.$in), so no value is both and every start " +
2115+
'that supplies the input is refused',
2116+
);
2117+
expect(findings[0].hint).not.toContain('\n');
2118+
});
2119+
2120+
it('reads a `vars.` hand-off and a node nested in a region, located at the nested node', () => {
2121+
const findings = lint(inputFlow([declared], [
2122+
{
2123+
id: 'guard',
2124+
type: 'try_catch',
2125+
config: {
2126+
try: { nodes: [{ id: 'drop', type: 'delete_record', config: { objectName: 'app_candidate', filter: { id: { $in: cel('vars.candidateIds') } } } }] },
2127+
catch: { nodes: [] },
2128+
},
2129+
},
2130+
]));
2131+
expect(findings.map((f) => [f.rule, f.path])).toEqual([
2132+
[FLOW_INPUT_RECORD_READ_AS_LIST, 'flows[0].nodes[1].config.try.nodes[0].config.filter.id.$in'],
2133+
]);
2134+
expect(findings[0].message).toContain('delete_record "drop"');
2135+
});
2136+
2137+
it('is silent where admission reads one id, or does not read the declared input at all', () => {
2138+
const silent: Array<[string, Record<string, unknown>]> = [
2139+
['filter.id is the input', inputFlow([declared], [getRow({ id: cel('candidateIds') })])],
2140+
['filter.id.$eq is the input', inputFlow([declared], [getRow({ id: { $eq: cel('candidateIds') } })])],
2141+
['the $in reads an input with no declaration', inputFlow(
2142+
[{ name: 'candidateIds', type: 'text', isInput: true }],
2143+
[getRow({ id: { $in: cel('candidateIds') } })],
2144+
)],
2145+
['the declared variable is not an input', inputFlow(
2146+
[{ ...declared, isInput: false }],
2147+
[getRow({ id: { $in: cel('candidateIds') } })],
2148+
)],
2149+
['the $in source is computed from the input', inputFlow([declared], [getRow({ id: { $in: cel('[candidateIds]') } })])],
2150+
['a loop rebinds the name before the read', inputFlow([declared], [
2151+
{ id: 'each', type: 'loop', config: { collection: cel('rows'), iteratorVariable: 'candidateIds', body: { nodes: [] } } },
2152+
getRow({ id: { $in: cel('candidateIds') } }),
2153+
])],
2154+
['the node is not a row-addressing CRUD node', inputFlow([declared], [
2155+
{ id: 'mk', type: 'create_record', config: { objectName: 'app_candidate', filter: { id: { $in: cel('candidateIds') } } } },
2156+
])],
2157+
];
2158+
for (const [label, flow] of silent) {
2159+
expect(lint(flow).filter((f) => f.rule === FLOW_INPUT_RECORD_READ_AS_LIST), label).toEqual([]);
2160+
}
2161+
});
2162+
});
2163+
});
2164+
19982165
describe('[#22161] one-line verdicts — the never-fire family', () => {
19992166
it('every verdict the cases above fired is one line of at most 200 characters', () => {
20002167
// The coverage control first: each of the five ids fired at least once, so

0 commit comments

Comments
 (0)