diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 9896eda0..33657024 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -20,14 +20,17 @@ import { join } from 'node:path' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' import { parseBlockers } from '../core/blockers.ts' +import { loadSchema, schemaDir } from '../core/change-metadata.ts' import { changesDir, + defaultProjectSchema, findNestedChangesIn, isCospecType, listChanges, readOpenspecYaml, } from '../core/change.ts' import { flagValue, hasFlag } from '../core/command-table.ts' +import { artifactOutputExists } from '../core/glob.ts' import { OpenspecCallError, passthroughOpenspec, @@ -172,6 +175,45 @@ function nativeRow( } } +/** + * Whether `dir` holds a file its own declared schema's `generates` pattern + * names — the only signal cospec has for an artifact it doesn't recognize by + * name (mirrors `core/change.ts`'s `hasSchemaOutput`, scoped to the change's + * own resolved schema rather than the project's default, since a + * schema-bearing change always names its own). A schema name that resolves to + * no directory at all gives no signal, as the binary's own `list` never loads + * a schema either (its row is task-progress-only; `dist/core/list.js`) — but a + * schema that does resolve and then fails to read, parse or validate is a + * real defect, not an absence, so it is surfaced as a warning on `id`'s row + * rather than silently counted as no artifacts. + */ +function hasDeclaredArtifact( + dir: string, + schema: string, + base: string, + id: string, + warnings: ReadWarning[], +): boolean { + if (schemaDir(schema, base) === undefined) return false + let artifacts: { generates: string }[] + try { + artifacts = loadSchema(schema, base) + } catch (err) { + warnings.push({ + code: 'schema_unreadable', + message: `${id}: ${err instanceof Error ? err.message : String(err)}; its artifacts are counted as none`, + }) + return false + } + try { + return artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates)) + } catch { + // upstream's bare `catch` on an output it cannot resolve (one leaving + // the change, a linked directory cycle): no signal. + return false + } +} + function computeRow( base: string, id: string, @@ -181,14 +223,30 @@ function computeRow( ): Row { const dir = join(changesDir(base), id) const finding = findNestedChangesIn(changesDir(base), id) - const schema = readOpenspecYaml(dir)?.schema ?? '' + // A change with no `.openspec.yaml` of its own takes its schema the way + // `cospec status`'s `gradedChange` and `core/change.ts`'s `hasSchemaOutput` + // do — the project's `config.yaml` `schema:`, else `spec-driven` — so a + // custom-named artifact under that fallback schema is never reported as no + // artifacts at all, and the row's type/completeness agree with `status`. + // A namespace folder is not a change at all (`state` below reports it as + // such), so it never takes this fallback — `status --all` discards its + // `gradedChange`-resolved schema the same way, reporting it as a failure + // entry with no `type` field rather than the project's default schema. + const bare = finding === undefined && !existsSync(join(dir, '.openspec.yaml')) + const schema = bare ? defaultProjectSchema(base) : (readOpenspecYaml(dir)?.schema ?? '') const blockersPath = join(dir, 'blocking-changes.md') const gate = existsSync(blockersPath) ? computeGate(parseBlockers(readFileSync(blockersPath, 'utf8')), archived, active) : ({ state: 'clear', hard: [], soft: [] } satisfies Gate) - const empty = !hasAnyArtifact(dir) const cospec = isCospecType(schema) + // cospec's fixed artifact filenames are the only signal for a cospec-typed + // change; a schema cospec doesn't type additionally gets its own declared + // schema's `generates` signal, so a custom-named artifact cospec doesn't + // recognize by filename is never reported as no artifacts at all (the + // misclassification task 11.5 fixed for `status`'s `state`/`next`). + const empty = + !hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base, id, warnings)) const parsedTasks = readChangeTasks(dir, warnings) const total = parsedTasks.items.length diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 81f39fb4..2c794dc6 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -19,11 +19,11 @@ import { import { archiveDir, changesDir, + defaultProjectSchema, describeNestedChange, findNestedChangesIn, isCospecType, listChanges, - projectConfigSchema, resolveChange, type Change, } from '../core/change.ts' @@ -155,7 +155,19 @@ export interface TasksWarning { message: string } -export type ReadWarning = ArchiveWarning | TasksWarning +/** + * The warning for a change whose own declared schema resolves to a real + * schema directory but fails to load (read, parse or validate) — `list`'s + * `hasDeclaredArtifact`. A schema name that resolves to no directory at all + * gives no signal and no warning, matching the binary's own `list`, which + * never loads a schema. + */ +export interface SchemaWarning { + code: 'schema_unreadable' + message: string +} + +export type ReadWarning = ArchiveWarning | TasksWarning | SchemaWarning const NO_TASKS: ParsedTasks = { items: [], malformed: [], groups: [] } @@ -369,7 +381,7 @@ export interface ChangeEntryFailure { */ function gradedChange(base: string, change: Change, override: string | undefined): Change { const bare = !existsSync(join(change.dir, '.openspec.yaml')) - const schema = override ?? (bare ? (projectConfigSchema(base) ?? 'spec-driven') : change.schema) + const schema = override ?? (bare ? defaultProjectSchema(base) : change.schema) return bare ? { ...change, schema, schemaVersion: 1 } : { ...change, schema } } diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index 0771f15a..ce16fcfb 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1143,6 +1143,21 @@ async function validateForcedSpec(root: Root, id: string, strict: boolean): Prom /** The first openspec release whose `validate` takes `--archived`. */ const ARCHIVED_SINCE = '1.9.0' +/** + * `validate --archived`'s refusal when the wrapped OpenSpec is below + * `ARCHIVED_SINCE`, or `undefined` when it isn't. A pure function of the + * version string (never spawns), so it is unit-testable without a fake + * binary: the command's own `--json`/text branch (`rootSelectionDocument` or + * `cospec: `) matches every other early-exit refusal in this file. + */ +export function archivedUnsupportedRefusal(version: string): RootSelectionError | undefined { + if (!openspecBelow(version, ARCHIVED_SINCE)) return undefined + return new RootSelectionError({ + code: 'openspec_version_too_old', + message: `validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ${version}`, + }) +} + /** * The binary's answer to `validate --archived`: its report's items, or its * failure document (an unreadable `changes/archive/`, say) with its exit code. @@ -1482,16 +1497,26 @@ const NO_OPENSPEC_ROOT = new RootSelectionError({ fix: respellRemedies('Run openspec init to create a root here.'), }) +/** + * Test-only override for the wrapped binary's version read — lets a unit test + * drive `--archived`'s version-floor refusal (`archivedUnsupportedRefusal`) + * without a fake binary, since `wrappedOpenspecVersion` memoizes its result + * once per process. Production callers omit it and get the real read. + */ +export interface ValidateDeps { + wrappedOpenspecVersion?: () => Promise +} + /** * `cospec validate`: an errno failure it lets escape (an unreadable * `openspec/changes/` or `openspec/specs/`) is the binary's one * `validate_error` document under `--json`. */ -export function run(ctx: CommandContext): Promise { - return answeringErrno(ctx.flags.json, { code: 'validate_error' }, () => validate(ctx)) +export function run(ctx: CommandContext, deps: ValidateDeps = {}): Promise { + return answeringErrno(ctx.flags.json, { code: 'validate_error' }, () => validate(ctx, deps)) } -async function validate(ctx: CommandContext): Promise { +async function validate(ctx: CommandContext, deps: ValidateDeps): Promise { const { flags } = ctx const parsed = ctx.parsed! const strict = hasFlag(parsed, '--strict') @@ -1551,12 +1576,11 @@ async function validate(ctx: CommandContext): Promise { // changes/archive/, which active-change discovery deliberately excludes, and // it must never quietly alter an ordinary invocation. if (wantArchived) { - const version = await wrappedOpenspecVersion() - if (openspecBelow(version, ARCHIVED_SINCE)) { - process.stderr.write( - `cospec: validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ` + - `${version}\n`, - ) + const version = await (deps.wrappedOpenspecVersion ?? wrappedOpenspecVersion)() + const refusal = archivedUnsupportedRefusal(version) + if (refusal !== undefined) { + if (flags.json) process.stdout.write(rootSelectionDocument(refusal)) + else process.stderr.write(`cospec: ${refusal.diagnostic.message}\n`) return 1 } const archived = await validateArchived(root) diff --git a/apps/cli/src/core/change.ts b/apps/cli/src/core/change.ts index 82299975..44c56ec1 100644 --- a/apps/cli/src/core/change.ts +++ b/apps/cli/src/core/change.ts @@ -366,6 +366,17 @@ export function projectConfigSchema(base: string): string | undefined { return typeof schema === 'string' && schema.length > 0 ? schema : undefined } +/** + * The schema a change with no (or unusable) `.openspec.yaml` of its own + * resolves to: the project's `config.yaml` `schema:`, else `spec-driven` — + * upstream's own default-schema fallback. Shared by `hasSchemaOutput` below, + * `cospec status`'s `gradedChange` and `cospec list`'s row computation, so the + * three never drift apart on what a bare change's type is. + */ +export function defaultProjectSchema(base: string): string { + return projectConfigSchema(base) ?? 'spec-driven' +} + /** * upstream's `hasSchemaOutput`: `dir` holds a file where the schema it resolves * to (its `.openspec.yaml`, else the root's `config.yaml`, else `spec-driven`) @@ -375,7 +386,7 @@ function hasSchemaOutput(dir: string, projectRoot: string): boolean { // A candidate reaching here has no regular `.openspec.yaml`; anything else at // that path fails upstream's metadata read, which gives no signal. if (existsSync(join(dir, '.openspec.yaml'))) return false - const name = projectConfigSchema(projectRoot) ?? 'spec-driven' + const name = defaultProjectSchema(projectRoot) let artifacts: { generates: string }[] try { artifacts = loadSchema(name, projectRoot) diff --git a/apps/cli/test/contract/cli-surface.test.ts b/apps/cli/test/contract/cli-surface.test.ts index bfd90838..b1a9c684 100644 --- a/apps/cli/test/contract/cli-surface.test.ts +++ b/apps/cli/test/contract/cli-surface.test.ts @@ -2768,6 +2768,101 @@ describe('17. round-4 review rows', () => { }) }) +// --- 18. list-status-untyped-leftovers ------------------------------------------------------ + +describe('18. list-status-untyped-leftovers', () => { + test("18.1 list reports building for an untyped schema's own declared artifact", async () => { + const root = cospecRoot() + rfcSchema(root) + writeChange(root, 'r-doc', { 'doc.md': '# RFC\n' }, 'rfc') + writeChange(root, 'r-empty', {}, 'rfc') + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + expect(cs.exitCode).toBe(up.exitCode) + const row = rowsOf(cs.json).find((r) => r.change === 'r-doc')! + expect(row.state).toBe('building') + expect(row.archiveReady).toBe(false) + const empty = rowsOf(cs.json).find((r) => r.change === 'r-empty')! + expect(empty.state).toBe('in-progress') + // cospec's native `state` and the binary's own task-count-only `status` + // coexist on the same row without a key collision. + const upRow = rowsOf(up.json).find((r) => r.name === 'r-doc')! + expect(upRow.status).toBe('no-tasks') + expect(row.status).toBe('no-tasks') + // `--sort` is irrelevant here (recency order is non-deterministic across + // filesystems); find r-doc's own line, wherever the table put it. + const text = await ours(['list'], root) + const line = text.stdout.split('\n').find((l) => l.includes('r-doc'))! + expect(line).toMatch(/^\s*r-doc\s+rfc\s+clear\s+0\/0 tasks\s*$/) + }) + + unlessRoot('mode 000', () => { + test('18.2 status: a mode-000 artifact other than tasks.md already answers as the binary does', async () => { + const root = listFixture() + const proposal = join(root, 'openspec/changes/alpha/proposal.md') + const restore = lock(proposal) + try { + for (const argv of [ + ['status', '--change', 'alpha', '--json'], + ['status', '--all', '--json'], + ]) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + captureStatus(`18.2 ${argv.join(' ')}`, cs) + // Ground truth is the measured binary answer, never a prediction: + // --change and --all can disagree on exit code for a reason + // unrelated to the mode-000 lock itself. `--all`'s sweep also walks + // `mobile`, the fixture's own namespace folder, which the binary + // reports as its own `change_error` ("is not a change") whether or + // not alpha's `proposal.md` is locked — confirmed on Linux/Bun, + // where the lock itself is read past in both modes (its `realpath` + // needs no read permission there) and only `mobile` drives --all's + // exit 1; on macOS/Bun the lock also refuses `--change alpha` on its + // own. Each invocation's own exit code and refusal shape decide the + // branch below, so this fixture quirk never has to be modeled. + if (argv[1] === '--all') { + const mobileEntry = rowsOf(up.json).find((e) => e.changeName === 'mobile') + expect(Array.isArray(mobileEntry?.status)).toBe(true) + } + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + const textArgv = argv.filter((a) => a !== '--json') + const upText = await upstream(textArgv, root) + const text = await ours(textArgv, root) + captureStatus(`18.2 ${textArgv.join(' ')}`, text) + expect({ textArgv, exit: text.exitCode }).toEqual({ textArgv, exit: upText.exitCode }) + + const upEntry = + argv[1] === '--all' + ? rowsOf(up.json).find((e) => e.changeName === 'alpha')! + : (up.json as Row) + const refusedHere = Array.isArray(upEntry.status) + if (refusedHere) { + expect(up.exitCode).toBe(1) + const d = firstStatus(upEntry) + expect(errnoShape(d.message)).toMatchObject({ + code: 'EACCES', + path: join(realpathSync(dirname(proposal)), 'proposal.md'), + }) + continue + } + // The binary read past it and counts `proposal` done; so does cospec. + const upDone = + (upEntry.artifacts as Row[]).find((a) => a.id === 'proposal')!.status === 'done' + const csEntry = + argv[1] === '--all' + ? rowsOf(cs.json).find((e) => e.change === 'alpha')! + : (cs.json as Row) + const csDone = (csEntry.artifacts as Row[]).find((a) => a.id === 'proposal')!.done + expect(csDone).toBe(upDone) + expect(csDone).toBe(true) + } + } finally { + restore() + } + }) + }) +}) + // --- 5.6 no status output names a bare openspec command ------------------------------------ describe('5.6 status outputs', () => { diff --git a/apps/cli/test/contract/upstream-spellings.test.ts b/apps/cli/test/contract/upstream-spellings.test.ts index 5f3f7b70..1bde8a6e 100644 --- a/apps/cli/test/contract/upstream-spellings.test.ts +++ b/apps/cli/test/contract/upstream-spellings.test.ts @@ -991,8 +991,15 @@ describe('3.7 an instructions failure is the binary answer, rendered from its do for (const asJson of [false, true]) { const full = [...argv, ...(asJson ? ['--json'] : [])] test(`${full.join(' ')}: the listed change names are the binary's bytes`, async () => { - const c = await runCospec(full, remedyNamedRoot()) - const u = await runUpstream(full, remedyNamedRoot()) + // One shared root for both calls: the binary's own directory listing + // (dist's getAvailableChanges) is unsorted and cospec relays it + // verbatim, so two independently-created copies can land on different + // on-disk entry orders (overlayfs) even though neither side sorts — + // sharing the root removes that dependency instead of asserting an + // order either side doesn't guarantee. + const root = remedyNamedRoot() + const c = await runCospec(full, root) + const u = await runUpstream(full, root) expect(u.exitCode, detail('openspec', u)).toBe(1) for (const name of REMEDY_SHAPED_CHANGES) expect(asJson ? statusMessage(json(u)) : u.stderr).toContain(`\n ${name}`) @@ -1010,8 +1017,9 @@ describe('3.7 an instructions failure is the binary answer, rendered from its do for (const asJson of [false, true]) { const argv = ['instructions', 'proposal', '--change', name, ...(asJson ? ['--json'] : [])] test(`a listed name copied back resolves: ${JSON.stringify(argv)}`, async () => { - const c = await runCospec(argv, remedyNamedRoot()) - const u = await runUpstream(argv, remedyNamedRoot()) + const root = remedyNamedRoot() + const c = await runCospec(argv, root) + const u = await runUpstream(argv, root) expect(u.exitCode, detail('openspec', u)).toBe(0) expect(c.exitCode, detail('cospec', c)).toBe(0) if (asJson) expect(json(c)['changeName']).toBe(name) diff --git a/apps/cli/test/unit/commands/commands.test.ts b/apps/cli/test/unit/commands/commands.test.ts index 2de11624..c09703a2 100644 --- a/apps/cli/test/unit/commands/commands.test.ts +++ b/apps/cli/test/unit/commands/commands.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, test } from 'bun:test' import { cpSync, + existsSync, mkdirSync, mkdtempSync, realpathSync, @@ -664,6 +665,132 @@ describe('list', () => { expect(r.out).toContain('archive-ready') }) + test("an untyped schema's own declared artifact decides its state, not cospec's fixed filenames", async () => { + const cwd = repo() + // A schema cospec doesn't type, whose artifact lives under a filename + // none of cospec's own (proposal.md, tasks.md, ...) match. + mkdirSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates'), { recursive: true }) + writeFileSync( + join(cwd, 'openspec', 'schemas', 'rfc', 'schema.yaml'), + [ + 'name: rfc', + 'version: 1', + 'description: An rfc-style schema', + 'artifacts:', + ' - id: doc', + ' generates: doc.md', + ' description: The RFC document', + ' template: doc.md', + ' instruction: Write the RFC.', + ' requires: []', + 'apply:', + ' requires: [doc]', + ' tracks: null', + '', + ].join('\n'), + ) + writeFileSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates', 'doc.md'), '# Doc\n') + writeChange(cwd, 'r-doc', 'rfc', { 'doc.md': '# RFC\n' }) + writeChange(cwd, 'r-empty', 'rfc') + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { changes: { change: string; state: string }[] } + expect(parsed.changes.find((c) => c.change === 'r-doc')?.state).toBe('building') + expect(parsed.changes.find((c) => c.change === 'r-empty')?.state).toBe('in-progress') + + const text = await runCmd(listRun, ctx(cwd, [], { command: 'list' })) + expect(text.out).toMatch(/r-doc\s+rfc/) + expect(text.out).not.toMatch(/r-doc\s+rfc\s+clear\s+no artifacts yet/) + }) + + test("a change with no .openspec.yaml takes config.yaml's schema, same as status, not '(none)'", async () => { + const cwd = repo() + // config.yaml's default schema is the untyped 'rfc', so a bare change dir + // (no .openspec.yaml of its own) resolves to 'rfc', not '(none)'. + mkdirSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates'), { recursive: true }) + writeFileSync( + join(cwd, 'openspec', 'schemas', 'rfc', 'schema.yaml'), + [ + 'name: rfc', + 'version: 1', + 'description: An rfc-style schema', + 'artifacts:', + ' - id: doc', + ' generates: doc.md', + ' description: The RFC document', + ' template: doc.md', + ' instruction: Write the RFC.', + ' requires: []', + 'apply:', + ' requires: [doc]', + ' tracks: null', + '', + ].join('\n'), + ) + writeFileSync(join(cwd, 'openspec', 'schemas', 'rfc', 'templates', 'doc.md'), '# Doc\n') + writeFileSync(join(cwd, 'openspec', 'config.yaml'), 'schema: rfc\n') + // No `writeChange` here on purpose: the whole point is a change directory + // with no `.openspec.yaml` of its own. + const bareDir = join(cwd, 'openspec', 'changes', 'b-noyaml') + mkdirSync(bareDir, { recursive: true }) + writeFileSync(join(bareDir, 'doc.md'), '# RFC\n') + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { + changes: { change: string; type: string; state: string }[] + } + const row = parsed.changes.find((c) => c.change === 'b-noyaml') + expect(row?.type).toBe('rfc') + expect(row?.state).toBe('building') + }) + + test("a namespace folder never takes config.yaml's schema fallback (it is not a change)", async () => { + const cwd = repo('feat') + // A namespace folder (design D2): no `.openspec.yaml` of its own, only a + // nested child one level down. It also has no `.openspec.yaml`, so the + // bare-schema fallback must not mistake it for an ordinary bare change — + // it is reported as `not-a-change`, never typed by config.yaml's default. + const nested = join(cwd, 'openspec', 'changes', 'mobile', 'refresh-token') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, '.openspec.yaml'), 'schema: feat\ncreated: 2026-09-01\n') + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { + changes: { change: string; type: string; state: string }[] + } + const row = parsed.changes.find((c) => c.change === 'mobile') + expect(row?.state).toBe('not-a-change') + expect(row?.type).toBe('(none)') + }) + + test('a declared schema that fails to load warns on the row instead of silently reporting empty', async () => { + const cwd = repo() + mkdirSync(join(cwd, 'openspec', 'schemas', 'broken'), { recursive: true }) + // Deliberately unparsable: an unterminated flow mapping. + writeFileSync(join(cwd, 'openspec', 'schemas', 'broken', 'schema.yaml'), 'name: [broken\n') + const dir = writeChange(cwd, 'b-broken', 'broken', { 'doc.md': '# Doc\n' }) + // The schema itself resolves (the directory exists) but fails to parse, so + // this must warn, not silently swallow the failure as "no such schema". + expect(existsSync(join(dir, 'doc.md'))).toBe(true) + + const r = await runCmd(listRun, ctx(cwd, [], { json: true, command: 'list' })) + expect(r.code).toBe(0) + const parsed = JSON.parse(r.out) as { + changes: { change: string; state: string }[] + warnings?: { code: string; message: string }[] + } + const row = parsed.changes.find((c) => c.change === 'b-broken') + expect(row?.state).toBe('in-progress') + const warning = parsed.warnings?.find((w) => w.code === 'schema_unreadable') + expect(warning?.message).toContain('b-broken') + + const text = await runCmd(listRun, ctx(cwd, [], { command: 'list' })) + expect(text.err).toContain('b-broken') + }) + test('--blocked filters to gated changes', async () => { const cwd = repo() writeChange(cwd, 'clear-one', 'ci', { diff --git a/apps/cli/test/unit/commands/validate.test.ts b/apps/cli/test/unit/commands/validate.test.ts index 3ad9b169..85ac38ed 100644 --- a/apps/cli/test/unit/commands/validate.test.ts +++ b/apps/cli/test/unit/commands/validate.test.ts @@ -1,15 +1,20 @@ -import { describe, expect, test } from 'bun:test' +import { afterAll, describe, expect, test } from 'bun:test' +import { rmSync } from 'node:fs' import { + archivedUnsupportedRefusal, erroredChange, concurrencyBound, mapPool, mergeDelegated, + run as validateRun, TARGET_INVALID, TARGET_INVALID_HEAD, TARGET_INVALID_LINE, } from '../../../src/commands/validate.ts' +import { rootSelectionDocument } from '../../../src/core/root.ts' import type { Issue } from '../../../src/core/rules/issue.ts' +import { ctx, makeRepo, runCmd } from './helpers.ts' // mergeDelegated's DUPLICATE_CLASSES table drops a delegated (openspec/validate) // issue only when a cospec-native issue already reported the same defect. The @@ -374,3 +379,86 @@ describe('a change whose validation throws (verification 16.3)', () => { expect(() => erroredChange('/r', 'c1', error)).toThrow(error) }) }) + +describe("archivedUnsupportedRefusal: validate --archived's version-floor guard", () => { + test('no refusal at or above the floor', () => { + expect(archivedUnsupportedRefusal('1.9.0')).toBeUndefined() + expect(archivedUnsupportedRefusal('1.13.1')).toBeUndefined() + }) + + test('a document under --json below the floor, not stderr text', () => { + const refusal = archivedUnsupportedRefusal('1.8.0') + expect(refusal).toBeDefined() + expect(refusal!.diagnostic.message).toBe( + 'validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0', + ) + // The command's --json branch calls rootSelectionDocument(refusal), exactly + // as its sibling no-root guard does: one parseable JSON document, never the + // unconditional stderr text the pre-fix guard wrote regardless of --json. + const doc = JSON.parse(rootSelectionDocument(refusal!)) as { + status: { severity: string; code: string; message: string }[] + } + expect(doc.status).toEqual([ + { + severity: 'error', + code: 'openspec_version_too_old', + message: 'validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0', + }, + ]) + }) + + test('an unparseable version is never judged too old (drift allowed)', () => { + expect(archivedUnsupportedRefusal('not-a-version')).toBeUndefined() + }) +}) + +describe("validate --archived: the command's own refusal branch below the version floor", () => { + // `wrappedOpenspecVersion` memoizes its result once per process, so the real + // wrapped binary (always >= ARCHIVED_SINCE in dev/CI) can never drive this + // branch through the command. `run`'s injectable `deps.wrappedOpenspecVersion` + // is the seam: reverting the command's refusal write (back to an + // unconditional stderr line, the pre-fix behaviour) fails these. + const roots: string[] = [] + afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }) + }) + const repo = (): string => { + const dir = makeRepo() + roots.push(dir) + return dir + } + const oldVersion = { wrappedOpenspecVersion: () => Promise.resolve('1.8.0') } + + test('--json: exactly one parseable refusal document on stdout, nothing on stderr', async () => { + const cwd = repo() + const r = await runCmd( + (c) => validateRun(c, oldVersion), + ctx(cwd, ['--archived'], { json: true, command: 'validate' }), + ) + expect(r.code).toBe(1) + expect(r.err).toBe('') + const doc = JSON.parse(r.out) as { + status: { severity: string; code: string; message: string }[] + } + expect(doc.status).toEqual([ + { + severity: 'error', + code: 'openspec_version_too_old', + message: 'validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0', + }, + ]) + }) + + test('text mode: the refusal on stderr, nothing on stdout', async () => { + const cwd = repo() + const r = await runCmd( + (c) => validateRun(c, oldVersion), + ctx(cwd, ['--archived'], { command: 'validate' }), + ) + expect(r.code).toBe(1) + expect(r.out).toBe('') + expect(r.err).toBe( + 'cospec: validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0\n', + ) + }) +}) diff --git a/apps/docs/reference/commands.md b/apps/docs/reference/commands.md index 26a184c2..ba43f9bd 100644 --- a/apps/docs/reference/commands.md +++ b/apps/docs/reference/commands.md @@ -111,7 +111,7 @@ the binary as the item name. | `cospec migrate ` | Opt-in: stamp a change created under an older `schemaVersion` to the current one, scaffolding a fully-deferred `verification.md` where the type requires it. Never runs automatically. Under `--json`, one document `{change, schemaVersion, migrated, verificationScaffolded}` on both paths — `migrated: false` when the change is already current. | — | [Verification](/concepts/verification) | | `cospec validate [name]` | Validate one or all changes and specs against cospec's rules. A name is resolved as OpenSpec resolves it: `--type` forces the kind; a name that is both a change and a living spec is refused (`ambiguous_item`) and one that is neither gets OpenSpec's nearest matches (`unknown_item`); a bulk flag beside a name runs the bulk scope and ignores the name. `--report findings` prints only the items with findings (the exit code is still the full report's); `--concurrency` bounds the change validations run at once. `--json` carries OpenSpec's `root`, `items[].durationMs` and `summary.totals`/`byType` beside cospec's keys, `version` stays `1`, and an item's `type` stays the change's schema while `kind` carries OpenSpec's `change`/`spec` — see [Validation rules](/reference/validation-rules#output-shape). An unreadable artifact — a change file, the living `spec.md` a delta targets, or a living `spec.md` itself — is a `meta/unreadable-artifact` ERROR (a directory no artifact lives in, a dot-directory or one outside `specs/`, is passed by, as OpenSpec passes it by), a namespace folder a `meta/nested-change` ERROR, and a relayed OpenSpec message names `cospec`, never bare `openspec`. OpenSpec's own validation of an item is asked for by kind (`--type change\|spec`), so a change sharing a living spec's name is still validated as a change; when OpenSpec refuses an item instead of reporting it, its refusal is that item's `openspec/validate` ERROR, never an empty pass. `--strict` fails a spec with a warning in `valid` and `summary.totals`, as OpenSpec does. `--type spec` on a spec discovery skips (a dot-directory, a capability behind a linked directory) validates that file as OpenSpec does. An unreadable `openspec/changes/archive/` validates as if nothing were archived, with a warning (`archive_unreadable` in the document's `warnings`, `Warning:` on stderr). With no `openspec/` directory a name alone is resolved as OpenSpec resolves it and, matching nothing, is `unknown_item`; any other `--json` invocation there is OpenSpec's one `no_openspec_root` document, exit `1`. An unreadable `openspec/changes/`, `openspec/specs/` or capability directory is one `validate_error` document under `--json`; `--archived` relays OpenSpec's own failure document (or its message in text) with its exit code. **BREAKING:** `validate --all\|--changes\|--specs` validates the bulk scope, not the one item; an ambiguous name is refused and an unknown one prints OpenSpec's message. | `--strict` (promote warnings to errors), `--all`, `--changes`, `--specs`, `--archived`, `--type `, `--report `, `--concurrency ` (else `OPENSPEC_CONCURRENCY`, else 6), `--fast`, `--no-interactive` | [Validation rules](/reference/validation-rules) | | `cospec status --change ` | Per-artifact completion, the blocker gate state, and archive-readiness for one change; `--all` sweeps every active change instead of one. Every entry names its next step — `next` under `--json`, a `Next:` line in text: the first ready artifact the change requires, else `cospec apply ` once every required one is done, else the first ready optional one. `--json` also carries every key OpenSpec's own `status --json` does (`changeName`, `schemaName`, `planningHome`, `changeRoot`, `artifactPaths`, `isPlanningComplete`, `isComplete`, `applyRequires`, `nextSteps` spelled `cospec`, `actionContext`, `root`, and each artifact's `outputPath`/`status`/`requires`), from one delegated call. `--schema ` is OpenSpec's schema override, not a filter: every change is reported as that schema, and an unknown name is refused with OpenSpec's `Schema '' not found` before the sweep enumerates or the named change is reported. A change whose schema isn't a cospec type (a fork, `spec-driven`, or a name that resolves nowhere) is answered from OpenSpec's own status document, rendered as OpenSpec renders it in text, with OpenSpec's exit code. A change is looked up as OpenSpec looks it up: a directory under `openspec/changes/` (a regular file of that name is no change) whose name OpenSpec accepts — no path separator, no leading dot, not `archive` — kebab-case or not. A change directory with no `.openspec.yaml` takes the root's `config.yaml` `schema:` (else `spec-driven`) at `schemaVersion` 1. A cospec-typed change with no artifacts yet is `state: in-progress` with `artifacts: []`, never filled with OpenSpec's artifact objects. A namespace folder is refused (`--change`) or a failure entry (`--all`), exit `1`. An unreadable `openspec/changes/archive/` computes the gate from an empty index with a warning (`archive_unreadable` under `--json`). A change OpenSpec refuses is refused: any error in OpenSpec's status for it is the answer — its `change_error` document under `--json`, its message in text, a failure entry under `--all` — and text mode asks OpenSpec too for a change cospec can't read every entry of, whose `.openspec.yaml` OpenSpec refuses (unreadable, not YAML, naming a schema OpenSpec doesn't list, or failing OpenSpec's metadata schema: a `created` that isn't `YYYY-MM-DD`, an empty `goal`, a non-boolean `skip_specs` or `retire_capabilities`, an `affected_areas` that isn't a list of non-empty strings, an `initiative` that isn't exactly `{store, id}` in kebab-case), or whose schema OpenSpec can't load (missing, unreadable, unparsable or invalid). So a cospec-typed change whose schema was removed from `openspec/schemas/` is refused with OpenSpec's `Unknown schema` message in text as under `--json`, one whose `created` is malformed with OpenSpec's `Invalid metadata` message, and an unreadable change directory is refused, as is, under Bun on macOS, an unreadable file in it; elsewhere OpenSpec reads past the file, and an unreadable `tasks.md` is counted as no tasks with a warning (`tasks_unreadable`). Any other read failure is a `change_error` document, an unreadable `openspec/changes/` included (`{changes: [], root: null, status}` under `--all`). Every OpenSpec message status relays, in text or in `status[]`, is spelled `cospec`. **BREAKING:** `root` is OpenSpec's `{path, source}` object, not a path string; a namespace folder makes `status` exit `1`; `--json` on a schema cospec doesn't type exits `1` when OpenSpec does; a cospec-typed change whose schema OpenSpec can't load, or whose `.openspec.yaml` OpenSpec refuses, exits `1`, in text and `--json`; a directory without `.openspec.yaml` is typed by `config.yaml`. | `--change `, `--all`, `--schema ` | [Apply and archive](/concepts/apply-and-archive) | -| `cospec list` | List active changes with type, gate state, task progress, and archive-readiness columns, in OpenSpec's order and membership: most recently modified first, or by name with `--sort name` (any other value is the default, as in OpenSpec). `--json` rows also carry OpenSpec's `name`, `completedTasks`, `totalTasks`, `lastModified`, `status` and `nested`, and the document its `warnings` and `root`, from one delegated call. A namespace folder's row reads `not a change` (state `not-a-change`) with OpenSpec's `Warning:` after the table. An unreadable `openspec/changes/archive/` lists normally with a warning (`archive_unreadable`); a read failure OpenSpec refuses is OpenSpec's `list_error` answer; an unreadable `tasks.md` OpenSpec lists past counts as no tasks with a warning (`tasks_unreadable`); an unreadable `blocking-changes.md` fails only its row (`error`), exit `1`. `--specs` instead lists living specs by requirement count (`--json` carries `root`); a failure OpenSpec reports there is relayed — its document under `--json`, `cospec: ` and its `Fix:` line in text — exit `1`. **BREAKING:** the default order is most recent first — pass `--sort name` for the old order; outside an OpenSpec root `list` answers OpenSpec's own `no_openspec_root` refusal (its message and `Fix:` line, or its document under `--json`), exit `1`, where it printed `No active changes.` | `--blocked` (only changes with a non-clear gate), `--specs`, `--sort ` | [Apply and archive](/concepts/apply-and-archive) | +| `cospec list` | List active changes with type, gate state, task progress, and archive-readiness columns, in OpenSpec's order and membership: most recently modified first, or by name with `--sort name` (any other value is the default, as in OpenSpec). `--json` rows also carry OpenSpec's `name`, `completedTasks`, `totalTasks`, `lastModified`, `status` and `nested`, and the document its `warnings` and `root`, from one delegated call. A namespace folder's row reads `not a change` (state `not-a-change`) with OpenSpec's `Warning:` after the table. A cospec-typed change's `state` (`in-progress`/`building`) is cospec's own fixed artifact filenames; a change on a schema cospec doesn't type additionally checks that schema's own `generates` pattern against the change directory, so a custom-named artifact cospec doesn't recognize by filename still reads `building`, not forced `in-progress`. A change directory with no `.openspec.yaml` takes the root's `config.yaml` `schema:` (else `spec-driven`) for this, the same fallback `status` and OpenSpec's own `hasSchemaOutput` use, so its row's type and completeness agree with `status`'s; a declared schema that resolves to a real schema directory but fails to read, parse or validate warns (`schema_unreadable`) rather than silently reporting the row as empty. An unreadable `openspec/changes/archive/` lists normally with a warning (`archive_unreadable`); a read failure OpenSpec refuses is OpenSpec's `list_error` answer; an unreadable `tasks.md` OpenSpec lists past counts as no tasks with a warning (`tasks_unreadable`); an unreadable `blocking-changes.md` fails only its row (`error`), exit `1`. `--specs` instead lists living specs by requirement count (`--json` carries `root`); a failure OpenSpec reports there is relayed — its document under `--json`, `cospec: ` and its `Fix:` line in text — exit `1`. **BREAKING:** the default order is most recent first — pass `--sort name` for the old order; outside an OpenSpec root `list` answers OpenSpec's own `no_openspec_root` refusal (its message and `Fix:` line, or its document under `--json`), exit `1`, where it printed `No active changes.` | `--blocked` (only changes with a non-clear gate), `--specs`, `--sort ` | [Apply and archive](/concepts/apply-and-archive) | | `cospec instructions [artifact] --change ` | Print the authoring instructions for one artifact of a change (e.g. `proposal`, `verification`, `tasks`, `archive`). `archive` is a read-only relay of the wrapped `openspec instructions archive`, not an alias for `cospec archive` (requires openspec >=1.7.0). `--schema ` forwards to the wrapped call; both `artifact` and `--change` are optional, as upstream declares them — with either missing, the wrapped binary answers instead of a cospec-side refusal (its `Available changes`/`Valid artifacts` message), so `--json` gets exactly one document on every path. `instructions apply --change ` is always `cospec apply ` — the gate, from any directory and for any slug, with `apply`'s own refusals (no `openspec/` tree, an unknown change) — never OpenSpec's ungated apply instructions. `--schema` is refused there, before the gate runs, exit `1` (`cospec instructions: '--schema' does not apply to 'apply' …` on stderr, or one `{status: [{severity, code: "schema_not_applicable", message}]}` document under `--json`): OpenSpec's `instructions apply --schema` answers from another schema's apply requirements, while the gate enforces the change's own. Every other artifact's answer is built from the wrapped binary's own `--json` document: only the commands OpenSpec writes into it itself are respelled to `cospec` — each referenced store's `Fetch:` recipe and `Fix:` remedy (`references[].fetch`, `references[].status[].fix`, rewritten only where the whole value is one of OpenSpec's own remedies) and, for a change on OpenSpec's built-in `spec-driven` schema as the package ships it (not a project or user copy), that schema's own lines naming a bare `openspec` command. Your template, context, rules, spec summaries, store ids and paths are exactly what OpenSpec prints; text mode is OpenSpec's instruction layout rendered from the rewritten document, byte-identical to OpenSpec's wherever nothing was respelled. Every failure — an unknown change, a missing artifact or `--change`, `apply` or `archive` without a change — is OpenSpec's own answer rendered from its `--json` document: only a message or fix that is wholly one of OpenSpec's remedies names `cospec` (`Create one with: cospec new `), and the change names it lists under `Available changes` are exactly your directory names, whatever they read like. | `--change `, `--schema `, `--allow-soft` | [Workflow](/guide/workflow) | | `cospec apply ` | The gate: check blockers and required artifacts before you implement. | `--allow-soft` (proceed past a soft block), `--skip-specs` (one-shot equivalent of a persisted `skip_specs: true` marker) | [Apply and archive](/concepts/apply-and-archive) | | `cospec archive ` | Validate, gate on tasks and verification, archive via OpenSpec, verify the move on disk, and fan out blocker sync. `--json` adds `warnings`/`retired` arrays (always present, `[]` when empty). | `--skip-specs`, `--force-incomplete` | [Apply and archive](/concepts/apply-and-archive) | diff --git a/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/.openspec.yaml b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/.openspec.yaml new file mode 100644 index 00000000..55b41241 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/.openspec.yaml @@ -0,0 +1,4 @@ +schema: fix +created: 2026-10-05 +schemaVersion: 2 +skip_specs: true diff --git a/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/blocking-changes.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/blocking-changes.md new file mode 100644 index 00000000..7a02b8c2 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/blocking-changes.md @@ -0,0 +1,16 @@ +# Dependencies + +## Blocked by + + + + + +None. + +## Soft-blocked by + + + + +None. diff --git a/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/design.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/design.md new file mode 100644 index 00000000..a49cb674 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/design.md @@ -0,0 +1,197 @@ +# Design + +## Context + +Four items, verified against `main` first +(`.claude/handoff/reports/list-status-untyped-leftovers-verify.md`): + +1. `list.ts`'s `computeRow` derives `empty` — and so `state` — from + `hasAnyArtifact(dir)`, which checks only cospec's own fixed artifact + filenames (`proposal.md`, `tasks.md`, `design.md`, `verification.md`, + `blocking-changes.md`, `specs/*.md`). It never branches on + `isCospecType(schema)` for this computation (the `cospec` bool it does + compute is used only for `archiveReady`). A change on a schema cospec doesn't + type — whose own `schema.yaml` names its artifacts under other filenames + (`doc.md`, `requirements.md`, …) — is therefore always "empty" to this check, + even with a written artifact on disk, the same misclassification `status.ts` + fixed at task 11.5 for `state`/`next` (`status.ts` now answers every + untyped-schema change from the binary's own status, never from + `hasAnyArtifact`). + +2. `validate.ts:1553-1561`'s `--archived` version-floor guard writes + unconditionally to `process.stderr`, never branching on `flags.json` — unlike + the no-root guard immediately above it (1541-1548), which does. + `validate --archived --json` against a binary below `ARCHIVED_SINCE` + therefore prints no JSON document at all. + +3. `upstream-spellings.test.ts` row 3.7 asserts byte-identical stdout/stderr + between a `cospec` and an `openspec` instructions call, each over its own + `remedyNamedRoot()` copy (two independent `mkdtemp` + `cpSync` calls). The + pinned binary's own `getAvailableChanges` + (`dist/commands/workflow/shared.js:77-90`) returns + `readdir(...).filter(...).map(e => e.name)` with no `.sort()` anywhere on + this path — it is unsorted by design, and `instructions.ts` never re-sorts or + re-derives the list cospec relays (grep confirms: no `sort` call in + `instructions.ts`/`instructions-render.ts`). The row's byte-identity + assertion is therefore only valid when both calls observe the same on-disk + entry order, which two independently-created copies don't guarantee on every + filesystem (overlayfs in particular). + +4. The stage's own verify report lists a fourth item — `status`'s handling of a + mode-000 artifact other than `tasks.md` — as unfixed, reasoning from a + primitive-level probe (`existsSync` true, `accessSync(R_OK)` throws EACCES) + and a read of `artifactDone` (`apply.ts:148-152`) in isolation. It did not + run `cospec status` end to end. `hasUnreadableEntry` (`status.ts:481-510`, + task 11.12) already walks every non-dot entry under the whole change + directory — not just `tasks.md` — `openSync`-ing every regular file; + `binaryDecides` (`status.ts:451-472`) routes a change to the binary's own + answer whenever `hasUnreadableEntry` finds one, in text mode as under + `--json`, for both `--change` and `--all`. End-to-end differential runs + against the pinned binary, both single-change and `--all`, both `--json` and + text, confirm it already matches: + + - **macOS** (host, this change's author environment): both the pinned binary + (spawned under Bun, as cospec's production wrapped calls run — + `BUN_BE_BUN=1`) and `cospec status` refuse a mode-000 `proposal.md` with + `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every + mode tried. + - **Linux** (`oven/bun:1.3.14`, non-root UID 1000, bind-mounted tree): both + the pinned binary and `cospec status` read past the same mode-000 + `proposal.md` — Linux's `realpath` doesn't require read permission on the + target, only search permission on its parent directories — reporting + `proposal` done, exit 0, in every mode tried. + + `hasUnreadableEntry`'s own check (`openSync(path, 'r')`) _does_ throw EACCES + on Linux too (unlike the binary's `realpath`), which is why `binaryDecides` + still routes the change to a delegated upstream call on Linux — but since + that delegated call itself succeeds there, `status` falls through to its own + `computeStatus`/`artifactDone` (`existsSync`), which agrees with the binary's + own exit-0/done answer. Both platforms' two independent checks (cospec's own + routing signal, and the delegated or local answer it falls back to) land on + the binary's actual answer by different paths, not by coincidence: + `binaryDecides` only ever _widens_ when to ask the binary, never narrows + cospec's own fallback below what the binary would say when asked. + +## Goals / Non-Goals + +**Goals:** + +- Fix items 1–3 with the smallest change that makes cospec's answer match the + binary's, reusing an existing pattern each time rather than inventing a new + one. +- Lock item 4's current, already-correct cross-platform behavior down with a + differential contract row, and record why the verify report's finding doesn't + hold up — no product change. + +**Non-Goals:** + +- Re-deriving or sorting the binary's `getAvailableChanges` list (item 3): the + binary itself gives no ordering guarantee here, so cospec has none to provide + either; the fix is the test sharing one root, not a new cospec behavior. +- Touching `hasUnreadableEntry`, `binaryDecides`, or `artifactDone` (item 4): no + divergence was found to fix. + +## Decisions + +**Item 1 — reuse `hasSchemaOutput`'s signal, scoped to the change's own declared +schema.** `core/change.ts`'s `hasSchemaOutput` already answers almost this +question — "does `dir` hold a file the resolved schema's `generates` names" — +for namespace-folder detection, resolving the schema from the _project's_ config +when the candidate has no `.openspec.yaml` of its own. `list.ts`'s case is +narrower and simpler: the change already declares its own schema in +`.openspec.yaml`, so no project-config fallback is needed. A new +`hasDeclaredArtifact(dir, schema, base)` in `list.ts` loads that schema +(`loadSchema`, already used by `status.ts`) and checks `artifactOutputExists` +(`core/glob.ts`, the binary's own `generates` glob semantics, task 11.3) over +each of its artifacts' `generates` patterns, mirroring `hasSchemaOutput`'s +two-`catch`-blocks structure (a schema that cannot be loaded, or an output +pattern that cannot be resolved, gives no signal — the binary's own `list` never +loads a schema at all, so it has no opinion either; confirmed by grep of +`dist/core/list.js`, which reads only `tasks.md`-derived progress counts, never +a schema file). + +`empty` becomes +`!hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base))` — +additive over today's check: a cospec-typed change's classification is +bit-for-bit unchanged (the `cospec ||` short-circuits), and an untyped-schema +change is "empty" only when _neither_ signal finds anything, so there is no +regression path for a change `hasAnyArtifact` already caught by cospec's fixed +names. + +**Rejected:** delegating to `openspec status --change --json` per +untyped-schema row to ask the binary directly, which `status.ts`'s own +`legacyChangeEntry` does for its richer, single-change answer. `list.ts`'s own +discipline (its file header, D6) is one delegated `openspec list --json` call +per invocation, never one per change; the binary's `list --json` row carries no +artifact-presence signal at all (`dist/core/list.js`: `name`, `completedTasks`, +`totalTasks`, `lastModified`, `status` — `status` is task-count-only: +`no-tasks | in-progress | complete`, not "has artifacts"), so there is nothing +there to merge in instead. + +**Bare directories unaffected.** A change directory with no `.openspec.yaml` +reports `schema: ''` (`readOpenspecYaml(dir)?.schema ?? ''`); `isCospecType('')` +is `false`, so it takes the new branch, but `loadSchema('', base)` throws +immediately (`schemaDir`'s empty-name guard) before any output check runs, so +`hasDeclaredArtifact` returns `false` and `empty` reduces to exactly today's +`!hasAnyArtifact(dir)` — unchanged, matching the proposal's scope (a change +whose `.openspec.yaml` _declares_ an untyped schema, not a bare directory with +none). + +**Item 2 — mirror the sibling guard's branch exactly.** The no-root guard four +lines above already shows the right shape +(`rootSelectionDocument`/`respellRemedies` under `--json`, stderr text +otherwise); the `--archived` version-floor guard gets the same `flags.json` +branch, reusing `rootSelectionDocument` with the same null payload shape +`validate --json` uses elsewhere for a resolver-stage refusal. + +**Item 3 — share one root, don't sort either side.** `copyOf(upstreamTemplate)` +currently runs twice independently in row 3.7. Calling it once and passing the +same directory to both the `runCospec` and `runUpstream` invocations removes the +two-independent-`cpSync` ordering dependency entirely, with `cospec`'s own relay +still exercised as a true passthrough (it does not write into the shared root; +confirmed by reading `instructions.ts`, which never writes files for an +instructions call). + +## Risks / Trade-offs + +- [Item 1: a schema whose `generates` pattern is broad (e.g. `**/*.md`) could + flag a change "building" from an incidental file, such as a stray README + someone dropped in the change directory] → Same risk the binary itself accepts + for its own `hasSchemaOutput`/`artifactOutputExists` (`looksLikeChange`): + cospec is matching the binary's own generosity here, not inventing a new one, + and a schema author controls their own `generates` precision. +- [Item 4: a future binary version could change `realpath`'s Linux behavior, + reopening a real divergence] → The new contract row is differential (compares + live against the pinned binary on both OSes it runs in CI on), not a hardcoded + assertion of "exit 0"/"exit 1" per OS, so a future binary regression would + fail the row rather than passing silently. +- [Item 4 (found in CI, not locally): the first cut of row 18.2 predicted + `up.exitCode` from a single `realpathRefuses(proposal)` check shared across + both `--change` and `--all` — CI's `ubuntu-latest` runner showed `--all` + refusing where `--change` did not for the same mode-000 `proposal.md`] → + Rewrote the row to never predict a measured exit code: each invocation + (`--change`, `--all`) reads its own `up` answer's shape + (`Array.isArray(status)`) to decide the refused/not-refused branch + independently, exactly as the proven 15.11/15.12 tasks.md rows already do. + `cs.exitCode === up.exitCode` is the only cross-environment invariant + asserted; the row now passes however this binary version and this runner + happen to answer. The mechanism itself is confirmed, not a CI-only artifact: + the listed fixture's namespace folder (`mobile`, a folder wrapping a nested + change) makes the binary's own `--all` sweep report a `change_error` ("is not + a change") for `mobile` independent of any lock on `alpha`'s `proposal.md` — + reproduced directly against the pinned binary (both unlocked and locked, on + macOS and in an `oven/bun:1.3.14` container as non-root) — while + `--change alpha` only ever answers for `alpha`, never sweeping `mobile` at + all. Linux's own `realpath` also plays a part (it resolves a mode-000 file + without opening it, so a locked `proposal.md` is read past in every mode there + — only macOS/Bun's `realpath` opens the file and refuses it), but the + `--change` vs `--all` divergence specifically is `mobile`'s doing, not an OS- + or CI-runner-specific `realpath` quirk. +- [Item 1 (found in CI, not locally): the first cut of row 18.1's text assertion + anchored `r-doc`'s line with `\s+$`, relying on it being the last line of a + two-row table — `list`'s default order is recency (mtime), which is + environment-dependent, and CI's filesystem produced the opposite order from + this machine's, so the greedy `\s+$` silently matched across the newline into + the next row locally and failed once the order flipped] → Split `stdout` into + lines and matched `r-doc`'s own line directly, with an anchored, non-greedy + pattern — order-independent. diff --git a/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/proposal.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/proposal.md new file mode 100644 index 00000000..fd20a971 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/proposal.md @@ -0,0 +1,88 @@ +# Proposal + +## Why + +`cli-surface-parity` (#59) closed most of the `list`/`status`/`validate` surface +gaps, but its own later review rounds left a short tail of already-wrong output +that no later feature PR absorbs. Verified fresh against `main` +(`.claude/handoff/reports/list-status-untyped-leftovers-verify.md`): +`cospec list` misclassifies a change on a schema cospec doesn't type as "no +artifacts yet" whenever that schema names its artifacts under filenames cospec +doesn't recognize, even though the change plainly has one — the same class of +bug `status.ts` fixed at task 11.5 for its own `state`/`next` reporting, never +ported to `list.ts`'s row. Separately, `validate --archived --json` against an +OpenSpec binary below the version floor that supports `--archived` prints stderr +text unconditionally, so a `--json` caller gets no parseable document at all on +an old binary — the version guard immediately above it in the same file branches +on `flags.json` correctly; this one does not. A third item, +`upstream-spellings.test.ts` row 3.7, is flagged as a CI flake on overlayfs: two +independent `remedyNamedRoot()` copies are asserted byte-identical, which only +holds when both sides read the same directory. + +This proposal also closes out a fourth item the stage's own verify report listed +as unfixed — `status`'s handling of a mode-000 artifact other than `tasks.md` — +on the strength of further, end-to-end differential testing against the pinned +binary on both operating systems: it is not reproducible. `hasUnreadableEntry` +(task 11.12) already walks every non-dot file under a change directory, not just +`tasks.md`, so `status`'s existing `binaryDecides` routing already matches the +pinned binary's observed behaviour for any unreadable artifact, on macOS and on +Linux. This proposal adds a differential contract row that pins that behaviour +down (see Design) rather than changing product code that is already correct. + +## What Changes + +- `cospec list`'s `state` column no longer forces `in-progress` ("no artifacts + yet") for a change on a schema cospec doesn't type purely because none of + cospec's own fixed artifact filenames (`proposal.md`, `tasks.md`, …) are + present. It additionally checks whether the change's own declared schema's + `generates` pattern matches a file in the change directory — the same signal + `core/change.ts`'s `hasSchemaOutput` already uses for namespace detection — + and reports `building` when it does. A cospec-typed change's classification is + unchanged. +- `cospec validate --archived --json` against an OpenSpec binary below the + `--archived` version floor now relays its refusal as one JSON document (the + same `rootSelectionDocument` shape the sibling no-root guard already uses), + instead of unconditional stderr text, matching `--json` on every other + `validate` early-exit path. +- `upstream-spellings.test.ts` row 3.7 shares one `remedyNamedRoot()` directory + between its `cospec` and `openspec` instructions calls instead of two + independently-created copies, removing the row's dependency on two separate + `cpSync` calls landing on the same directory-entry order. Neither side sorts + the list (the pinned binary's own `getAvailableChanges` is unsorted and cospec + relays it verbatim), so sharing the root is the fix, not sorting either side. +- A new differential contract row pins `status`'s already-correct handling of a + mode-000 artifact other than `tasks.md` on both macOS and Linux (no product + code change for this item — see Design for the evidence superseding the + stage's verify report). + +## Capabilities + +### New Capabilities + +### Modified Capabilities + + + +## Impact + +- `apps/cli/src/commands/list.ts` — `computeRow`'s emptiness check. +- `apps/cli/src/commands/validate.ts` — the `--archived` version guard. +- `apps/cli/test/contract/upstream-spellings.test.ts` — row 3.7's fixture root. +- `apps/cli/test/unit/commands/commands.test.ts`, + `apps/cli/test/contract/cli-surface.test.ts` — new/updated test rows for all + four items. +- `apps/docs/reference/commands.md` — `cospec list`'s row documents the new + schema-output signal. + +## Surfaces + + + +- [ ] interactive — a user-visible/interactive surface (UI, TUI, CLI UX) +- [ ] deploy — deploy/runtime/CI-execution topology (infra, Dockerfile, workflow + runtime, secrets, bind address) +- [ ] integration — a third-party/external contract (SDK, OAuth, schema/id-type + reconciliation) +- [ ] agent-behavior — prompts, tools, model routing, or agent output shape diff --git a/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/tasks.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/tasks.md new file mode 100644 index 00000000..f3df1073 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/tasks.md @@ -0,0 +1,63 @@ +# Tasks + +## 1. `list.ts`: an untyped schema's own artifacts decide its state + +- [x] 1.1 Add a failing unit test (`commands.test.ts`'s `describe('list', …)`): + a change on a custom schema (`generates: doc.md`) with `doc.md` written + lists `state: 'building'`, not `in-progress`/"no artifacts yet" — red + against current `list.ts` and verify with `bun test`. +- [x] 1.2 Add `hasDeclaredArtifact(dir, schema, base)` to `list.ts` and wire it + into `computeRow`'s `empty` computation for a schema cospec doesn't type + (design: Decisions). Verify: 1.1's test goes green. Commit + `fix(cli): decide an untyped schema's list state from its own artifacts` +- [x] 1.3 Add a contract row to `cli-surface.test.ts` reusing the existing + `rfcSchema` fixture: `cospec list --json`'s row for a change with `doc.md` + written reports `state: 'building'`, `archiveReady: false` (an untyped + schema is never archive-ready), compared against the pinned binary's own + `status` key on the same row (`no-tasks`, since `rfc` has no `tasks.md`) + to confirm cospec's native `state` and the binary's own `status` key + coexist without collision. Verify: + `bun test apps/cli/test/contract/cli-surface.test.ts` passes. +- [x] 1.4 Update `apps/docs/reference/commands.md`'s `cospec list` row to + document the schema-output signal for an untyped schema's `state`. Verify: + `mise run docs:build` succeeds. + +## 2. `validate.ts`: `--archived --json` below the version floor + +- [x] 2.1 Add a failing unit test exercising the `--archived` version-floor + guard directly (stub `wrappedOpenspecVersion` below `ARCHIVED_SINCE`, as + the existing `openspecBelow` unit tests do) asserting `--json` prints one + parseable document, not stderr text — red against current `validate.ts`. +- [x] 2.2 Branch the guard on `flags.json` exactly as the no-root guard four + lines above it does, reusing `rootSelectionDocument`. Verify: 2.1's test + goes green. Commit + `fix(cli): relay validate --archived's version-floor refusal as JSON` + +## 3. `upstream-spellings.test.ts` row 3.7: one shared root + +- [x] 3.1 Change row 3.7 to call `remedyNamedRoot()` once and pass the same + directory to both the `runCospec` and `runUpstream` invocations, removing + the two-independent-copy ordering dependency (design: Decisions). Verify: + `bun test apps/cli/test/contract/upstream-spellings.test.ts -t '3.7'` + passes, and 20 repeated local runs show no flake. + +## 4. `status`: pin the mode-000-artifact behavior (no product change) + +- [x] 4.1 Add a differential contract row to `cli-surface.test.ts`: a mode-000 + artifact other than `tasks.md` (e.g. `proposal.md`) on a cospec-typed + change, `cospec status --change ` compared against the pinned binary + in both text and `--json` mode — same exit code, same reported + artifact-done state either way the runtime's `realpath` happens to answer + it (design: Context item 4; pattern after rows 15.11/15.12, branching on + the observed behavior, never on `process.platform`). Verify: + `bun test apps/cli/test/contract/cli-surface.test.ts` passes locally + (macOS) and in CI (Linux). +- [x] 4.2 Record in this change's verification ledger that both OS observations + were differential, with no product change (design: Context item 4 + documents the evidence superseding the stage's verify report). + +## 5. Close out + +- [x] 5.1 `mise run check` green (lint, format, typecheck, unit, contract, + integration, pack smoke). +- [x] 5.2 the archive commit follows this one diff --git a/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/verification.md b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/verification.md new file mode 100644 index 00000000..99ee5242 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-list-status-untyped-leftovers/verification.md @@ -0,0 +1,29 @@ +# Verification + +## 1. `list` reports `building` for an untyped schema's own artifact [critical] + +- [x] 1.1 @unit (agent) a change on a custom `rfc`-style schema (`generates: doc.md`) with `doc.md` written -> `computeRow`/`list.ts` reports `state: 'building'`, not `in-progress` — observed: `commands.test.ts` "an untyped schema's own declared artifact decides its state, not cospec's fixed filenames" passes +- [x] 1.2 @regression (agent) the same fixture against `list.ts` before the fix FAILS (`state: 'in-progress'`), after the fix PASSES (`state: 'building'`) -> red-then-green captured in the commit that lands 1.2 — observed: red with `Received: "in-progress"` against unmodified `list.ts`, green after `hasDeclaredArtifact` landed +- [x] 1.3 @integration (agent) `cospec list --json` vs the pinned binary's `openspec list --json` on the same `rfc`-schema fixture: no key collision between cospec's native `state` and the binary's own `status`, `archiveReady: false` -> `cli-surface.test.ts` passes — observed: `18.1 list reports building for an untyped schema's own declared artifact` passes, `row.status === upRow.status === 'no-tasks'` +- [x] 1.4 @e2e (agent) `mise run docs:build` after the `commands.md` edit -> build succeeds — observed: `build complete in 1.94s` + +## 2. `validate --archived --json` below the version floor prints a document + +- [x] 2.1 @unit (agent) `wrappedOpenspecVersion` stubbed below `ARCHIVED_SINCE`, `validate --archived --json` -> one parseable JSON document on stdout, not stderr text — observed: factored `archivedUnsupportedRefusal(version)` (pure, no spawn) unit-tested directly; `validate.test.ts` "archivedUnsupportedRefusal" passes, `rootSelectionDocument(refusal)` parses to one `status[]` document +- [x] 2.2 @regression (agent) the same unit test before the fix FAILS (stderr text, unparseable stdout), after the fix PASSES -> captured in the commit that lands 2.2 — observed: `archivedUnsupportedRefusal` did not exist before this commit (the guard wrote stderr text unconditionally inline); the test file would not compile against unmodified `validate.ts`, green once the export landed +- [x] 2.3 @regression (agent) a true command-level red/green, closing the gap 2.1/2.2 left (they only unit-test the pure helper, never the command's own `flags.json` branch, and every contract/integration row runs the real pinned binary, always >= `ARCHIVED_SINCE`, so that branch was never exercised by any test) -> `run`'s new injectable `deps.wrappedOpenspecVersion` drives `validate --archived --json`/text end to end with a stubbed `1.8.0`; reverting the command's refusal write to its pre-fix unconditional stderr line (manually, then restored) FAILS `validate.test.ts`'s new "validate --archived: the command's own refusal branch below the version floor" describe (`expect(r.err).toBe('')` got the stderr line instead) — observed: red with the reverted branch (`Received: "cospec: validate --archived needs OpenSpec >=1.9.0; the wrapped OpenSpec is 1.8.0\n"` on stderr where `''` was expected), green restored + +## 3. `upstream-spellings.test.ts` row 3.7 is deterministic + +- [x] 3.1 @integration (agent) row 3.7 against a shared `remedyNamedRoot()` root, run 20x locally -> no divergence in any run — observed: `12 pass, 0 fail` identically across 20/20 local runs +- [x] 3.2 @e2e (agent) row 3.7 green in this PR's CI -> green — observed: PR #62's `ci-bun` run 37283579143 passed (32m4s, `ubuntu-latest` — this repo's CI has no macOS runner; `mise run check` includes `test:contract`, which covers row 3.7) + +## 4. `status` already matches the binary on a mode-000 artifact [critical] + +- [x] 4.1 @equivalence (agent) macOS: `cospec status --change ` (text and `--json`, single and `--all`) vs the pinned binary (spawned under Bun) with a mode-000 `proposal.md` -> both refuse `EACCES: permission denied, realpath '…/proposal.md'`, exit 1, in every mode — observed directly (ad hoc probe, re-confirmed against the pinned binary spawned under `bun` directly: `--change alpha` and `--all` both exit 1 with that document) and via the `18.2` contract row, which measures each invocation's own answer (`Array.isArray(status)`) rather than citing a shared `realpathRefuses` prediction (that helper was dropped from row 18.2 in commit `a59e9b9b`; it is still used by unrelated rows elsewhere in the file): both exit 1, `errnoShape` matches `{code: 'EACCES', path: …/proposal.md}` +- [x] 4.2 @equivalence (agent) Linux (`oven/bun:1.3.14`, non-root UID 1000): same fixture -> `--change alpha` reads past the lock in every mode (Linux's `realpath` needs no read permission, unlike macOS/Bun's), reports `proposal` done, exit 0 — re-confirmed directly via a local Docker probe (`docker run -u 1000:1000 oven/bun:1.3.14`, bind-mounted tree, pinned binary spawned under `bun`): both cospec and the binary exit 0, both report `proposal` artifact `done`/`status: 'done'`. `--all` does **not** also exit 0 "in every mode" as this row previously said: both cospec and the binary exit 1 there whether or not `proposal.md` is locked, because `--all`'s sweep also walks `mobile`, the list fixture's own namespace folder (a folder wrapping a nested change with no change of its own directly under `openspec/changes/`), which the binary reports as its own `change_error` ("… is not a change: it is a folder wrapping …") independent of any lock — confirmed by probing the pinned binary directly (unlocked and locked) on both macOS and in the same Docker container: `alpha`'s own entry is `ok`/not refused by the lock on Linux, `mobile`'s entry is a `change_error` every time. The `18.2` contract row's equality assertion (`cs.exitCode === up.exitCode`) already held regardless of this, since it never predicted a code; only this row's and design.md's narrative explanation were wrong, and `18.2`'s own comment repeated the same misdiagnosis (now corrected alongside this row) +- [x] 4.3 @e2e (agent) the new contract row (tasks.md 4.1) green in this PR's CI -> green — observed: PR #62's `ci-bun` run 37283579143 passed (32m4s, `ubuntu-latest` — this repo's CI has no macOS runner); row 18.2 (the differential: measures each invocation's own answer rather than predicting one, after a first CI run found `--change` vs `--all` disagreeing on refusal on this runner) passed, confirming the differential measurement holds under the real CI container too, not just the local Docker/macOS probes used to write rows 4.1–4.2 + +## 5. Full gate + +- [x] 5.1 @e2e (agent) `mise run check` -> green — observed: lint, format:check, typecheck, generate:check, vendor:openspec:check, cospec-validate-all, agents:check, openspec:schema:validate all pass; `apps/cli:test` 1974 pass/0 fail, `apps/cli:test:integration` 193 pass/0 fail, `apps/cli:test:contract` 2523 pass/0 fail, `packages/bench:test` 343 pass/0 fail, `e2e:release-test` 14 pass/0 fail; `Finished in 1458.54s`, exit 0 — re-run at the merge stage after rebasing onto main and landing the four review-finding fixes (config.yaml schema fallback, namespace-folder guard, the command-level `--archived --json` test, and the 18.2/ledger correction); `mise run cospec -- validate list-status-untyped-leftovers --strict` also passed (0 errors, 0 warnings)