Skip to content

fix(cli): os lint --json reports the ADR-0087 conversions defineStack applied - #20617

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20583-conversions-lint-and-refusal
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20583-conversions-lint-and-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20583
Clause-②: no

What this lands: location 1 only

os lint --json now folds LoadedConfig.stackConversions into its conversions list, right after loadConfig: the same one-line fold validate.ts / compile.ts make at their step 1b since PR #20579. defineStack converts at load, so the config os lint received was already canonical and its own normalizeStackInput pass found nothing of the default export to convert. The notice reached stderr alone.

  • os lint keeps accepting exactly what it accepts today. There is no refuseUnbuiltStack here and none is implied; the one-shape rule is not extended to this command. An unbuilt default export carries no record, so stackConversionsOf answers an empty list for it and the command's own pass converts it as before.
  • One conversion is listed once. The producer's output is canonical wherever it converted, so the pass finds only what no producer saw: an unbuilt export, or a key merged from a named export. Pinned by the exactly-one rows below.
  • No new --json key, and passed, issues, the counts and the exit code do not move. The text face prints the folded notice in its warning block, as os build does.

What this leaves: location 2 is a fork, not shipped

A defineStack that converts and then refuses still reports conversions: [] on os validate --json, os build --json and os lint --json (the third door, measured below). The dispatch's hypothesis was that the CLI can recover those conversions without a spec edit. Measured, it cannot recover them; it can only reconstruct them:

  1. Capturing the producer's stderr lines during loadConfig loses notices. warnConversionNotice is warn-once per process, keyed on conversion id, path, from and to. The record is not subject to that warn-once.
    • Measured at eb4b17c346: composeStacks([defineStack(A), defineStack(B)]), where both carry page:header description at pages[0]. The record carries 2 notices, and stderr carries 1 line.
    • In the refusing variant (B adds requires: ['no-such-capability']), the one stderr line belongs to A, whose defineStack did not refuse. B's own notice was suppressed.
    • The line also lacks the surface, toMajor, message and code fields, so the structured notice would have to be re-derived from prose. The conversion types document that prose as derived, never the source of truth.
  2. Recomputing through normalizeStackInput on the authored argument is a second conversion pass. The CLI would shim defineStack in every config load, or re-load the module in authored-source mode, and rerun the pass when the call refuses. The stackConversionsOf TSDoc rules this out: a door never runs a second pass to reconstruct the record. It would also copy the producer's record formula (input record plus pass notices) into the CLI, where it drifts.
  3. The spec exposes no other channel: warnConversionNotice and its warn-once set are module-private, and the refusal errors (StackRefusalError subclasses) carry issues only.

So the only channel that is not a workaround is a spec change: the refusal carries the notices it applied. packages/spec belongs to the spec seat under this dispatch, so no spec edit is made here. The dev report carries the fork, with options.

Measurements (CLI from source, bin/run-dev.js)

run before (eb4b17c346) after (ca74de14aa)
os lint --json, the card's page:header description case exit 0, conversions: [], 1 stderr line exit 0, conversions = the one page-header-subtitle-alias notice, 1 stderr line
os validate --json, convert-then-refuse (requires: ['no-such-capability']) exit 1, STACK_CAPABILITY_UNKNOWN, conversions: [] unchanged (location 2)
os build --json, the same config exit 1, STACK_CAPABILITY_UNKNOWN, conversions: [] unchanged (location 2)
os lint --json, the same config not measured exit 1, STACK_CAPABILITY_UNKNOWN, conversions: [] (location 2, third door)

Tests

  • packages/cli/test/stack-conversion-record-door.test.ts gains an os lint --json block. It covers the card's plain case, the record across the named-export spread, composeStacks, a key merged from a named export (the pass converts it once, with no producer stderr line), and the canonical control. Every non-empty row asserts exactly one entry. This file is in the per-PR integration tier. The existing lint-conversion-notices.e2e.test.ts is *.e2e.* and runs nightly only, which is why the new rows are not in it.
  • At ca74de14aa: the unit tier passed 234 of 234 files (3342 tests), and the door file passed 15 of 15 tests. pnpm --filter @objectstack/cli typecheck exits 0, and the door file is in the test-layer program (--listFilesOnly). lint-conversion-notices.e2e.test.ts under OS_TEST_TIERS=nightly passed 6 of 6 at a80b61dad0. Its unbuilt export default fixtures still lint and convert through the pass.
  • Ablation, run from the committed state a80b61dad0 through scripts/ablation-replace.mjs (WRAP mode, with a trap). Deleting the fold line took the anchor count from 1 to 0 on disk (blob 37bf1203bff7 to 95dcca7f308a).
    • Exactly the three record-dependent lint rows went red (plain, record across spread, composeStacks). The named-export-pass row, the lint control and all 10 validate / build / strict rows stayed green: 3 failed, 12 passed.
    • Restore was proven: the blob equals HEAD (37bf1203bff7) and git diff HEAD is empty. lint.ts is loaded from src/ by the child, so no dist/ sits on the measured path.

