Skip to content

Commit 8305ad6

Browse files
claude[bot]claude
andauthored
fix(cli): os lint's hand-written checks and scoreMetadata judge the stack an option-B project declares, instead of passing a stack they never opened (#17528) (#17822)
Fixes #17528 Clause-②: no Re-derived from the delivered diff rather than carried over from the claim: no schema key, no closed-set member, no published export and no registry entry moves. `lintConfig` and `scoreMetadata` keep their signatures and their return types, `os build`'s accept set is untouched, and no authorable key changes. What changes is which stack the existing checks are handed. ## The defect `lintConfig` (`packages/cli/src/commands/lint.ts`) runs two families: the shared author-time rule registry, and `os lint`'s **own** hand-written rubric — naming, labels, empty field maps, the intra-package duplicate advisory, hook-body lowering, and the data-model conventions. #17069 folded the **registry call inside that function** and scoped itself the way `compile.ts` scopes its own, writing down exactly what it left behind: > **Scoped to this call, as it is in `compile.ts`: the hand-written checks above and `scoreMetadata` (which reaches `lintConfig` through `lint/score.js`) keep reading the caller's own stack**, so nothing about what this function returns for a top-level stack moves. This is the other half of that staged change, not a reversal of it. Under ADR-0130 D4 / option B every definition lives in `packages[]` and the top level carries none, so the family that was left behind read an **empty stack**. ## Acceptance 1 — the card's repro, both shapes, through the real binary `node packages/cli/bin/run.js lint --score`, built from this branch's tree in both directions. One object, authored two ways; the metadata is byte-identical and only its location differs. **BEFORE** (`origin/main` `c9eb773ba`): ``` packages[] exit 0 ✓ All checks passed Metadata quality: 100/100 (A) 0 schema · 0 error(s) · 0 warning(s) · 0 suggestion(s) top level exit 0 ⚠ Label "order" should start with an uppercase letter convention/label-case at objects[0].label ℹ Object "ob_order" has no nameField and no name-like field … object/missing-name-field at objects[0].fields Metadata quality: 96/100 (A) ``` **AFTER**, same two projects: ``` packages[] exit 0 ⚠ convention/label-case at objects[0].label ℹ object/missing-name-field at objects[0].fields Metadata quality: 96/100 (A) top level exit 0 — unchanged (see acceptance 3) ``` Diffed rather than eyeballed: with the config path and the elapsed-time token normalised, the two faces are **identical**. ## Acceptance 2 — the parity is pinned, and here is what it covers `test/lint-handwritten-checks-package-fold.test.ts`. One set of declarations is rendered into **three** shapes — top-level-only, `packages[]`-only, and today's additive artifact that carries both — and the assertion is that the findings are equal, field for field, in order. A test that only asserted "a finding appears" would stay green against a fold that reached one check and missed four, so the file also names the checks and the count each owes (`NAMED_HAND_WRITTEN_COVERAGE`): | hand-written check | findings owed | read site it proves | |:--|--:|:--| | `convention/label-case` | 4 | `stack.objects` (object label + field label), `stack.views`, `stack.apps` | | `required/label` | 1 | `stack.objects` | | `structure/empty-fields` | 1 | `stack.objects[i].fields` | | `naming/namespace-prefix` | 2 | the `PREFIXED_TYPES` loop's `stack[key]`, on **two** different keys (`apps`, `flows`) | | `hook-body/not-lowerable` | 1 | `checkHookBodyLowering(stack)` reading `stack.hooks` | | `object/missing-name-field` | 1 | `lintDataModel(objects)`, fed from the same `stack.objects` local | **Two checks are deliberately NOT covered, and the file says so.** `authoringRuleUnionStack` resolves package order through `resolveArtifactPackageOrder`, whose ADR-0112 refusals are not swallowed, so a `packages[]` stack whose body does not parse is refused before any rule runs — it already was, since `lintConfig` has driven that seam for the registry tier since #17069. Every carrier of `naming/snake-case` (a non-snake_case machine name) and of `structure/no-fields` (an object with no `fields` at all) is schema-invalid, so no option-B stack can reach them. They read the same `stack.KEY` locals every covered rule reads. `protocol/missing-engines-range` reads `manifest`, an artifact-envelope key present in both shapes by construction, so it is not a fold site at all. The third shape is the one that can regress silently: a fold that **unioned** instead of resolving would double every finding on today's additive artifact while both other shapes stayed correct. `authoringRuleUnionStack` is present-wins, so it does not — and that is now pinned. ## Acceptance 3 — identity control A top-level-only project's `os lint` output, before and after, driven through the real binary: ``` 15c15 < 1 warning(s), 1 suggestion(s) (134ms) --- > 1 warning(s), 1 suggestion(s) (62ms) ``` That is the whole diff, on both faces (`os lint` and `os lint --score`): with the elapsed-time token normalised the two outputs are **byte-identical**. It holds by construction — the seam returns a stack that carries its own collections by identity — and it is measured anyway, because that is the claim. The same control on a multi-package **additive** project (flattened top level plus `packages[]`) is likewise byte-identical before and after. ## Acceptance 4 — `scoreMetadata`, and why the fork does not fire **The number moves, on the option-B repro only: `100/100 (A)` becomes `96/100 (A)`, with counts `0 schema · 0 error · 0 warning · 0 suggestion` becoming `0 · 0 · 1 · 1`.** That is the right number because it is the number the same metadata already scored when spelled at the top level. `100/100 (A)` with every count at zero is byte-for-byte the verdict a genuinely clean project gets, published by a rubric that had judged nothing. **The card's fork — whether the score should be per-project or per-package on a multi-package artifact — is not forced by this change, and that is measured, not assumed.** Two readings: 1. **The scorer already scores the whole project, and always has.** Its schema half parses the artifact whole: on a `packages[]`-only stack carrying one schema-invalid object, `scoreMetadata` reports `packages.0.manifest.objects.0: Unrecognized key(s) on this object …` **today, with no fold anywhere**. Only its lint half was blind. 2. **Per-project is what its lint half already computes for every multi-package artifact that exists.** A two-package project, one object each, driven through the real binary: | shape | before | after | |:--|:--|:--| | additive (what the platform emits today) | `89/100 (B)` · 3 warnings, 2 suggestions | **unchanged**, byte-identical | | `packages[]`-only (option B) | `97/100 (A)` · 1 warning | `89/100 (B)` · 3 warnings, 2 suggestions | The additive column is the union across both packages — a per-project score, computed by the code on `main`. The fold makes the option-B spelling agree with it. So there is no decision in it: the scorer computes the same thing it computed before, over a stack that is no longer empty. Clause-② stays `no`. ## Acceptance 5 — the acceptance pin owes new rows, and they are paid `test/option-b-reader-acceptance.pin.test.ts` held `OPTION_B_LOSSES` empty and stayed green through this defect because its probe carried **no row** for either reader. Two rows added to `test/fixtures/option-b-reader-probe.ts`, both counted over the reader's **return value** per that file's own rule, never over `project.KEY`: - `B2/B3 · cli lint hand-written rubric (lintConfig — naming, labels, structure) · objects` - `B2/B3 · cli metadata-quality rubric (scoreMetadata — os lint --score, metadata eval) · objects` Two rows and not one, because they are two doors: the command calls `lintConfig` directly, while `os lint --score` and the metadata eval reach it through `lint/score.js` after `normalizeStackInput`. **The zoo gained one deliberately lint-dirty label**, for the same reason #15229 gave it a federated object — watching the reader required carrying something it can report. `lintConfig` says nothing at all about a clean stack, so on the zoo as it stood both rows returned the same four registry findings in both shapes: a row that could never go red. Exactly one label is lower-cased, on the **module** package's object so the row is a cross-package resolution (`PROBE_DIRTY_LABEL` / `PROBE_DIRTY_LABEL_RULE`, both commented at the site). **The floor is raised 36 to 38, and measured at the boundary** the way this file requires — written `toBeGreaterThanOrEqual(39)`, the run reports `expected 38 to be greater than or equal to 39`, so the floor is the exact count with no slack. A **named-row** test is added beside #15006's and #17527's, because a count cannot tell "this row left" from "this row left and another arrived". ⛔ No floor lowered, no ledger line added. ## The guard this fix had to teach, rather than weaken `test/validate-build-gate-parity.test.ts` carries a **source-level** rule — deliberately so, per its own docblock — that each of the three authoring commands hands the rule table a folded stack. It matched one literal spelling: ``` \b(normalized|parsed)\s*:\s*authoringRuleUnionStack\s*\( ``` Hoisting the fold to `lintConfig`'s entry hands the tiers `normalized: stack` and `parsed: lowered` instead. The value is still folded — more of it than before, since the hand-written checks now see it too — but the spelling was gone, and **both** tiers went red in CI (`Test Core (4/6)`, 1 failed / 1519 passed, on `d52f22dfc`). ⛔ **Nothing here weakens the guard.** Relaxing the regex to any identifier, deleting the assertion or dropping `lint.ts` from `AUTHORING_COMMANDS` were all available and all refused: broadened to `\w+` it would pass on `normalized: config`, which is the defect the guard exists for. Instead the identifier is **resolved**: follow `const` bindings in the same source, at most four hops, and answer true only when some hop is a literal `authoringRuleUnionStack(` call. That turns a **spelling** match into a **value-provenance** one. ⭐ The old spelling match had a hole the red never showed, and it is the reason this is the right direction rather than an accommodation: **a pure spelling match would be satisfied BY a cosmetic re-fold whatever the surrounding code did.** A door could have written `normalized: authoringRuleUnionStack(x)` for any `x` — or kept the wrapper while every other read in the function drifted onto the caller's own stack, which is exactly the state `lint.ts` was in and this guard reported green on for as long as it existed. **Negative controls on the same pass**, against fabricated sources, because a guard that cannot fail is not a guard: | fabricated tier expression | verdict | |:--|:--| | `normalized: config` (the pre-#17069 defect itself) | rejected, both tiers | | `normalized: normalized` where `const normalized = normalizeStackInput(config)` | rejected | | the fold IS called in the file, on an unused binding, tier handed something else | **rejected** — the line between "this file mentions the fold" and "this value came from the fold" | | a six-hop chain against a four-hop budget | rejected (fail-closed) | | `parsed: authoringRuleUnionStack(lowered)` (compile.ts / validate.ts) | accepted | | hoisted `normalized: stack`, and `parsed: lowered` one hop further | accepted | `tierExpression` reads each tier's value with bracket-depth tracking rather than to the next comma, which also answers the slice-truncation hazard: measured, `ruleTableCallIn`'s cut still bounds the whole object literal in all three commands. **The claim the resolver now trusts is pinned behaviourally, not left in a comment.** `parsed: lowered` reaches the fold through `lowerCallables`, on the argument that it shallow-clones the top level it is handed. `THE CHAIN` in `test/lint-handwritten-checks-package-fold.test.ts` asserts that directly: every collection the fold filled survives `lowerCallables` at the same length, and `authoringRuleUnionStack(lowered)` returns it **by identity** — which is why the second call was dropped rather than kept as insurance. **Two bounds are stated in the resolver's docblock** rather than implied: it follows every identifier in an expression, so it is source-level reachability and not dataflow; and `constBindingOf` takes the first `const NAME =` in the file, so a shadowed binding would resolve to the wrong one. Neither is a hole today; both are named so a future shape outgrows this resolver visibly instead of defeating it quietly. ## Reverse verification The fix commit reverted to its `origin/main` base (`c9eb773ba`), proved on disk before anything was read: the fold line went `1 to 0` occurrences, the pre-fix top-level read went `0 to 1`, and the blob moved `cc731022 to b62eb82`. Restored from `HEAD` afterwards and re-verified byte-identical (`cc731022`), `git diff HEAD` empty. The readers are reached from **source** by relative path, so no build leg applies. **`test/lint-handwritten-checks-package-fold.test.ts` — 4 of 7 red:** ``` AssertionError: The two shapes declare byte-identical metadata and must be judged identically. top-level reported 12 finding(s), packages[] reported 2 AssertionError: The hand-written check `convention/label-case` reported 0 finding(s) on the packages[]-only shape, not the 4 it owes AssertionError: expected 94 to be 56 (the scorer, packages[] vs top level) AssertionError: expected [] to deeply equal [ 2 findings ] (the card's own repro) ``` **`test/option-b-reader-acceptance.pin.test.ts` — red, naming both new rows:** ``` A subsystem lost a collection that the ledger does not carry … B2/B3 · cli lint hand-written rubric (lintConfig — naming, labels, structure) · objects B2/B3 · cli metadata-quality rubric (scoreMetadata — os lint --score, metadata eval) · objects LOST … lintConfig … = 0 LOST … scoreMetadata … = 0 hand-written finding(s) · score 90/A ``` In the same run the additive BASELINE and both anti-vacuity controls stayed green, which is what makes the red a discrimination rather than a broken fixture. ## The fix The fold moves to `lintConfig`'s entry and every check below it reads the resolved stack — the shape #17775 landed for `collectMetadataStats` in this same package: ```ts const stack: any = authoringRuleUnionStack(config); ``` ⛔ **One fold, one helper, no second implementation.** `authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's single resolution rule for a package-owned collection: present-wins, identity return, key set derived from the two schemas rather than transcribed. The registry call below now takes `normalized: stack` and `parsed: lowered` directly — `lowerCallables` shallow-clones the top level it is handed, so `lowered` already carries the folded collections and re-folding it could only ever return by identity. ## Tests Run on `2c5d49463`, the final commit of this branch. | command | result | |:--|:--| | `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit --shard=1..3/3` | exit **0** × 3 — **199 files, 2838 tests passed** (the whole unit tier, sharded only to stay inside this session's foreground budget) | | `… --project integration test/union-fold-command-parity.test.ts test/authoring-rule-command-parity.test.ts` | exit **0** — 2 files, 17 tests | | `pnpm --filter @objectstack/lint exec vitest run src/authoring-rule-wiring.test.ts` | exit **0** — 26 tests (it reads `packages/cli/src/commands/*.ts`, so this diff moves its inputs) | | `pnpm --filter @objectstack/cli typecheck` | exit **0** — `tsc --noEmit` + `check:test-typecheck`; the debt ledger is unchanged at 3 files / 28 errors / 6 pinned signatures | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit **0** — the whole population, not a narrowing | | 19 `check:*` families (`nul-bytes`, `cross-package-test-inputs`, `test-source-alias`, `tier-file-adoption`, `type-check-coverage`, `type-check-debt`, `stack-collection-maps`, `comment-mask-adoption`, `comment-mask-corpus`, `keyed-text-bounds`, `closing-keyword-parity`, `doc-authoring`, `cli-test-child-env`, `published-files`, `objectui-changeset`, `changeset-gate-self-tests`, `empty-changeset`, `changeset-no-major`, `adr-0087-registration`) | exit **0** each; the ratchet families were re-run on the final head | The `packages/cli` `integration` tier beyond the two files above is declared to CI: this diff touches no integration-tier file, no spawn entry and no driver or kernel boot path. Those two are run locally anyway because they are the closest neighbours of this seam. `dispatch-gates --ran` reconciles 19 of 62 derived families run locally; the remaining 43 are repo-wide or artifact-roster families no path of this diff narrows, and they are CI's. ## Acceptance notes - **Filed as #17821** — `os lint`'s `naming/namespace-prefix` reports a legitimate cross-package name reuse as an intra-package duplicate on a composed multi-package stack, contradicting the ADR-0048 §3.4 sentence in its own message. Measured both ways: **1 false positive on the additive shape before and after this change**, 0 before and 1 after on the option-B shape — so it is pre-existing on the shape the platform emits today, and this change only brings the option-B spelling into parity with it, which is its acceptance. The fix needs per-item package attribution, a design question rather than a wiring detail, so it is filed rather than folded in. - **Noted, not filed:** on today's additive artifact a definition that is carried twice (flattened AND inside `packages[]`) is reported twice by `scoreMetadata`'s SCHEMA half — measured, `schemaErrors: 2` for one bad object, at `objects.0` and at `packages.0.manifest.objects.0`. Both copies genuinely exist in that artifact, so the parse is accurate; option B's emitter half (#14512) removes the duplication. The lint half never double-counts, because the fold is present-wins — pinned by the third shape in the parity test above. - **Noted, not filed:** the comment on `LintConfigOptions.sduiManifest` ("`scoreMetadata` deliberately does not — the scorer is a pure function of a stack and must not read the filesystem") reads, out of context, like a decision that the scorer must see an unfolded stack. Its subject is the SDUI manifest and the filesystem; a clarifying clause is added in this diff so the next reader does not have to open the file to settle it. - **Noted, not filed:** my own first verification pass selected the lint/score/option-B test files by hand and missed `test/validate-build-gate-parity.test.ts`, which is in the same `unit` tier and reads `lint.ts` as source. The whole tier is run above; the targeted selection is what cost a CI cycle. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51b024a commit 8305ad6

7 files changed

Lines changed: 731 additions & 24 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os lint`'s own rubric and `os lint --score` judge the stack an ADR-0130 D4 / option-B project actually declares, instead of reporting `✓ All checks passed` on a stack they never opened
6+
7+
`lintConfig` runs two families: the shared author-time rule registry and
8+
`os lint`'s **own** hand-written checks — naming, labels, empty field maps, the
9+
intra-package duplicate advisory, hook-body lowering and the data-model
10+
conventions. The registry learned to resolve `packages[]` earlier; the
11+
hand-written family and `scoreMetadata`, which reaches the same function, still
12+
read the **top level only**. On an option-B project (every definition inside
13+
`packages[]`, none flattened up) they were handed an empty stack.
14+
15+
Measured through the real binary, on one object authored two ways — the same
16+
metadata, differing only in where it is declared:
17+
18+
```
19+
packages[] os lint exit 0 ✓ All checks passed
20+
Metadata quality: 100/100 (A)
21+
22+
top level os lint exit 0 ⚠ Label "order" should start with an uppercase letter
23+
convention/label-case at objects[0].label
24+
ℹ Object "ob_order" has no nameField and no name-like field …
25+
object/missing-name-field at objects[0].fields
26+
Metadata quality: 96/100 (A)
27+
```
28+
29+
and after, on the same two projects:
30+
31+
```
32+
packages[] os lint exit 0 ⚠ convention/label-case at objects[0].label
33+
ℹ object/missing-name-field at objects[0].fields
34+
Metadata quality: 96/100 (A)
35+
36+
top level os lint exit 0 — byte-identical to before
37+
```
38+
39+
The score is the sharper half. `100/100 (A)` with every count at zero is
40+
byte-for-byte the verdict a genuinely clean project gets, on a rubric that had
41+
judged nothing — the same indistinguishability a swallowed linter crash used to
42+
produce, arriving through the input instead.
43+
44+
**The fix folds once, at `lintConfig`'s entry, with the existing helper.**
45+
`authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one
46+
resolution rule for a package-owned collection and it is present-wins: a key the
47+
top level already carries wins, because in today's additive shape that array
48+
already *is* the union. So a multi-package artifact is judged once, never twice,
49+
and a stack whose top level carries its collections — every stack the platform
50+
emits today — is returned by identity and lints byte-identically to before.
51+
52+
**This does not change what `scoreMetadata` scores.** It already scored the whole
53+
project: its schema half reports `packages.0.manifest.objects.0: …` on an
54+
option-B stack with no fold anywhere, and on today's additive multi-package shape
55+
its lint half already read the flattened union across every package. The fold
56+
makes the option-B shape agree with the additive one.
57+
58+
⛔ No authorable key, spec schema, published export or accept set moves.
59+
`os build` rejects and accepts exactly what it did; `os lint`'s own `error`
60+
severity remains a lint verdict, not a publish gate.

packages/cli/src/commands/lint.ts

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,75 @@ export interface LintConfigOptions {
174174
* JSX gate does full component/prop validation; absent, it stays parse-level.
175175
* The `os lint` command resolves it; `scoreMetadata` deliberately does not —
176176
* the scorer is a pure function of a stack and must not read the filesystem.
177+
*
178+
* ⚠️ That sentence is about the FILESYSTEM and about nothing else. It is not
179+
* a decision about WHICH stack the scorer judges: the stack it is handed is
180+
* still resolved to the collections the author declared, in either ADR-0130
181+
* D4 shape, by the fold at `lintConfig`'s entry below. ⛔ Do not read it as a
182+
* warrant for the scorer seeing an unfolded stack.
177183
*/
178184
sduiManifest?: unknown;
179185
}
180186

187+
/**
188+
* `os lint`'s whole rubric: the hand-written checks below plus the shared
189+
* author-time rule registry (#4409), over the stack the author DECLARED.
190+
*
191+
* ## Why the fold is here, at the entry, and not at the registry call alone
192+
*
193+
* #17069 taught the registry call inside this function to resolve `packages[]`
194+
* and scoped itself the way `compile.ts` scopes it, writing down exactly what
195+
* it left behind: "the hand-written checks above and `scoreMetadata` (which
196+
* reaches `lintConfig` through `lint/score.js`) keep reading the caller's own
197+
* stack". This is the other half of that staged change, not a reversal of it.
198+
*
199+
* The half it left was the sharper one. Under ADR-0130 D4 / option B every
200+
* definition lives in `packages[]` and the top level carries none, so the
201+
* hand-written family read an EMPTY stack: a project with a lower-case object
202+
* label and no name field got `✓ All checks passed`, while the byte-identical
203+
* top-level spelling of the same metadata got `convention/label-case` and
204+
* `object/missing-name-field`. `scoreMetadata` reaches this same function, so
205+
* an option-B project's metadata-quality rubric was computed over nothing and
206+
* published `100/100 (A)` — the #15658 shape ("the linter found nothing" and
207+
* "the linter never ran" collapsed into the better-looking one) arriving
208+
* through the INPUT this time rather than through a swallowed crash.
209+
*
210+
* ## Why {@link authoringRuleUnionStack} and not a second fold
211+
*
212+
* `stack-collections.ts` is the ONE place this package resolves a
213+
* package-owned collection, and its rule is present-wins: a key the top level
214+
* already carries wins, because in today's additive shape that array already
215+
* IS the union. A stack that carries its collections comes back BY IDENTITY,
216+
* so every single-package project lints byte-identically to before. A second
217+
* fold written here could not have that property without re-deriving it, and
218+
* two folds that disagree is a worse defect than the one being removed.
219+
*
220+
* ## What this does NOT decide
221+
*
222+
* It does not choose between a per-project and a per-package metadata score.
223+
* `scoreMetadata` already scores the whole project and always has: its schema
224+
* half parses the artifact whole and reports `packages.0.manifest.objects.0:
225+
* …` on an option-B stack today, with no fold anywhere, and on today's
226+
* additive multi-package shape its lint half already reads the flattened union
227+
* across every package. This fold makes the option-B shape agree with the
228+
* additive one — the same thing computed, over a stack that is no longer
229+
* empty — and raises no new scoping question.
230+
*/
181231
export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue[] {
232+
// The stack as DECLARED, whichever ADR-0130 D4 shape it arrived in. ⛔ Never
233+
// read `config.KEY` below this line — a check that skipped the fold is
234+
// exactly the defect #17528 removed, and it would be invisible in every
235+
// other check, because the run still exits 0 and still prints a score.
236+
const stack: any = authoringRuleUnionStack(config as Record<string, unknown>);
237+
182238
const issues: LintIssue[] = [];
183239

184240
const push = (issue: LintIssue | null) => {
185241
if (issue) issues.push(issue);
186242
};
187243

188244
// ── Objects ──
189-
const objects: any[] = Array.isArray(config.objects) ? config.objects : [];
245+
const objects: any[] = Array.isArray(stack.objects) ? stack.objects : [];
190246

191247
for (let i = 0; i < objects.length; i++) {
192248
const obj = objects[i];
@@ -244,7 +300,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
244300
}
245301

246302
// ── Views ──
247-
const views: any[] = Array.isArray(config.views) ? config.views : [];
303+
const views: any[] = Array.isArray(stack.views) ? stack.views : [];
248304
for (let i = 0; i < views.length; i++) {
249305
const view = views[i];
250306
const viewPath = `views[${i}]`;
@@ -259,7 +315,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
259315
}
260316

261317
// ── Apps ──
262-
const apps: any[] = Array.isArray(config.apps) ? config.apps : [];
318+
const apps: any[] = Array.isArray(stack.apps) ? stack.apps : [];
263319
for (let i = 0; i < apps.length; i++) {
264320
const app = apps[i];
265321
const appPath = `apps[${i}]`;
@@ -273,7 +329,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
273329
}
274330

275331
// ── Flows ──
276-
const flows: any[] = Array.isArray(config.flows) ? config.flows : [];
332+
const flows: any[] = Array.isArray(stack.flows) ? stack.flows : [];
277333
for (let i = 0; i < flows.length; i++) {
278334
const flow = flows[i];
279335
const flowPath = `flows[${i}]`;
@@ -283,7 +339,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
283339
}
284340

285341
// ── Agents ──
286-
const agents: any[] = Array.isArray(config.agents) ? config.agents : [];
342+
const agents: any[] = Array.isArray(stack.agents) ? stack.agents : [];
287343
for (let i = 0; i < agents.length; i++) {
288344
const agent = agents[i];
289345
const agentPath = `agents[${i}]`;
@@ -309,7 +365,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
309365
//
310366
// Objects are already prefix-*enforced* (error) in defineStack; views are
311367
// object-derived; `doc` has its own build lint — so they are excluded here.
312-
const ns: string | undefined = config.manifest?.namespace;
368+
const ns: string | undefined = stack.manifest?.namespace;
313369

314370
// Bare-named UI/automation types that share the generic registry namespace.
315371
// Data-driven so a new bare-named type is one line.
@@ -369,7 +425,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
369425
];
370426

371427
for (const { key, label, registryKey } of PREFIXED_TYPES) {
372-
const items: any[] = Array.isArray(config[key]) ? config[key] : [];
428+
const items: any[] = Array.isArray(stack[key]) ? stack[key] : [];
373429
// First occurrence of each registry key → its index, so a later duplicate
374430
// can point back at the original declaration.
375431
const firstSeen = new Map<string, number>();
@@ -427,7 +483,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
427483
// Scoped to configs that declare a manifest — a bare metadata fragment (no
428484
// package identity) has nowhere to hang an engines range.
429485
{
430-
const manifest = config.manifest as Record<string, any> | undefined;
486+
const manifest = stack.manifest as Record<string, any> | undefined;
431487
const hasRange =
432488
typeof manifest?.engines?.protocol === 'string' ||
433489
typeof manifest?.engines?.platform === 'string' ||
@@ -456,7 +512,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
456512
//
457513
// Reads FUNCTION values, so it must run on the normalized input before any
458514
// Zod parse — which is where `lintConfig` already sits.
459-
issues.push(...checkHookBodyLowering(config as Record<string, unknown>));
515+
issues.push(...checkHookBodyLowering(stack as Record<string, unknown>));
460516

461517
// ── Data-model best practices (relationships / master-detail / roll-ups) ──
462518
// Cross-object rules that encode the conventions in ADR-0035 and the
@@ -523,14 +579,19 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
523579
// a stack that still carries them comes back BY IDENTITY and every
524580
// single-package project lints exactly as before.
525581
//
526-
// Scoped to this call, as it is in `compile.ts`: the hand-written checks
527-
// above and `scoreMetadata` (which reaches `lintConfig` through
528-
// `lint/score.js`) keep reading the caller's own stack, so nothing about
529-
// what this function returns for a top-level stack moves.
530-
const { lowered, loweredHookRefs } = lowerCallables(config as Record<string, unknown>);
582+
// ⭐ #17069 scoped its fold to THIS CALL, as `compile.ts` scopes its own, and
583+
// named the two things it deliberately left reading the caller's own stack:
584+
// "the hand-written checks above and `scoreMetadata`". #17528 is the other
585+
// half of that staged change — the fold now happens ONCE, at this function's
586+
// entry, so every check in it and every caller of it (the `os lint` command
587+
// and `scoreMetadata`) judge one stack. ⛔ There is still exactly one fold
588+
// and one helper: `lowerCallables` shallow-clones the top level it is handed,
589+
// so `lowered` already carries the folded collections and re-folding it here
590+
// would be a second call that could only ever return by identity.
591+
const { lowered, loweredHookRefs } = lowerCallables(stack as Record<string, unknown>);
531592
for (const f of runAuthoringRules('lint', {
532-
normalized: authoringRuleUnionStack(config as Record<string, unknown>),
533-
parsed: authoringRuleUnionStack(lowered),
593+
normalized: stack,
594+
parsed: lowered,
534595
sduiManifest: opts.sduiManifest,
535596
// [#16546] Same ref set `os build` computes from the same normalized
536597
// input — what lets `validateReadonlyHookWrites` / `validateHookBodyWrites`

packages/cli/test/fixtures/option-b-collection-zoo.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,38 @@ export const PROBE_HOOK_OBJECT = 'probe_order';
115115
export const PROBE_SEED_DATASET = 'probe_account';
116116
export const PROBE_LOCALE = 'en';
117117

118+
/**
119+
* [#17528] The one deliberately LINT-DIRTY value in this fixture: a label that
120+
* `os lint`'s HAND-WRITTEN family has something to say about.
121+
*
122+
* It is here for the same reason `PROBE_FEDERATED_DATASOURCE` is — "watching
123+
* the map required carrying one". `lintConfig`'s own rubric (naming, labels,
124+
* structure, data-model conventions) reports nothing at all about a CLEAN
125+
* stack, so on this zoo as it stood the reader returned the identical four
126+
* findings in BOTH shapes while reading an empty one under option B: every
127+
* finding came from the shared rule registry, which #17069 had already taught
128+
* to fold. A probe row counted over that return value would have been green
129+
* before the fix and green after it — a row that cannot go red.
130+
*
131+
* So exactly one label is lower-cased, on the MODULE package's federated
132+
* object, which makes the row a CROSS-PACKAGE resolution rather than a
133+
* within-body read. Labels are display strings: nothing in this fixture, the
134+
* probe or the pin keys on one, and `ObjectSchema` does not constrain their
135+
* case — the whole effect is one `convention/label-case` warning.
136+
*
137+
* ⛔ Do not "fix" this label. Restoring its case deletes the only signal two
138+
* probe rows have.
139+
*/
140+
export const PROBE_DIRTY_LABEL = 'probe federated order';
141+
/**
142+
* [#17528] The hand-written `os lint` rule {@link PROBE_DIRTY_LABEL} provokes.
143+
*
144+
* Named here, beside the value that provokes it, so the provocation and the
145+
* rule that observes it cannot drift apart. It is one of the two rules the
146+
* card's own repro measured going missing on a `packages[]`-only project.
147+
*/
148+
export const PROBE_DIRTY_LABEL_RULE = 'convention/label-case';
149+
118150
// ─── Package 1: the App ─────────────────────────────────────────────────────
119151

120152
const coreStack = (): ObjectStackDefinition =>
@@ -241,7 +273,8 @@ const ordersStack = (): ObjectStackDefinition =>
241273
// makes the two collections separately observable through one function.
242274
{
243275
name: PROBE_FEDERATED_OBJECT,
244-
label: 'Probe Federated Order',
276+
// [#17528] Deliberately lower-case — see `PROBE_DIRTY_LABEL`.
277+
label: PROBE_DIRTY_LABEL,
245278
pluralLabel: 'Probe Federated Orders',
246279
sharingModel: 'private',
247280
datasource: PROBE_FEDERATED_DATASOURCE,

packages/cli/test/fixtures/option-b-reader-probe.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ import { lowerCallables } from '../../src/utils/lower-callables.js';
8181
// `lowerCallables` is: it ships inside `@objectstack/cli`, so the row below
8282
// measures the reader in this checkout rather than a built artifact.
8383
import { collectMetadataStats, type MetadataStats } from '../../src/utils/format.js';
84+
// [#17528] `os lint`'s OWN rubric, and the metadata-quality scorer that reaches
85+
// it through `lint/score.js`. Reached as SOURCE, by relative path, for the same
86+
// reason the two imports above are: both ship inside `@objectstack/cli`, so the
87+
// rows below measure the readers in this checkout, not a built artifact.
88+
import { lintConfig } from '../../src/commands/lint.js';
89+
import { scoreMetadata } from '../../src/lint/score.js';
8490
// [#15006] The CLI's OWN reads of a package-owned collection. Until that card
8591
// these were inline expressions inside oclif command bodies — no exported
8692
// reader, nothing a probe could call — which is why the four rows they carry
@@ -98,6 +104,7 @@ import {
98104
import {
99105
PACKAGE_OWNED_COLLECTION_KEYS,
100106
PROBE_DEFAULT_PERMISSION_SET,
107+
PROBE_DIRTY_LABEL_RULE,
101108
PROBE_FEDERATED_OBJECT,
102109
PROBE_FUNCTION,
103110
PROBE_FUNCTION_EFFECT,
@@ -513,6 +520,57 @@ export async function measureShape(project: unknown, projectRoot: string): Promi
513520
summaryItems,
514521
));
515522

523+
// [#17528] `os lint`'s OWN rubric — the hand-written checks at the top of
524+
// `lintConfig` (naming, labels, structure, name-field suggestions). #17069
525+
// folded the RULE REGISTRY inside that same function and scoped itself
526+
// explicitly to that call, naming the two things it left reading the caller's
527+
// own stack: this family, and `scoreMetadata`. Under option B both read an
528+
// EMPTY stack, so a project with a lint-dirty label got `✓ All checks passed`
529+
// while the byte-identical top-level spelling of the same metadata reported
530+
// it.
531+
//
532+
// Counted over the reader's RETURN VALUE, per this file's rule — never over
533+
// `project.<collection>`, which would be a second copy of the read the row
534+
// watches.
535+
//
536+
// ⚠️ Narrowed to the ONE rule the zoo provokes on purpose, and the narrowing
537+
// is what makes the row a measurement at all: every other finding `lintConfig`
538+
// returns on this fixture comes from the rule registry, which already folds,
539+
// so an unfiltered count is identical in both shapes before AND after the fix
540+
// — a row that can never go red. `PROBE_DIRTY_LABEL` is the label that
541+
// provokes it, carried by the MODULE package so this is a cross-package
542+
// resolution. ⛔ This row is enumeration, not coverage of the family: the
543+
// byte-parity of the WHOLE family across both shapes is pinned separately, in
544+
// `test/lint-handwritten-checks-package-fold.test.ts`.
545+
const handWritten = lintConfig(project as Record<string, unknown>)
546+
.filter((issue) => issue.rule === PROBE_DIRTY_LABEL_RULE);
547+
rows.push(countRow(
548+
'B2/B3 · cli lint hand-written rubric (lintConfig — naming, labels, structure) · objects',
549+
handWritten.length,
550+
));
551+
552+
// [#17528] The metadata-quality SCORER, the second call site into the reader
553+
// above (`lint/score.js`, reached by `os lint --score` and the metadata eval).
554+
// It is the sharper half: the linter RAN and read an empty stack, so an
555+
// option-B project's rubric was computed over nothing and published
556+
// `100/100 (A)` — the #15658 shape, "the linter found nothing" and "the linter
557+
// never ran" collapsed into the better-looking one, arriving through the INPUT
558+
// rather than through a swallowed crash.
559+
//
560+
// A separate row from the one above because it is a separate DOOR: the scorer
561+
// normalizes its input first (`normalizeStackInput`) and answers a score
562+
// rather than a finding list, so a fold that reached `lintConfig` but not this
563+
// path would still publish a clean number. Counted over `MetadataScore.issues`
564+
// — the reader's return value — with the score rendered beside it.
565+
const score = scoreMetadata(project);
566+
const scoredHandWritten = score.issues
567+
.filter((issue) => issue.rule === PROBE_DIRTY_LABEL_RULE);
568+
rows.push(row(
569+
'B2/B3 · cli metadata-quality rubric (scoreMetadata — os lint --score, metadata eval) · objects',
570+
`${scoredHandWritten.length} hand-written finding(s) · score ${score.score}/${score.grade}`,
571+
scoredHandWritten.length === 0,
572+
));
573+
516574
// ── B2 · `os verify`'s readers (#15229) ──────────────────────────────────
517575
//
518576
// `os verify` has exactly ONE door — `loadConfig` (`verify.ts:92`) — and it

0 commit comments

Comments
 (0)