Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30633-flow-input-declaration-lint.md
Original file line number Diff line number Diff line change
@@ -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-②: 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.

- **`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: <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`.

`flow-input-unknown-object` answers exactly as before, including for `'global'` when the stack defines no object of that name.
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' +
Expand Down
167 changes: 167 additions & 0 deletions packages/lint/src/validate-flow-trigger-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>]> = [
['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
Expand Down
Loading
Loading