Found while implementing #5693. Filed unassigned, observation-class (finding, no pm:queue): the first half is measured, the second half is a consequence I did not verify — see "What still needs measuring". Nothing is known to be broken for a user today.
The tier's contract
packages/lint/src/authoring-rules.ts declares two input tiers, and says why the pre-parse one exists:
/**
* - `normalized` — the `normalizeStackInput` output, BEFORE the Zod parse. The
* rules that need it check keys the parse strips (a flat list view in
* `views: []`, `userFilters` on an object list view, a `visibleOn` alias): by
* the time `result.data` exists the evidence is gone.
*/
export type AuthoringRuleInputTier = 'normalized' | 'parsed';
Six rules are wired input: 'normalized' today, and their own headers repeat it — e.g. validate-list-view-mode.ts:15 "Runs PRE-parse (on the normalizeStackInput output, before the …)", same in validate-view-containers.ts:14.
What is measured
defineStack — the documented and universal way a TS config declares a stack — parses, strictly by default (packages/spec/src/stack.zod.ts:1245):
const result = ObjectStackDefinitionSchema.safeParse(normalized, { error: objectStackErrorMap });
...
const data = result.data; // returned to the caller
So export default defineStack({...}) evaluates to result.data — defaults filled, unknown keys stripped — before the CLI ever loads the module. Whatever the commands then do with that value, the parse has already happened.
Directly observed on examples/app-todo (a defineStack config), during #5693:
os lint calls runAuthoringRules('lint', { normalized: config, … }) with no parsed key (packages/cli/src/commands/lint.ts:428), and the runner falls back — run.parsed ?? run.normalized (authoring-rules.ts:1035) — precisely so a parsed-tier rule still gets something. The comment there is explicit that os lint "does not Zod-parse".
- Yet with the flow's
runAs line deleted, os lint printed the flow-runas-unscoped message arm that is only reachable when flow.runAs is a string. It could only be a string because FlowSchema's .default('user') had already been applied — i.e. the stack os lint had in hand was post-parse.
That is the measurement: for a defineStack config, os lint's "normalized" input is a parsed stack.
Why it may matter beyond #5693
For flow-runas-unscoped this was harmless-and-then-fixed (#5693 removed the branch that could see the difference). But the tier exists for rules whose evidence is destroyed by the parse. If the value handed to normalizeStackInput in lint.ts / validate.ts / compile.ts is already result.data, then normalizing it again cannot resurrect a key the parse stripped — the alias, the flat list view, the userFilters are gone before any command runs, and the normalized-tier rules would be structurally silent on exactly the configs that make up the repo's own examples.
If that holds, the tier is only genuinely pre-parse for stacks that never went through strict defineStack: raw JSON/YAML, defineStack(x, { strict: false }), and direct API callers. Same family as #4984 / #5693 — a check whose input shape in production differs from the input shape its tests feed it.
What still needs measuring (do this before treating it as a defect)
I did not verify the consequence, only the mechanism. Concretely:
- Take one
normalized-tier rule with a real stripped-key fixture — validate-list-view-mode (flat list view in views: []) or validate-view-containers — and author that shape in a defineStack TS config in an example app.
- Run
os lint and os validate on it. Does the rule report? If it reports, the parse is not stripping what we assume (ObjectStackDefinitionSchema may be permissive at that site) and there is nothing here.
- If it does not report, compare against the same stack fed as raw JSON — that difference is the defect, and its size is "how many of the six
normalized rules are affected".
Worth noting one non-obvious possibility in the other direction: defineStack also runs warnUnknownAuthoringKeys(normalized) on the pre-parse value and warns there, so some of this ground may already be covered at definition time by a different channel — which would change the remedy (delete the tier's claim, or route those rules through the same seam) rather than the finding.
Related
Generated by Claude Code
Found while implementing #5693. Filed unassigned, observation-class (
finding, nopm:queue): the first half is measured, the second half is a consequence I did not verify — see "What still needs measuring". Nothing is known to be broken for a user today.The tier's contract
packages/lint/src/authoring-rules.tsdeclares two input tiers, and says why the pre-parse one exists:Six rules are wired
input: 'normalized'today, and their own headers repeat it — e.g.validate-list-view-mode.ts:15"Runs PRE-parse (on the normalizeStackInput output, before the …)", same invalidate-view-containers.ts:14.What is measured
defineStack— the documented and universal way a TS config declares a stack — parses, strictly by default (packages/spec/src/stack.zod.ts:1245):So
export default defineStack({...})evaluates toresult.data— defaults filled, unknown keys stripped — before the CLI ever loads the module. Whatever the commands then do with that value, the parse has already happened.Directly observed on
examples/app-todo(adefineStackconfig), during #5693:os lintcallsrunAuthoringRules('lint', { normalized: config, … })with noparsedkey (packages/cli/src/commands/lint.ts:428), and the runner falls back —run.parsed ?? run.normalized(authoring-rules.ts:1035) — precisely so aparsed-tier rule still gets something. The comment there is explicit thatos lint"does not Zod-parse".runAsline deleted,os lintprinted theflow-runas-unscopedmessage arm that is only reachable whenflow.runAsis a string. It could only be a string becauseFlowSchema's.default('user')had already been applied — i.e. the stackos linthad in hand was post-parse.That is the measurement: for a
defineStackconfig,os lint's "normalized" input is a parsed stack.Why it may matter beyond #5693
For
flow-runas-unscopedthis was harmless-and-then-fixed (#5693 removed the branch that could see the difference). But the tier exists for rules whose evidence is destroyed by the parse. If the value handed tonormalizeStackInputinlint.ts/validate.ts/compile.tsis alreadyresult.data, then normalizing it again cannot resurrect a key the parse stripped — the alias, the flat list view, theuserFiltersare gone before any command runs, and thenormalized-tier rules would be structurally silent on exactly the configs that make up the repo's own examples.If that holds, the tier is only genuinely pre-parse for stacks that never went through strict
defineStack: raw JSON/YAML,defineStack(x, { strict: false }), and direct API callers. Same family as #4984 / #5693 — a check whose input shape in production differs from the input shape its tests feed it.What still needs measuring (do this before treating it as a defect)
I did not verify the consequence, only the mechanism. Concretely:
normalized-tier rule with a real stripped-key fixture —validate-list-view-mode(flat list view inviews: []) orvalidate-view-containers— and author that shape in adefineStackTS config in an example app.os lintandos validateon it. Does the rule report? If it reports, the parse is not stripping what we assume (ObjectStackDefinitionSchemamay be permissive at that site) and there is nothing here.normalizedrules are affected".Worth noting one non-obvious possibility in the other direction:
defineStackalso runswarnUnknownAuthoringKeys(normalized)on the pre-parse value and warns there, so some of this ground may already be covered at definition time by a different channel — which would change the remedy (delete the tier's claim, or route those rules through the same seam) rather than the finding.Related
flow-runas-unscoped's "the defaultrunAs:'user'" wording is unreachable on the CLI surface — the parse materializes the default, so an author who declared nothing is told they declared it #5693 — where this surfaced (os lintprinting a parsed-tier-only message arm).Generated by Claude Code