Gates (at ca74de14aa, after merging origin/main)

  • dispatch-gates --commands: 63 commands, all exit 0. dispatch-gates --ran reconciles 63 derived, 63 run, 0 not measured, 0 unrun, each with its exit code. The first runs of check:dual-build-cjs-loads and check:i18n-coverage answered PREREQUISITE NOT MET (exit 3) before a full build. Both were rerun green after pnpm turbo run build --filter=!@objectstack/docs.
  • The artifact-roster rows: 36 of 39 exit 0. check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths need PR context and are rerun against this PR.
  • pnpm lint (the repo-wide eslint . --no-inline-config) exits 0 in 29s.
  • node scripts/check-issue-citations.mjs --base origin/main exits 0 (1 citation, resolves).

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.

Acceptance notes

  • Location 2 stays open on the card. This PR says Part of, so merging it leaves the card open for the fork above.
  • os lint's text face now prints the producer's notice in its warning block for a defineStack config with a retiring spelling, in the same wording as os build. check:i18n-coverage runs os lint over the 13 example configs and stays green.
  • Host-only reading, not a defect: on macOS, test/published-subpath-console.pin.test.ts and test/published-subpath-hook-body.pin.test.ts fail 5 assertions when TMPDIR is the /var/folders/... symlink. The resolver answers the /private/var/... realpath. With TMPDIR set to its realpath, both pass 29 of 29. CI runs on Linux. Carrier: none.
  • Carried over from the card, not filed: content/docs/deployment/cli.mdx's "Warnings checked" list for os validate names no conversion notices.

Generated by Claude Code

hotlong and others added 3 commits September 29, 2026 16:04
…pplied into conversions

os lint filled conversions only from its own normalizeStackInput pass over
the loaded config, which a defineStack default export hands over already
canonical, so the notice reached stderr alone. Fold
LoadedConfig.stackConversions right after loadConfig, the step 1b fold
os validate / os build make. No one-authoring-shape refusal is added: an
unbuilt export carries no record and converts through the pass as before.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os lint (command, 30 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json aa23e2c8fd39eaf24e27d78dfd68332c07154af6 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ca74de14aae97502a8b813572ebfe45380be87f1
Local-runs: none

Inputs, and nothing else: card #20583 (body and all 4 comments: triage 5884692257, claim 5886007741, os-dev-report 5886640889, seat answer 5886671384); PR #20617 body, file list (3 files, +93 / -1) and the net diff against main at ca74de14; the check-runs on ca74de14, read 2026-09-29T08:38:46Z; source at that head through the REST contents API (lint.ts, config.ts, validate.ts, compile.ts, the door test, lint-conversion-notices.e2e.test.ts, spec stack-provenance.ts / stack.zod.ts). Nothing checked out, built, run or re-run.

Check-runs at read (newest per name, 31 names): 21 completed, 18 success and 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke); 10 in progress (Dogfood Regression Gate 1/3 to 3/3, Lint and Repo Gates, Test Core 1/6 to 4/6, Type Check consumer gates, Type Check workspace); 0 failed, 0 queued. Success includes Build Core, Test Core 5/6 and 6/6, Type Check source gates and debt ledger, Dogfood Verify CLI, Temporal Conformance, Check Changeset, Check PR Size, both single-claim guards, Part-of PR must not also close its card and The card this PR closes must claim this branch. Commit status Vercel: success. No verdict is inferred for the 10 running checks; this record does not stand in for them.

① Derived judgments

The diff is one executable line in packages/cli/src/commands/lint.ts (conversionNotices.push(...loaded.stackConversions), plus comments), a five-row os lint --json block and one plain fixture in packages/cli/test/stack-conversion-record-door.test.ts, and one changeset. packages/spec, config.ts, validate.ts, compile.ts are untouched.

(1) os lint accepts exactly what it accepted; an unbuilt config still answers through its own pass. RIGHT. lint.ts imports nothing from stack-provenance-refusal and calls no refuseUnbuiltStack (validate.ts:16/228 and compile.ts:17/264 do; lint's only mentions are in the comment saying it does not). The folded value is stackConversionsOf(mod.default), read by loadConfig before the named-export spread (config.ts:477, not in this diff); stackConversionsOf answers the frozen empty list for an unmarked value or a missing record and cannot throw (stack-provenance.ts:488-492), so the push can never turn a load that succeeded into a refusal. The exit code is failing = errors.length + (strict ? warnings.length : 0) over issues alone (lint.ts:1043-1047); conversionNotices feeds only the text-face warning block and the two --json emits, so --strict on lint moves for no config. A plain object-literal default export gets fold [] and is then converted by the command's own normalizeStackInput pass as before; the pre-existing lint-conversion-notices.e2e.test.ts pins that on export default { (nightly tier, untouched here). No one-shape rule reaches os lint; #20367 OQ3 stays unopened, as triage 5884692257 binds.

(2) A conversion cannot be listed twice, and the pins prove exactly-once. RIGHT, for the driven conversion. The one array has two fillers: the fold (what the producer converted, recorded on the default export before the spread drops it) and the pass over the merged config. They do not overlap because the producer's output is canonical wherever it converted, so the pass can only find what no producer saw: an unbuilt export, or a key merged from a named export after the producer ran. Producer side, at the head: defineStack starts its record from the input's own record and appends its pass (stack.zod.ts:3608-3614), so a built stack handed back keeps one record; composeStacks concatenates its inputs' records with identity dedup (stack.zod.ts:5342). The pins assert exactly-once, not containment: expectExactly projects every entry to (code, conversionId, path, from, to) and toEquals the whole array against [THE_NOTICE], so length 1 is asserted. Five lint rows: plain (fold 1, pass 0, and the producer's stderr line counted exactly 1), recordAcrossSpread (fold 1, pass 0), composed (fold 1 from the input that applied it, pass 0), namedExportPass (fold 0, pass 1, and no producer stderr line), canonical ([]). The dev's ablation (fold line removed: exactly the three record-dependent rows red, namedExportPass and control green) is reported in the PR, not re-run here. Boundary stated plainly: exactly-once for every conversion rests on the pass being idempotent on canonical output, the same premise os validate / os build have carried since PR #20579; this PR adds no premise of its own.

(3) The --json shape is unchanged. RIGHT. conversions has been an unconditional key of os lint --json since #12297 (lint.ts:1069-1076, outside the diff); no key is added; passed, strict, failing, issues, the counts and duration are untouched; the catch-all still emits conversions: conversionNotices, and [] for a throw inside loadConfig because the push sits after it. The text face now prints the folded notices in its warning block through the same formatConversionNotice wording os build uses (compile.ts:299-303): new stdout lines on a defineStack config carrying a retiring spelling, not a contract surface. check:i18n-coverage (runs os lint over 13 example configs) is reported green by the dev locally and sits inside the running Lint and Repo Gates.

Public surface: no export, flag, error code or schema key moves.

② Semver level

.changeset/20583-lint-json-define-stack-conversions.md: @objectstack/cli: patch. RIGHT: a bug fix in a released package (AGENTS.md rule 3). An existing array on an existing --json door now carries what it always should have; that publishes (so not skip-changeset) and widens nothing (so not minor). Check Changeset: success.

Clause-②: no is RIGHT and consistent across the claim 5886007741, the PR body (line 2) and the changeset's last line (the repo's convention; 453 changesets in the tree carry the line). The judgement the line makes is whether the card relaxes an accept set or enlarges a public surface: neither. No config that was refused is accepted, none that was accepted is refused, and no key or export is added. (4) patch and no are right for a CLI --json field that now reports what it always should have.

③ Boundary flags

Dev deviations (5886640889), each answered:

  1. Part of #20583, no closing keyword. (5) RIGHT. The card stays open for location 2: the seat's answer 5886671384 splits its spec half to spec(stack): a refusing defineStack carries the conversions it applied on its StackRefusalError, so the doors can report them (the spec half of #20583) #20618 (read at REST: open, bare, filed 08:36Z, title names it the spec half of [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583) and keeps the CLI half (the three catch-alls and the convert-then-refuse pin) on [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583 behind it. Gate Part-of PR must not also close its card: success. Gate The card this PR closes must claim this branch: success (the claim names claude/issue-20583-conversions-lint-and-refusal).
  2. Triage's convert-then-refuse pin not delivered: right to withhold. A pin on conversions: [] at the refusal would encode the defect; it belongs with location 2.
  3. De-duplication structural, not code: right, see ①(2). validate.ts / compile.ts add no dedup either.
  4. Lint pins in the per-PR integration tier rather than the nightly *.e2e.* file: accepted. The unbuilt-export half stays pinned nightly only, a placement that predates this PR and is not worsened by it.
  5. The first label-write ran outside with-fleet.sh and was rerun once: process, no contract bearing.
  6. Local git identity with model-free trailers: process, no contract bearing.
  7. The unit tier needs a built packages/cli and a realpath TMPDIR on macOS: host-only; the head's check-runs (Linux) are the reading of record.

open_questions (1), location 2's channel: ANSWERED by the seat, 5886671384, option A (the refusal carries the record), spec half #20618, CLI half stays on #20583. Not judged here, per the brief (location 1 only). Noted for that half: the dev measured the same refusal answering [] on os lint --json too, so lint's catch-all belongs to the CLI half alongside validate's and build's.

out_of_scope_findings (1), the macOS TMPDIR symlink failing two published-subpath pins: host-only, outside this diff, carrier none. No escalation.

PR-body flag: verification ran UNLOCKED (no flock on macOS), a declared narrowing of the dev's local runs. It does not touch the gate verdicts, which are the head's check-runs.

Escalations: none.

Implemented-by: claude/issue-20583-conversions-lint-and-refusal
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 08:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit ed6f734 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20583-conversions-lint-and-refusal branch September 29, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant