Skip to content

fix(cli): match the binary on untyped list state, unreadable artifacts - #62

Merged
replygirl merged 15 commits into
mainfrom
worktree-list-status-untyped-leftovers
Oct 5, 2026
Merged

replygirl merged 15 commits into
mainfrom
worktree-list-status-untyped-leftovers

Conversation

@replygirl

@replygirl replygirl commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Four items from list-status-untyped-leftovers obligations
(.claude/handoff/reports/list-status-untyped-leftovers-verify.md), verified
UNFIXED-or-inconclusive against main:

  • list.ts untyped-schema misclassification (verify item 1, CONFIRMED
    UNFIXED): computeRow's empty/state only checked cospec's own fixed
    artifact filenames, never a schema cospec doesn't type's own generates
    pattern. A change on a custom/forked schema with an artifact under another
    filename always read "no artifacts yet". Fixed by hasDeclaredArtifact,
    mirroring core/change.ts's hasSchemaOutput.
  • validate --archived --json below the version floor (verify item 3,
    CONFIRMED UNFIXED): the version-floor guard wrote stderr text
    unconditionally, never branching on flags.json like the no-root guard
    immediately above it — so --json got no document at all on an old
    binary. Factored into a pure, unit-testable archivedUnsupportedRefusal
    and wired through rootSelectionDocument.
  • upstream-spellings.test.ts row 3.7 overlayfs flake (verify item 4,
    INCONCLUSIVE): two independent remedyNamedRoot() copies asserted
    byte-identical, which only holds when both sides observe the same on-disk
    entry order. The pinned binary's own getAvailableChanges is unsorted and
    cospec relays it verbatim (confirmed by reading the dist and
    instructions.ts), so the fix is sharing one root between the two calls,
    not sorting either side.
  • status's mode-000-artifact handling (verify item 2, CONFIRMED
    UNFIXED by the verify report) — reopened here with further evidence:
    the verify report reasoned from a primitive existsSync/accessSync
    probe, not an end-to-end run. hasUnreadableEntry (task 11.12) already
    walks every file under a change directory, not just tasks.md, so
    status's existing routing already matches the pinned binary on both
    macOS (both refuse, exit 1) and Linux (oven/bun:1.3.14, non-root UID
    1000: both read past it, exit 0) — verified directly against the real
    binary on both OSes. No product change; a new differential contract row
    (18.2) pins the behavior down instead. See design.md "Context" for the
    full evidence trail and why it supersedes the verify report.

Review follow-ups (fixed before merge)

A review round confirmed four further findings, all fixed on this branch
before merge:

  • list's untyped-schema fix missed the config.yaml-schema fallback case:
    a change with no .openspec.yaml of its own (taking its type from
    config.yaml's schema:, as status already does) still reported
    type (none)/in-progress/never-archive-ready in list. computeRow now
    shares defaultProjectSchema with status's gradedChange and
    hasSchemaOutput, and a namespace folder is explicitly excluded from that
    fallback (it stays (none)/not-a-change, matching status --all).
  • No test covered validate --archived --json's new stdout branch: the
    unit tests only drove the pure archivedUnsupportedRefusal helper, and
    every contract/integration row runs the real pinned binary (always above
    the version floor), so the command's own flags.json branch was never
    exercised — reverting it to an unconditional stderr write would still pass
    the full suite. run now takes an injectable deps.wrappedOpenspecVersion
    (test-only), and a new command-level test drives both the --json and text
    branches end to end against a stubbed old version.
  • Ledger rows 4.1–4.3 and the 18.2 comment misdiagnosed the Linux --all
    exit 1
    and cited a helper (realpathRefuses) already dropped from that
    row: reproduced directly against the pinned binary on both OSes — --all
    exits 1 regardless of the mode-000 lock because its sweep also walks the
    fixture's own namespace folder (mobile), which the binary reports as its
    own change_error independent of any lock. Narrative corrected in
    design.md/verification.md; the 18.2 row gained an assertion pinning the
    mobile change_error down.
  • hasDeclaredArtifact silently swallowed every loadSchema failure: a
    declared schema that exists but fails to parse/validate was counted as "no
    artifacts" with no diagnostic. It now distinguishes "no such schema" (no
    signal, matching upstream) from "schema exists but won't load" (a
    schema_unreadable warning on the row).

Design, tasks and verification ledger (now archived):
openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/.

Two of the new contract rows (18.1, 18.2) failed on their first CI run
for reasons that didn't reproduce locally (non-deterministic list recency
order; --change vs --all disagreeing on refusal for the same mode-000
file on that runner) — both rewritten to stop predicting an environment-
dependent outcome and instead assert order-independently / against each
invocation's own measured answer, matching the proven 15.11/15.12 pattern.
design.md's Risks section records both findings.

