fix(cli): refuse a present non-array packages in the stack-collection and docs readers - #20231
Conversation
…on and docs readers
`packageBodies`, `docsPackageRefs`, `bodyDocsOf` and `attachPackageDocs`
answered "no packages" for `packages: {}` / `0` / `'x'`. They now judge the
value through one helper, `declaredPackageEntries`, which hands a present
non-array to `resolveArtifactPackageOrder` so the refusal is core's own
`INVALID_ARTIFACT_PACKAGES` (ADR-0112, 422). Absent (`undefined` / `null`)
still reads as no packages, and an array is walked exactly as before.
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
Clause-② no (narrowing): `os info` and `os lint` exited 0 for a hand-written
stack carrying `packages: {}` / `0` / `'x'`, and now refuse it with
`INVALID_ARTIFACT_PACKAGES`.
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
…i-non-array-packages-refusal
…s: {}`
That row held the silent "no packages" answer the reader no longer gives. It
now asserts the `INVALID_ARTIFACT_PACKAGES` envelope, and keeps the absent
control.
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 467f7faa3904e91f8abd8fc9095bc49b54e8e01e && git checkout 467f7faa3904e91f8abd8fc9095bc49b54e8e01e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3cb84d084efa5095089de71899fe7b304f8f8993 ae8e3e83f734dc33a4937af5221c1a41b7dce6f2 && git checkout -B drift-repro 3cb84d084efa5095089de71899fe7b304f8f8993 && git merge --no-ff ae8e3e83f734dc33a4937af5221c1a41b7dce6f2
node scripts/docs-audit/affected-docs.mjs --json 3cb84d084efa5095089de71899fe7b304f8f8993 |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
|
…i-non-array-packages-refusal
…ng it as absent `declaredPackageEntries` answered `null` through a private copy of the resolver's absent branch, so the resolver's `null` refusal (ruling 5805260775 on #19926) could never reach the CLI readers. Only `undefined` (key absent) is answered locally now. Every other non-array, `null` included, goes to `resolveArtifactPackageOrder`, and its absent answer is read by identity, so today's `null` answer is unchanged. Pins: `null-packages-follows-resolver.test.ts` holds each reader to the real resolver's verdict on `null`, and to a resolver double that refuses `null`. The changeset and the pin-file header drop the "separate decision" wording and cite the ruling. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…i-non-array-packages-refusal
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver levelUnchanged by the delta: ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS |
…s group, refusing the rest (objectstack-ai#20497) (objectstack-ai#20517) Fixes objectstack-ai#20497 Clause-②: no ## What changes `parseNumberCell` in `packages/rest/src/import-coerce.ts` removed every comma before parsing, as if every comma grouped thousands. So `POST /api/v1/data/:object/import` stored a decimal-comma cell as a different number and reported success. A comma is now accepted only in a well-formed thousands group: 1 to 3 leading digits, then groups of exactly three, and only before any `.` (`1,000`, `12,345.67`). One anchored pattern, `THOUSANDS_GROUPED_INTEGER`, is tested before the strip, and the strip now runs only for that form. Every other comma makes the cell unparseable, so the row gets the importer's existing `invalid_number` error. That is the same code the plain write doors already answer for these cells. No locale is guessed, and there is no new error code and no decimal-separator option, as triage directed (`5877481993`). The rule is stated in the `parseNumberCell` docblock, where the reader's tolerances are listed. Measured through the real route (JSON rows, `writeMode: 'insert'`) on `InMemoryDriver` and on `SqlDriver` (better-sqlite3). Both drivers answered the same on every cell, at base `9449512a31` and at head `c76a3c95f2`: | cell | base: stored · import answer | head | plain `POST` (engine insert), both trees | |:--|:--|:--|:--| | `'3,14'` | `314` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | `VALIDATION_FAILED` / `invalid_number` | | `'1,5'` | `15` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | same | | `'1.000,5'` | `1.0005` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | same | | `'1,2,3'` | `123` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored | same | | `'1,000'` | `1000` | `1000` (unchanged) | refused (the import-only tolerance stays) | | `'12,345.67'` | `12345.67` | `12345.67` (unchanged) | refused | | `'(1,234)'` | `-1234` | `-1234` (unchanged) | refused | ## PM hypotheses, measured - **H0 holds.** On current `main` (`9449512a31`), the four cells imported as `314`, `15`, `1.0005` and `123` with `ok 1, errors 0`, on `InMemoryDriver` and on SQLite. The table above has the readings. - **H1 holds.** The one line `s.replace(/,/g, '')` is the whole cause. Before/after census through `coerceRow`: before is rest's built `dist` at the base, after is the head's `src`. It covers 79 rows: the spec grammar's 41 `NUMERIC_STRING_GRAMMAR_CASES` rows, 18 documented or control forms, and 20 comma probes. - **Grammar rows.** 1 of 41 changed: `'1.000,5'` went from `1.0005` to refused. The other 7 grammar-refused rows the reader admits keep their reading: `' 12 '`, `'12\n'`, `'\t-3'`, `'1,000'`, `'+5'`, `'.5'` and `'007'`. - **Documented and control forms.** 0 of 18 changed: `1,234`, `$1,000`, `¥2,500.75`, `€1,000`, `£1,000`, `¥1,000`, `25%`, `(1,234)`, `(100)`, `1,234.5`, `12,345.67`, `1,000`, `-1,234`, `+1,234`, `1,234,567.89`, `$ 1,000`, `1,234%` and `1,000e3`. - **Comma probes.** 18 of 20 changed, each from a stripped number to a refusal: `3,14`, `1,5`, `1.000,5`, `1,2,3`, `0,5`, `1,23`, `1234,567`, `1,0000`, `,123`, `-,123`, `.5,000`, `1,000,`, `12,345.6,7`, `12,34,567`, `1,00,000`, `(3,14)`, `$1,5` and `1,5%`. The other 2 (`1,000.` and `1 ,000`) were already refused. - **Overall.** 19 rows changed, every one from a stored number to a refusal. No row moved the other way, and no admitted value changed. - **H2 holds.** A refused cell's row carries `code: 'invalid_number'`, the code the importer already uses for `abc`, with the importer's existing sentence (`Amount: "3,14" is not a number`, from the catalog's `import_invalid_number` key). The plain create door answers `400 VALIDATION_FAILED` with field code `invalid_number` for the same cells, and that is pinned beside the import pins. No new code. - **H3 holds, in the direction expected.** The ablation removed the grouping guard, which restores the unconditional strip. It ran via `scripts/ablation-replace.mjs` in wrap mode, at head `c76a3c95f2`. The anchor went 1 to 0 and the blob went `afaf602da192` to `a06963b8c44c`, so the mutation landed. Result: `Tests 24 failed | 46 passed (70)`. - **Red: every refused-cell assertion and nothing else.** That is 18 `parseNumberCell` refusal cases, the 4 per-cell import pins, the CSV leg and the dry-run leg. - **Green: every admitted control.** That is 3 import pins, 8 `parseNumberCell` admitted cases, and the plain-door parity pin. - **Restore proven.** Blob after restore equals HEAD (`afaf602da192`), `git diff HEAD` is empty, and the marker count is 0. No build was needed: the pins reach `import-coerce.ts` through relative imports (`./rest-server`, `./import-coerce`), never through a `dist/`. ## Tests - `packages/rest/src/import-number-thousands-group.test.ts` (new) goes through the real `/import` route over `SqlDriver` (better-sqlite3 `:memory:`). It has 10 cases: - each of the four cells is refused as its own row's `invalid_number`, with a sibling row still written; - each admitted control (`1,000`, `12,345.67`, `(1,234)`) is stored as its number; - the same verdicts hold for quoted CSV cells; - the dry run predicts the refusals and persists nothing; - the plain create door answers the same code. - `packages/rest/src/import-coerce.test.ts` gets the `parseNumberCell` case table: 8 admitted groupings and 18 refused comma forms. - At `c76a3c95f2`, run as `pnpm --filter @objectstack/rest test --maxWorkers=2`: `Test Files 220 passed (220)` and `Tests 4211 passed | 40 skipped (4251)`. `test:repo` passed 1 file with 8 tests. - `pnpm --filter @objectstack/rest typecheck` exits 0: `tsc --noEmit`, then `check:test-typecheck: OK`, with 0 errors. Both test files are in the `tsconfig.test.json` program (`--listFilesOnly`). ## Gates - **Build.** `turbo run build` ran first for `@objectstack/rest...`, then for all of `./packages/*` and `./packages/*/*`, because two gates read the whole built tree. Result: 71/71 tasks. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands at `c76a3c95f2`. All 61 ran with exit 0. Reconciled with `--ran`: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`. - **Other gates, all exit 0.** `pnpm lint` (the whole repository, 29 s, no narrowing) and `node scripts/check-issue-citations.mjs --base origin/main` (`origin/main` is still `9449512a31`, the branch point, so there was nothing to merge). - **Changeset gates.** `check-adr-0087-registration` names this PR's changeset as `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major` reports no `major`. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2 --output-logs=errors-only pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 --output-logs=errors-only pnpm --filter @objectstack/rest test --maxWorkers=2 pnpm --filter @objectstack/rest test:repo --maxWorkers=2 pnpm --filter @objectstack/rest typecheck pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 (the targeted pin files, and the ablation's wrapped run) (The entry point printed this wording once for each command above. It is pasted once here, with every command it covered.) ## Changeset `.changeset/20497-import-number-thousands-group.md`: `@objectstack/rest` `minor`, with a line-initial `Clause-②: no (narrowing)`, a BREAKING paragraph giving each refused cell shape FROM → TO, and the ADR-0087 disposition `not-required (no-migration-prescription)`. The shape follows PR objectstack-ai#20218 and PR objectstack-ai#20231. ## Acceptance notes - **The `InMemoryDriver` leg is measured, not pinned. This departs from triage's pin list.** Triage asked for `/import` pins on memory and SQLite. `@objectstack/driver-memory`'s test consumers are a ruled, ledgered set (`scripts/driver-memory-census.ledger.json`, gated by `pnpm check:driver-memory-census`), and the gate says a new consumer is a maintainer ruling. A first cut added the driver as a `packages/rest` devDependency with a source alias. The census gate refused it by name: `x LEDGERED: packages/rest/src/import-number-thousands-group.test.ts:31 binds @objectstack/driver-memory (import) and the ledger does not cover it`. That cut was withdrawn, so the diff is back to the claim's file surface. The cell is judged by the importer's reader before any driver is reached, so one verdict holds on every driver. The memory readings at base and head are recorded in the table above and in the pin's header. If a permanent memory arm is wanted, it goes through the census ruling first. - **Grouping by twos is now refused (`12,34,567`, `1,00,000`).** These used to import as the number they denote. The changeset names this as the one case where the narrowing refuses a cell that was read correctly before, because a two-digit group cannot be told apart from a decimal comma. - **The import template card objectstack-ai#18386 should follow this wording.** Its value-domain row for `number` lists `1,234` among the tolerated forms. It should say that a comma is read only as a thousands group (1 to 3 leading digits, then groups of exactly three, only before any `.`), and that a decimal comma such as `3,14` is refused, never guessed. That card is assigned elsewhere and is not edited here. - **A spec comment now overstates the reader.** The module header of `packages/spec/src/data/filter-number-comparand-declared-type.ts` says, in the parenthetical under its refused digit-separator forms, that the CSV import route's cell reader strips such punctuation before it parses. For `1.000,5` that is no longer true. It was already untrue for `1_000` and `1 000`, which the reader refused before this change too. This is a comment in the spec seat's surface. There is no carrier, so it is noted here only. - **objectui's Import Wizard preview disagrees with the server on grouped numbers.** The preview judges numeric cells with a bare `Number()` (`packages/plugin-grid/src/ImportWizard.tsx` in objectui, the number/currency/percent case). So it flags `1,000` as invalid while the server admits it. That disagreement predates this PR and is unchanged by it. For the four cells here, preview and server now agree: both refuse. I read this in objectui's source and did not measure it through the UI. There is no carrier, so it is noted here only. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19925
Clause-②: no (narrowing)
What changed
Ruling A on #15293 (
5634034754) says that apackageswhich is present but is not an array ({},0,'x') is malformed, not absent, and that every reader refuses it. The runtime,@objectstack/coreand the plugin readers already refused it. The four@objectstack/clireaders answered "no packages" instead. They now refuse it with core's ownINVALID_ARTIFACT_PACKAGESrefusal (ADR-0112,status: 422).stack-collections.tsgets ONE exported helper,declaredPackageEntries(packages):undefined) answers[], and that is the only value the helper answers on its own;nullincluded, goes toresolveArtifactPackageOrder. A non-array is refused there. The resolver's absent answer ([artifact], the object passed in, by reference) is recognised by identity and answers[]. Sonullgets whatever the resolver answers for it (round 1, after ruling5805260775onpackages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926).The helper spells neither the rule nor the refusal, and no new code is minted.
packageBodiesroutes through the helper. So dodocsPackageRefs,bodyDocsOfandattachPackageDocsincollect-docs.ts. None of the four keeps its ownArray.isArrayguard.New pins in
packages/cli/test/non-array-packages-readers.test.ts(16 tests). Each refusal assertscode+status.New pins in
packages/cli/test/null-packages-follows-resolver.test.ts(16 tests), added in round 1:packages: nullto the REAL resolver's verdict on{ packages: null }. That is the absent answer today, and the refusal oncepackages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926's core change lands.nullwith the non-array envelope, and every reader must then refuse.A private
nullbranch in the CLI never asks the resolver, so today only leg 2 can see one.The existing row
docsPackageRefs› "is empty for anything that is not an array" (src/utils/collect-docs.package-docs.test.ts) pinned[]for{}, which is the retired answer. It now asserts the refusal envelope and keeps its absent control.Changeset
.changeset/19925-cli-non-array-packages-refusal.md:@objectstack/climinor,Clause-②: no (narrowing), a BREAKING paragraph, and the ADR-0087 dispositionnot-required (no-migration-prescription). Round 1 replaced itsnullsentence with a paragraph saying thatnullfollows core's resolver (ruling5805260775).Reach, measured: why the changeset narrows
The built CLI (
packages/cli/bin/run.js) was run once70876e4(before) and on this branch (after). Each fixture is a hand-writtenobjectstack.config.tswhose default export is a plain object carrying amanifestand thepackagesvalue shown.packages{}/0/'x'os info --jsonstats.objects: 0INVALID_ARTIFACT_PACKAGES{}/0/'x'os lint --jsonpassed: trueINVALID_ARTIFACT_PACKAGES{}/0/'x'os validate --jsoninvalid_typeatpackages{}os build --jsoninvalid_typeatpackages{}os serveINVALID_ARTIFACT_PACKAGESsentence raised during bootos info/os lintpassed: trueos info/os lintpassed: trueos info/os lintINVALID_ARTIFACT_PACKAGE_ENTRYWith its default strict parse,
defineStack(…)refusespackages: {}at config load (STACK_SCHEMA_INVALID). Only two spellings reachos info/os lintwith the shape: a plain-object export, anddefineStack(…, { strict: false }). Both were measured, and both behave as the table says.So two public doors ACCEPTED the shape and answered success. The claim carried
Clause-②: no. This PR follows the same-door precedents (.changeset/19120-install-door-parses-manifest-version.md,.changeset/19417-install-door-parses-manifest-id.md) and declaresClause-②: no (narrowing)with aminorbump instead.The four readers, before and after
The readers were called from source with a well-formed control and an absent control.
{}/0/'x'beforepackageBodies(viaresolveStackCollection)[]INVALID_ARTIFACT_PACKAGES, 422[], unchangeddocsPackageRefs[]INVALID_ARTIFACT_PACKAGES, 422[], unchangedbodyDocsOf[][], unchangedattachPackageDocsINVALID_ARTIFACT_PACKAGES, 422bodyDocsOfhas one caller,collectAndLintDocs, which calls it only for refs thatdocsPackageRefsproduced from the same value. So a non-array never reached it, before or after. It uses the helper anyway, so that no reader keeps a guard of its own. The ablation below confirms that no pin can observe it.Ablation
The fix was committed first (
67d5b4829). The pin file importssrc/relatively, so nodist/leg is involved. Each leg went throughscripts/ablation-replace.mjs: the anchor went 1 → 0, the injected text 0 → 1, and the blob changed. The leg then ran the pin file, and the tool restored the file (blob equal to HEAD,git diff HEADempty). At the end,git hash-objectof both files matched their HEAD blobs.[]packageBodiesgets its oldArray.isArrayguard backdocsPackageRefsgets its old guard backattachPackageDocsgets its old guard backbodyDocsOfgets its old guard backRound 1: the fix was committed first (
5bac82635). The leg restored the privatenullbranch,if (packages === undefined || packages === null) return [];, throughscripts/ablation-replace.mjs: the anchor went 1 → 0, the injected text 0 → 1, and the blobc8c830d2e25b→78739bfea3c8.c8c830d2e25b),git diff HEADempty, status clean.Verification
Round 1, all on HEAD
ae8e3e83f, after mergingorigin/mainat3cb84d084.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 229 files, 3250 tests passed. Theintegrationtier is left to CI, because the diff touches no spawn entry and no integration-tier file.pnpm --filter @objectstack/cli typecheck: exit 0.check:test-typecheckcompiles both pin files undertsconfig.test.json.pnpm lintover the whole repo: exit 0.node scripts/pm/dispatch-gates.mjs --commandsderived 62 commands. All 62 exited 0, and the--ranreconciliation reports 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. As in round 0,check:dual-build-cjs-loadsandcheck:i18n-coveragefirst exited 3 (PREREQUISITE NOT MET, somedist/missing). Both were re-run on the same HEAD once the builds were present, and exited 0.node scripts/check-adr-0087-registration.mjs --base origin/mainandnode scripts/check-changeset-no-major.mjs --base origin/main: exit 0 each.node scripts/check-issue-citations.mjs --base origin/main: exit 0, with 6 citations resolving.packages: nullon the built CLI, with a plain-object config:os info --jsonexits 0 (stats.objects: 0) andos lint --jsonexits 0 (passed: true). Both are unchanged.packages: nullas malformed, never absent #20228'spackages/core/src/artifact-packages.ts(0706ffef7), and core was rebuilt. There, both commands exit 1 withINVALID_ARTIFACT_PACKAGES, and all four readers refusenull, with no CLI edit. Both pin files pass there too (32 of 32), and leg 1 takes the refusal branch.Round 0, on
a89a8e528: 228 files / 3234 tests passed, and typecheck, lint, all 62 gates and the citation check each exited 0.Not in this PR
recordsOf(stack.packages)readers treat a non-arraypackagesas "no packages" instead of refusing it — the packages/lint half of #19925 (ruling #15293-A) #20206 remains open. It carries the fourpackages/lintsites (domain:spec).packages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926 remains open, but it is not a separate decision. Ruling5805260775(letter A) makespackages: nullmalformed at every reader, and core's resolver refuses it. That core change is PR fix(core,runtime,plugin-dev,plugin-security): refusepackages: nullas malformed, never absent #20228, which touches nopackages/clifile.nullto the resolver and do not answer it themselves, so the refusal reaches them when fix(core,runtime,plugin-dev,plugin-security): refusepackages: nullas malformed, never absent #20228 lands, with no CLI edit.nullreads as no packages, exactly as before this PR.Acceptance notes
File surface. The claim named "their tests in
packages/cli/test/". The new pins live there. The existingdocsPackageRefsrow sits beside its source inpackages/cli/src/utils/collect-docs.package-docs.test.ts, and it pinned the retired answer, so it had to change in this PR.artifactPackages(packages/cli/src/utils/artifact-packages.ts) keeps its own!Array.isArrayguard. That file is outside the claimed surface, so it is untouched. Its docblock says it reads the PARSED stack. Every caller reaches it only after the schema parse, or after one of the four readers has judged the same value:compile.ts,validate.ts,nav-contribution-groupsandpermission-set-name-collisionspass parsed data;lint.ts,sdui-manifest.tsandlintDocNavTargetsrun afterauthoringRuleUnionStackordocsPackageRefs.So no public door reaches it with a non-array now. Its carrier is whichever PR next touches that file.
os serve.serve.tswrapscollectDocsFromSrcin a catch-all ("docs are additive — never block boot"). AdocsPackageRefsrefusal is swallowed there. The same boot then refuses the stack throughshouldAutoRegisterObjectQLand the manifest service. Measured:os servestill exits 1 with the same sentence.Generated by Claude Code