Test plan

  • apps/cli/test/unit/commands/commands.test.ts — new list rows for an
    untyped schema's own declared artifact and the config.yaml-fallback/
    namespace-folder cases (red before, green after)
  • apps/cli/test/unit/commands/validate.test.ts — new
    archivedUnsupportedRefusal unit tests plus a command-level test
    driving the flags.json branch end to end
  • apps/cli/test/contract/cli-surface.test.ts — new 18.1 (list state
    parity) and 18.2 (mode-000 differential pin, now with a mobile
    change_error assertion) rows
  • apps/cli/test/contract/upstream-spellings.test.ts row 3.7 — shared
    root, run 20x locally with no divergence
  • mise run docs:build succeeds after the commands.md edit
  • mise run check green at the merge stage, rebased onto main (1974
    unit / 193 integration / 2523 contract / 343 bench / 14 e2e, 0 fail)
  • mise run cospec -- validate list-status-untyped-leftovers --strict
    passed (0 errors, 0 warnings)
  • CI green (ci-bun on ubuntu-latest — this repo's CI has no macOS
    runner)
  • mise run cospec -- archive list-status-untyped-leftovers — no flag,
    no refusal; move verified on disk

🤖 Generated with Claude Code

replygirl added a commit that referenced this pull request Oct 5, 2026
PR #62's ci-bun run passed (ubuntu-latest, 32m4s) -- this repo's CI has
no macOS runner, so rows 3.2 and 4.3 are corrected to say so rather
than claim a second runner that doesn't exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
replygirl and others added 13 commits October 5, 2026 06:21
Scaffold the fix-typed cospec change for the four UNFIXED items from
list-status-untyped-leftovers-verify.md: list.ts's untyped-schema state
misclassification, validate.ts's --archived --json version-floor guard,
upstream-spellings.test.ts row 3.7's ordering flake, and a differential
pinning test for status's already-correct mode-000-artifact handling
(contradicting the verify report's item 2, per end-to-end evidence in
design.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
computeRow derived `state` from hasAnyArtifact, which only recognizes
cospec's own fixed artifact filenames, never gated on isCospecType — so a
custom/forked schema whose artifacts live under other filenames always
read "no artifacts yet" even with a written artifact on disk (the same
misclassification task 11.5 fixed for status's state/next). A schema
cospec doesn't type now additionally checks its own declared schema's
generates pattern against the change directory (hasDeclaredArtifact,
mirroring core/change.ts's hasSchemaOutput), so it reads `building` once
a file that pattern names exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The --archived version-floor guard wrote to stderr unconditionally,
never branching on flags.json like the no-root guard four lines above
it — so `validate --archived --json` against an OpenSpec binary below
ARCHIVED_SINCE printed no parseable document at all. Factored the
refusal into archivedUnsupportedRefusal(version), a pure function unit-
testable without a fake binary, and wired it through
rootSelectionDocument exactly as every other early-exit refusal in this
file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Row 3.7 asserted byte-identical stdout/stderr between a cospec and an
openspec instructions call, each over its own independently-created
remedyNamedRoot() copy. The pinned binary's own getAvailableChanges is
unsorted and cospec relays it verbatim, so the assertion only holds
when both calls observe the same on-disk directory-entry order, which
two separate cpSync copies don't guarantee on every filesystem
(overlayfs in particular). Call remedyNamedRoot() once and pass the
same directory to both calls instead of sorting either side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
status's existing hasUnreadableEntry/binaryDecides routing (task 11.12)
already walks every file under a change directory, not just tasks.md,
so it already matches the pinned binary's mode-000-artifact behavior on
both macOS and Linux (bun realpath differs per OS; cospec's own routing
widens to ask the binary either way). Contract row 18.2, landed with
list.ts's fix, differentially pins this down; this records the
corresponding tasks/verification evidence, contradicting the stage's
verify report, which reasoned from a primitive existsSync/accessSync
probe rather than the end-to-end command (see design.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…vers

Full local gate green: 2523/2523 contract tests (0 fail), 1954/1954
unit, 193/193 integration, 343/343 bench, 14/14 e2e release — lint,
format, typecheck, generate:check, vendor:openspec:check,
cospec-validate-all, agents:check and openspec:schema:validate all
pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI (ubuntu-latest) failed both new rows, neither locally reproducible:

- 18.1's text assertion anchored r-doc's row with a greedy \s+$,
  relying on it being the last line of the table. list's default
  order is recency (mtime), which is filesystem-timing-dependent;
  CI's order put r-doc first instead of last, and the greedy pattern
  had silently been matching across the newline into the next row.
  Now finds r-doc's own line and matches it directly, order-independent.
- 18.2 predicted up.exitCode from one realpathRefuses() check shared
  across --change and --all. CI's runner showed --all refusing where
  --change did not for the same mode-000 proposal.md -- a divergence
  a local oven/bun:1.3.14 Docker probe could not reproduce, so its
  exact mechanism is unconfirmed. Rewrote to never predict an exit
  code: each invocation reads its own measured answer's shape to pick
  its branch, exactly as the proven 15.11/15.12 tasks.md rows do.

design.md records both findings and the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR #62's ci-bun run passed (ubuntu-latest, 32m4s) -- this repo's CI has
no macOS runner, so rows 3.2 and 4.3 are corrected to say so rather
than claim a second runner that doesn't exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
list.ts read only a change's own .openspec.yaml, so a change with none
(taking its type from config.yaml's schema:, as status and
hasSchemaOutput already do) fell back to an empty schema name and was
always reported type '(none)', state 'in-progress', never
archive-ready — disagreeing with `cospec status` on the same change.
Share one resolver (defaultProjectSchema) across hasSchemaOutput,
status's gradedChange and list's computeRow so the three never drift
again.

hasDeclaredArtifact also swallowed every loadSchema failure alike, so
a declared schema that exists but fails to parse/validate silently
reported the row as empty with no diagnostic, unlike `cospec status`
on the same change. It now distinguishes "no such schema" (no signal,
matching upstream) from "schema exists but won't load" (a
schema_unreadable warning on the row).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No test drove the command's own flags.json branch for --archived's
version-floor refusal: the unit test called the pure helper directly,
and the contract/integration rows only ever run against the pinned
binary (always >= ARCHIVED_SINCE), so reverting the command's refusal
write back to an unconditional stderr line would still pass the full
suite. wrappedOpenspecVersion memoizes its result once per process, so
a real command-level test needs the version source injectable: run now
takes an optional deps.wrappedOpenspecVersion, used only by tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ledger rows 4.1-4.3, design.md and the 18.2 test comment attributed
--all's own exit 1 on this fixture to the mode-000 proposal.md lock
and cited realpathRefuses, a helper commit a59e9b9 already dropped
from this row. Reproduced directly against the pinned binary (macOS
and an oven/bun:1.3.14 container as non-root), locked and unlocked:
--all exits 1 regardless, because its sweep also walks the fixture's
own namespace folder (mobile), which the binary reports as its own
change_error ("is not a change") independent of any lock; --change
alpha never sweeps mobile at all, and on Linux reads straight past
the lock (its realpath needs no read permission there, unlike
macOS/Bun's). The row's own exit-code equality assertion was already
correct either way, since it never predicted a code; only the
narrative explanation was wrong. Added an assertion pinning the
mobile change_error down so this can't regress silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ledger row 2.2's recorded observation was only that the test file
wouldn't compile against unmodified validate.ts, not a demonstration
that the test could catch the bug. Row 2.3 records the actual
red/green now available: reverting the command's refusal write to its
pre-fix unconditional stderr line fails the new command-level test
added in a34862b4, confirmed by hand (reverted, ran red, restored, ran
green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The config.yaml-schema fallback added for a genuinely bare change
also caught a namespace folder (no .openspec.yaml of its own, only a
nested child one level down): its row got typed by the project's
default schema (e.g. 'feat') instead of staying '(none)', even though
its state already correctly reports it as 'not-a-change'. cospec
status --all avoids this the same way — it discards a namespace
folder's gradedChange-resolved schema entirely, reporting a failure
entry with no type field. Caught by the existing contract key-oracle
row (1.1), which still had mobile expecting type '(none)'; added a
faster unit regression alongside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@replygirl
replygirl force-pushed the worktree-list-status-untyped-leftovers branch from 33fa09e to 9c765a1 Compare October 5, 2026 11:21
replygirl and others added 2 commits October 5, 2026 06:47
Tick tasks.md's final row now that the archive commit follows this
one, and refresh verification row 5.1 with the fresh mise run check
counts from a re-run at the merge stage (rebased onto main, four
review findings fixed: config.yaml schema fallback, namespace-folder
guard, the command-level --archived --json test, 18.2/ledger
correction) — 1974 unit tests (was 1954), same contract/integration/
bench/e2e counts, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Archived via cospec archive — validated, no hard-gate refusal.
Specs: skipped (fix schema has no spec-sync deltas).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@replygirl replygirl changed the title fix(cli): match the binary on untyped list state and unreadable artifacts fix(cli): match the binary on untyped list state, unreadable artifacts Oct 5, 2026
@replygirl
replygirl marked this pull request as ready for review October 5, 2026 11:48
Copilot AI balanced review requested due to automatic review settings October 5, 2026 11:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@replygirl
replygirl merged commit 97a3afe into main Oct 5, 2026
11 of 12 checks passed
@replygirl
replygirl deleted the worktree-list-status-untyped-leftovers branch October 5, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants