diff --git a/.agents/shared.md b/.agents/shared.md index d82538ce..bfdc909e 100644 --- a/.agents/shared.md +++ b/.agents/shared.md @@ -238,6 +238,19 @@ advisory findings listed in `apps/cli/src/core/rules/views.ts` fixture proving it reads the view it is registered under. A new rule or gate takes the verbatim view unless no commented line can ever trigger it. +**JSON documents are additive** — a cospec `--json` document that mirrors an +OpenSpec command (`status`, `list`, `validate`, and `archive --json` next) +carries every key upstream's document does: computed natively where cospec owns +the fact (`validate`'s report keys), otherwise from one delegated call per +invocation — never one per change — merged by identity through `mergeUpstream` +(`apps/cli/src/core/upstream-keys.ts`). No cospec key is removed and no cospec +value changed; every envelope keeps `version: 1`. The key oracle +(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts`) +is the gate: it fails on a missing upstream key, an upstream value reported +differently, a changed pre-existing cospec key, and any collision outside its +named list (`NAMED_COLLISIONS`: `version`, validate's `items[].type`), each +entry stating why cospec's value wins. + **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust filesystem/JSON post-conditions, never exit codes alone (OpenSpec aborts with @@ -263,10 +276,12 @@ out-of-scope issues as a proposed follow-up, not a silent fix. **Dependencies** — pin exact versions in `package.json`; regenerate the lockfile after editing the manifest. The OpenSpec pin is load-bearing: bumping it means running the contract suite and re-probing before updating -`EXPECTED_OPENSPEC_VERSION`. Tool pins in `mise.toml` are lockfile-backed — bump -a version and regenerate `mise.lock` in the same commit; CI's "mise lockfile -drift gate" step (`mise install` then `git diff --exit-code mise.lock`) fails -the PR otherwise. +`EXPECTED_OPENSPEC_VERSION`, and moving the exact `fast-glob` devDependency to +the version the new pin resolves (`core/glob.ts` ports the binary's glob +matching over it; `test/contract/glob.test.ts` fails until they agree). Tool +pins in `mise.toml` are lockfile-backed — bump a version and regenerate +`mise.lock` in the same commit; CI's "mise lockfile drift gate" step +(`mise install` then `git diff --exit-code mise.lock`) fails the PR otherwise. **Tests** — every command change lands with a contract or integration test. Contract tests run the real pinned binary; a false archive PASS is a release diff --git a/AGENTS.md b/AGENTS.md index aefaf114..821674c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,6 +242,19 @@ advisory findings listed in `apps/cli/src/core/rules/views.ts` fixture proving it reads the view it is registered under. A new rule or gate takes the verbatim view unless no commented line can ever trigger it. +**JSON documents are additive** — a cospec `--json` document that mirrors an +OpenSpec command (`status`, `list`, `validate`, and `archive --json` next) +carries every key upstream's document does: computed natively where cospec owns +the fact (`validate`'s report keys), otherwise from one delegated call per +invocation — never one per change — merged by identity through `mergeUpstream` +(`apps/cli/src/core/upstream-keys.ts`). No cospec key is removed and no cospec +value changed; every envelope keeps `version: 1`. The key oracle +(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts`) +is the gate: it fails on a missing upstream key, an upstream value reported +differently, a changed pre-existing cospec key, and any collision outside its +named list (`NAMED_COLLISIONS`: `version`, validate's `items[].type`), each +entry stating why cospec's value wins. + **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust filesystem/JSON post-conditions, never exit codes alone (OpenSpec aborts with @@ -267,10 +280,12 @@ out-of-scope issues as a proposed follow-up, not a silent fix. **Dependencies** — pin exact versions in `package.json`; regenerate the lockfile after editing the manifest. The OpenSpec pin is load-bearing: bumping it means running the contract suite and re-probing before updating -`EXPECTED_OPENSPEC_VERSION`. Tool pins in `mise.toml` are lockfile-backed — bump -a version and regenerate `mise.lock` in the same commit; CI's "mise lockfile -drift gate" step (`mise install` then `git diff --exit-code mise.lock`) fails -the PR otherwise. +`EXPECTED_OPENSPEC_VERSION`, and moving the exact `fast-glob` devDependency to +the version the new pin resolves (`core/glob.ts` ports the binary's glob +matching over it; `test/contract/glob.test.ts` fails until they agree). Tool +pins in `mise.toml` are lockfile-backed — bump a version and regenerate +`mise.lock` in the same commit; CI's "mise lockfile drift gate" step +(`mise install` then `git diff --exit-code mise.lock`) fails the PR otherwise. **Tests** — every command change lands with a contract or integration test. Contract tests run the real pinned binary; a false archive PASS is a release diff --git a/CLAUDE.md b/CLAUDE.md index 94dc8315..97398fa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,19 @@ advisory findings listed in `apps/cli/src/core/rules/views.ts` fixture proving it reads the view it is registered under. A new rule or gate takes the verbatim view unless no commented line can ever trigger it. +**JSON documents are additive** — a cospec `--json` document that mirrors an +OpenSpec command (`status`, `list`, `validate`, and `archive --json` next) +carries every key upstream's document does: computed natively where cospec owns +the fact (`validate`'s report keys), otherwise from one delegated call per +invocation — never one per change — merged by identity through `mergeUpstream` +(`apps/cli/src/core/upstream-keys.ts`). No cospec key is removed and no cospec +value changed; every envelope keeps `version: 1`. The key oracle +(`apps/cli/test/contract/support/key-oracle.ts`, rows in `cli-surface.test.ts`) +is the gate: it fails on a missing upstream key, an upstream value reported +differently, a changed pre-existing cospec key, and any collision outside its +named list (`NAMED_COLLISIONS`: `version`, validate's `items[].type`), each +entry stating why cospec's value wins. + **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust filesystem/JSON post-conditions, never exit codes alone (OpenSpec aborts with @@ -263,10 +276,12 @@ out-of-scope issues as a proposed follow-up, not a silent fix. **Dependencies** — pin exact versions in `package.json`; regenerate the lockfile after editing the manifest. The OpenSpec pin is load-bearing: bumping it means running the contract suite and re-probing before updating -`EXPECTED_OPENSPEC_VERSION`. Tool pins in `mise.toml` are lockfile-backed — bump -a version and regenerate `mise.lock` in the same commit; CI's "mise lockfile -drift gate" step (`mise install` then `git diff --exit-code mise.lock`) fails -the PR otherwise. +`EXPECTED_OPENSPEC_VERSION`, and moving the exact `fast-glob` devDependency to +the version the new pin resolves (`core/glob.ts` ports the binary's glob +matching over it; `test/contract/glob.test.ts` fails until they agree). Tool +pins in `mise.toml` are lockfile-backed — bump a version and regenerate +`mise.lock` in the same commit; CI's "mise lockfile drift gate" step +(`mise install` then `git diff --exit-code mise.lock`) fails the PR otherwise. **Tests** — every command change lands with a contract or integration test. Contract tests run the real pinned binary; a false archive PASS is a release diff --git a/apps/cli/package.json b/apps/cli/package.json index 7f0be853..8fb6cc31 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "bun-types": "1.3.14", + "fast-glob": "3.3.3", "yaml": "2.9.0" }, "optionalDependencies": { diff --git a/apps/cli/src/commands/apply.ts b/apps/cli/src/commands/apply.ts index 45c4d515..7d1bed40 100644 --- a/apps/cli/src/commands/apply.ts +++ b/apps/cli/src/commands/apply.ts @@ -25,15 +25,16 @@ import { type Change, } from '../core/change.ts' import { hasFlag } from '../core/command-table.ts' +import { answeringErrno } from '../core/errno.ts' import { openspecApplyInstructions, OpenspecCallError, type ApplyInstructionsJson, type Root, + type StatusDiagnostic, } from '../core/openspec.ts' import { respellRemedies } from '../core/remedies.ts' -import { renderHuman, renderJson, type ItemReport } from '../core/report.ts' -import { resolveRoot } from '../core/root.ts' +import { renderHuman, toJson, type ItemReport } from '../core/report.ts' import { surfaceUnmetConsequences } from '../core/rules/meta.ts' import { ARTIFACT_FILES, @@ -41,7 +42,9 @@ import { type ArtifactId, type CospecType, } from '../core/rules/type-facts.ts' -import { buildValidateContext, validateChange } from './validate.ts' +import { resolveRootOrDocument } from '../core/upstream-keys.ts' +import type { ArchiveWarning } from './status.ts' +import { readValidateContext, validateChange } from './validate.ts' // --- shared primitives (exported for status/list/archive/new) -------------- @@ -239,22 +242,98 @@ function printWarnings(instr: ApplyInstructionsJson): void { for (const w of instr.warnings ?? []) process.stdout.write(`Warning: ${w}\n`) } -function printReport(report: ItemReport, ctx: CommandContext): void { +/** The document's `warnings`, when there are any to carry. */ +function warningsKey(warnings: readonly ArchiveWarning[]): { warnings?: ArchiveWarning[] } { + return warnings.length === 0 ? {} : { warnings: [...warnings] } +} + +function printReport( + report: ItemReport, + ctx: CommandContext, + warnings: readonly ArchiveWarning[], +): void { const out = ctx.flags.json - ? renderJson([report]) + ? `${JSON.stringify({ ...toJson([report]), ...warningsKey(warnings) }, null, 2)}\n` : renderHuman([report], { noColor: ctx.flags.noColor, title: 'cospec apply' }) process.stdout.write(out) } -/** Legacy schema: no cospec gate — delegate to openspec and exit per its state. */ -async function applyLegacy(change: Change, ctx: CommandContext, root: Root): Promise { - let instr: ApplyInstructionsJson +/** + * An early exit (design D10): `prose` on stderr, or under `--json` one + * `{status: [{severity, code: "change_error", message, fix?}]}` document on + * stdout — the code the binary's `instructions apply` reports for the same + * lookups — so a `--json` caller always gets one document. Exit 1. + */ +function earlyExit( + ctx: CommandContext, + prose: string, + message: string, + fix?: string, + warnings: readonly ArchiveWarning[] = [], +): number { + if (ctx.flags.json) { + const status = [ + { severity: 'error', code: 'change_error', message, ...(fix === undefined ? {} : { fix }) }, + ] + process.stdout.write(`${JSON.stringify({ status, ...warningsKey(warnings) }, null, 2)}\n`) + } else process.stderr.write(prose) + return EXIT.failure +} + +/** + * The binary's refusal of `instructions apply`, relayed as the answer: its + * failure document under `--json`, each message and fix spelled through the + * remedy allowlist, or `cospec apply: ` and its `Fix:` line in text. + * Exit 1. + */ +function relayRefusal( + ctx: CommandContext, + refused: { status: StatusDiagnostic[] } & Record, + warnings: readonly ArchiveWarning[] = [], +): number { + const status = refused.status.map((d) => ({ + ...d, + message: respellRemedies(d.message), + ...(d.fix === undefined ? {} : { fix: respellRemedies(d.fix) }), + })) + if (ctx.flags.json) + process.stdout.write( + `${JSON.stringify({ ...refused, status, ...warningsKey(warnings) }, null, 2)}\n`, + ) + else + for (const d of status) + process.stderr.write( + `cospec apply: ${d.message}\n${d.fix === undefined ? '' : `Fix: ${d.fix}\n`}`, + ) + return EXIT.failure +} + +/** + * The apply payload the binary gives for `change`, relayed through cospec; or + * the exit code of the answer already printed when it refused the change or + * broke the call's contract. + */ +async function applyInstructions( + ctx: CommandContext, + root: Root, + change: Change, + warnings: readonly ArchiveWarning[] = [], +): Promise { + let answer try { - instr = relayApplyInstructions(await openspecApplyInstructions(root, change.id), change.id) + answer = await openspecApplyInstructions(root, change.id) } catch (err) { - process.stderr.write(`cospec apply: ${(err as Error).message}\n`) - return EXIT.failure + const msg = err instanceof OpenspecCallError ? err.message : (err as Error).message + return earlyExit(ctx, `cospec apply: ${msg}\n`, msg, undefined, warnings) } + if ('refused' in answer) return relayRefusal(ctx, answer.refused, warnings) + return relayApplyInstructions(answer.instructions, change.id) +} + +/** Legacy schema: no cospec gate — delegate to openspec and exit per its state. */ +async function applyLegacy(change: Change, ctx: CommandContext, root: Root): Promise { + const instr = await applyInstructions(ctx, root, change) + if (typeof instr === 'number') return instr if (ctx.flags.json) { process.stdout.write( `${JSON.stringify({ change: change.id, type: change.schema, legacy: true, apply: instr }, null, 2)}\n`, @@ -269,10 +348,20 @@ async function applyLegacy(change: Change, ctx: CommandContext, root: Root): Pro return instr.state === 'blocked' ? EXIT.blocked : EXIT.success } -export async function run(ctx: CommandContext): Promise { +/** + * `cospec apply`: an errno failure it lets escape (an unreadable + * `openspec/changes/`) is one `change_error` document under `--json`, the + * code the binary's `instructions apply` reports. + */ +export function run(ctx: CommandContext): Promise { + return answeringErrno(ctx.flags.json, { code: 'change_error' }, () => apply(ctx)) +} + +async function apply(ctx: CommandContext): Promise { const { flags } = ctx const parsedArgs = ctx.parsed! - const root = await resolveRoot(ctx) + const root = await resolveRootOrDocument(ctx, 'change_error') + if (root === undefined) return EXIT.failure const base = root.base const allowSoft = hasFlag(parsedArgs, '--allow-soft') // `skip_specs` precedence (DESIGN §5, OpenSpec 1.7 parity): the one-shot CLI @@ -287,19 +376,22 @@ export async function run(ctx: CommandContext): Promise { const name = parsedArgs.positionals[0]! if (!existsSync(openspecDir(base))) { - process.stderr.write(`cospec: no openspec/ directory at ${base} — run 'cospec init' first\n`) - return EXIT.failure + const message = `no openspec/ directory at ${base} — run 'cospec init' first` + return earlyExit(ctx, `cospec: ${message}\n`, message) } const change = resolveChange(base, name) if (change === undefined) { - process.stderr.write(`cospec apply: unknown change '${name}'\n`) const suggestion = closest( name, listChanges(base).map((c) => c.id), ) - if (suggestion !== undefined) process.stderr.write(`Did you mean '${suggestion}'?\n`) - return EXIT.failure + const didYouMean = suggestion === undefined ? '' : `Did you mean '${suggestion}'?` + return earlyExit( + ctx, + `cospec apply: unknown change '${name}'\n${didYouMean === '' ? '' : `${didYouMean}\n`}`, + `unknown change '${name}'${didYouMean === '' ? '' : `. ${didYouMean}`}`, + ) } // Step 1: legacy schemas bypass the cospec gate entirely. @@ -307,10 +399,14 @@ export async function run(ctx: CommandContext): Promise { if (resolution.kind === 'legacy') return applyLegacy(change, ctx, root) // Step 2: fast validation. Errors block the gate outright. - const vctx = buildValidateContext(base) + // An unreadable archive is read as empty (`readValidateContext`): the gate + // can only err toward blocked, and the warning says why. + const { ctx: vctx, warning } = readValidateContext(base) + const warnings = warning === undefined ? [] : [warning] + if (!flags.json) for (const w of warnings) process.stderr.write(`Warning: ${w.message}\n`) const report = await validateChange(root, change, vctx, { strict: false, fast: true }) if (!report.valid) { - printReport(report, ctx) + printReport(report, ctx, warnings) return EXIT.failure } @@ -331,6 +427,7 @@ export async function run(ctx: CommandContext): Promise { { change: change.id, type: change.schema, + ...warningsKey(warnings), gate: { state: 'blocked', reason: 'missing-artifacts', missingArtifacts: missing }, }, null, @@ -347,7 +444,7 @@ export async function run(ctx: CommandContext): Promise { // Step 4: blocker gate. Self-heal against the archive first (§5.1 step 4c). const blockersPath = join(change.dir, BLOCKERS_FILE) - const archived = archiveMap(base) + const archived = warning === undefined ? archiveMap(base) : new Map() const active = new Set(listChanges(base).map((c) => c.id)) const original = readFileSync(blockersPath, 'utf8') const heal = syncBlockers(original, archived, active, { fix: true }) @@ -362,6 +459,7 @@ export async function run(ctx: CommandContext): Promise { { change: change.id, type: change.schema, + ...warningsKey(warnings), gate: { state: 'blocked', reason: 'hard-blockers', @@ -423,6 +521,7 @@ export async function run(ctx: CommandContext): Promise { { change: change.id, type: change.schema, + ...warningsKey(warnings), gate: { state: 'soft-blocked', softBlockers: gate.soft, synced: heal.synced }, }, null, @@ -442,15 +541,9 @@ export async function run(ctx: CommandContext): Promise { const softAcknowledged = allowSoft ? gate.soft.map((s) => s.slug) : [] - // Step 5: fetch the apply payload from openspec. - let instr: ApplyInstructionsJson - try { - instr = relayApplyInstructions(await openspecApplyInstructions(root, change.id), change.id) - } catch (err) { - const msg = err instanceof OpenspecCallError ? err.message : (err as Error).message - process.stderr.write(`cospec apply: ${msg}\n`) - return EXIT.failure - } + // Step 5: fetch the apply payload from openspec; its refusal is the answer. + const instr = await applyInstructions(ctx, root, change, warnings) + if (typeof instr === 'number') return instr // Step 6: merged clear-gate output. if (flags.json) { @@ -459,6 +552,7 @@ export async function run(ctx: CommandContext): Promise { { change: change.id, type: change.schema, + ...warningsKey(warnings), gate: { state: 'clear', hardBlockers: [], softAcknowledged, synced: heal.synced }, apply: instr, }, diff --git a/apps/cli/src/commands/archive.ts b/apps/cli/src/commands/archive.ts index 48931f95..159eb1d7 100644 --- a/apps/cli/src/commands/archive.ts +++ b/apps/cli/src/commands/archive.ts @@ -183,8 +183,9 @@ const DATE_PREFIXED_RE = /^\d{4}-\d{2}-\d{2}-/ * Both accepted forms are real binary behaviour inside cospec's `>=1.0.0 * <2.0.0` range: from 1.7.0 a change whose id already carries a date prefix * archives under that id verbatim (#1309), while older binaries re-prefix it. - * cospec's own `CHANGE_ID_RE`/`meta/name-kebab` reject a date-prefixed id, so - * the verbatim arm is defence for a change created outside cospec, not a path + * `cospec new` (`CHANGE_ID_RE`) never creates a date-prefixed id and + * `meta/name-kebab` fails one before archive moves anything, so the verbatim + * arm is defence for a change created outside cospec, not a path * `cospec archive` can reach on its own. */ export function isArchiveTargetFor(changeId: string, dirName: string): boolean { diff --git a/apps/cli/src/commands/complete.ts b/apps/cli/src/commands/complete.ts index be3a4e52..36e941e4 100644 --- a/apps/cli/src/commands/complete.ts +++ b/apps/cli/src/commands/complete.ts @@ -1,6 +1,7 @@ -// `cospec __complete ` — the hidden dynamic-completion -// source the generated shell scripts call at Tab time. Emits tab-separated -// `iddescription` lines. +// `cospec __complete ` — the +// hidden dynamic-completion source the generated shell scripts call at Tab +// time. Emits tab-separated `iddescription` lines. The source name is +// matched case-insensitively, as upstream's `__complete ` matches it. // // EVERY failure is silent: exit 1 with nothing on stdout and nothing on stderr. // A completion helper runs mid-keystroke, where an error message would corrupt @@ -8,19 +9,26 @@ // unregistered store, or an unparseable wrapped payload all look the same: // no suggestions. The whole payload is built before anything is written, so a // late failure can never leave half a list on stdout. (Parse-time refusals from -// the command table — an unknown option, a missing source (commander's -// `missing required argument`, as upstream prints it), or upstream's `schemas` -// / `archived-changes` sources, still pending — do reach stderr; the generated -// scripts call this with `2>/dev/null`, and never with those tokens.) +// the command table — an unknown option, or a missing source (commander's +// `missing required argument`, as upstream prints it) — do reach stderr; the +// generated scripts call this with `2>/dev/null`.) + +import { existsSync, readdirSync } from 'node:fs' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' -import { COSPEC_TYPES } from '../core/change.ts' +import { archiveDir, COSPEC_TYPES, openspecDir } from '../core/change.ts' import { openspecList, passthroughOpenspec } from '../core/openspec.ts' -import { resolveRoot } from '../core/root.ts' +import { resolveRoot, type ResolvedRoot } from '../core/root.ts' import { TYPE_ARTIFACTS } from '../core/rules/type-facts.ts' -export const COMPLETE_SOURCES = ['changes', 'specs', 'types'] as const +export const COMPLETE_SOURCES = [ + 'changes', + 'specs', + 'types', + 'schemas', + 'archived-changes', +] as const export type CompleteSource = (typeof COMPLETE_SOURCES)[number] function isCompleteSource(name: string): name is CompleteSource { @@ -73,17 +81,79 @@ async function specItems(ctx: CommandContext): Promise<{ id: string; description })) } +/** A root that holds an `openspec/` tree; outside one there is nothing to complete. */ +async function plannedRoot(ctx: CommandContext): Promise { + const root = await resolveRoot(ctx) + if (!existsSync(openspecDir(root.base))) throw new Error('no openspec root') + return root +} + +interface SchemasPayloadEntry { + name?: unknown + description?: unknown +} + +/** + * Every schema `openspec schemas --json` lists, in its order, described by its + * `description` (else `schema`, upstream's own description). + */ +async function schemaItems(ctx: CommandContext): Promise<{ id: string; description: string }[]> { + const root = await plannedRoot(ctx) + const result = await passthroughOpenspec( + { command: ['schemas'], threaded: ['--json', ...root.storeArgs] }, + { cwd: root.cwd }, + ) + if (result.exitCode !== 0) throw new Error('schemas failed') + const payload = JSON.parse(result.stdout) as unknown + if (!Array.isArray(payload)) throw new Error('schemas printed no list') + return (payload as SchemasPayloadEntry[]).map((entry) => { + if (typeof entry.name !== 'string') throw new Error('a schema without a name') + const description = typeof entry.description === 'string' ? entry.description : '' + return { id: entry.name, description: description.length > 0 ? description : 'schema' } + }) +} + +/** + * The archived change directories under the resolved root's + * `openspec/changes/archive/` (non-dot, sorted), each `archived change`. No + * wrapped call: upstream reads the same directory, though under its cwd + * rather than the selected root (design D9). + */ +async function archivedItems(ctx: CommandContext): Promise<{ id: string; description: string }[]> { + const root = await plannedRoot(ctx) + const dir = archiveDir(root.base) + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .toSorted() + .map((id) => ({ id, description: 'archived change' })) +} + +async function itemsFor( + source: CompleteSource, + ctx: CommandContext, +): Promise<{ id: string; description?: string }[]> { + switch (source) { + case 'types': + return typeItems() + case 'changes': + return changeItems(ctx) + case 'specs': + return specItems(ctx) + case 'schemas': + return schemaItems(ctx) + case 'archived-changes': + return archivedItems(ctx) + } +} + export async function run(ctx: CommandContext): Promise { // Required in the table: the parser has refused a missing one. - const source = ctx.parsed!.positionals[0]! + const source = ctx.parsed!.positionals[0]!.toLowerCase() if (!isCompleteSource(source)) return EXIT.failure try { - const items = - source === 'types' - ? typeItems() - : source === 'changes' - ? await changeItems(ctx) - : await specItems(ctx) + const items = await itemsFor(source, ctx) process.stdout.write(renderCompletionItems(items)) return EXIT.success } catch { diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 5f121058..9896eda0 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -1,12 +1,18 @@ -// `cospec list [--blocked]` (DESIGN §2.6). Lists active changes with cospec -// columns — type, gate state, task progress, archive-readiness — derived from -// the filesystem (done == file exists) and the deterministic blocker gate. -// `--blocked` filters to changes whose gate is not clear. +// `cospec list [--blocked] [--sort ]` (DESIGN §2.6). Lists active +// changes with cospec columns — type, gate state, task progress, +// archive-readiness — derived from the filesystem (done == file exists) and the +// deterministic blocker gate. `--blocked` filters to changes whose gate is not +// clear. +// +// The rows, their order and the binary's own keys come from one delegated +// `openspec list --json` call (design D6): the binary's rows set the order and +// the membership, each gets cospec's native columns by name, and the binary's +// keys are merged in beside them (`core/upstream-keys.ts`). `--sort name` +// is forwarded; any other value, like none, is the binary's recent-first order. // // `cospec list --specs` (WI-7) closes the spec-listing gap: cospec's own rules // are change-centric, so it delegates to `openspec list --specs --json` -// (disciplined passthrough, WI-1) and renders cospec's own spec table — -// change listing stays entirely native. +// (disciplined passthrough, WI-1) and renders cospec's own spec table. import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -14,14 +20,31 @@ import { join } from 'node:path' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' import { parseBlockers } from '../core/blockers.ts' -import { isCospecType, listChanges } from '../core/change.ts' -import { hasFlag } from '../core/command-table.ts' -import { OpenspecCallError, passthroughOpenspec } from '../core/openspec.ts' -import { resolveRoot } from '../core/root.ts' +import { + changesDir, + findNestedChangesIn, + isCospecType, + listChanges, + readOpenspecYaml, +} from '../core/change.ts' +import { flagValue, hasFlag } from '../core/command-table.ts' +import { + OpenspecCallError, + passthroughOpenspec, + wrappedCallLabel, + type Root, +} from '../core/openspec.ts' +import { respellRemedies } from '../core/remedies.ts' import { TYPE_ARTIFACTS } from '../core/rules/type-facts.ts' -import { parseTasks } from '../core/tasks.ts' -import { archiveMap, artifactDone, computeGate, type Gate } from './apply.ts' -import { gateLabel, hasAnyArtifact } from './status.ts' +import { mergeUpstream, resolveRootOrDocument, type Identities } from '../core/upstream-keys.ts' +import { artifactDone, computeGate, type Gate } from './apply.ts' +import { + gateLabel, + hasAnyArtifact, + readArchive, + readChangeTasks, + type ReadWarning, +} from './status.ts' interface SpecRow { id: string @@ -30,29 +53,24 @@ interface SpecRow { interface OpenspecListSpecsJson { specs?: SpecRow[] + root?: unknown status?: { severity: string; code: string; message: string; fix?: string }[] } /** * Delegate spec listing to `openspec list --specs --json` (openspec's `list * --specs`/`--json` shape is `{ specs: [{id, requirementCount}], root, status? - * }`, re-probed against the pinned 1.11.0 — `specs[]` unchanged, `root` is now - * an object `{path, source}` which cospec does not read). Renders cospec's own - * spec table so - * `--specs` output style matches the change table above it. Never touches - * cospec's own rule families — spec *validation* stays `cospec validate - * --specs`; this is read-only listing. + * }`). Renders cospec's own spec table so `--specs` output style matches the + * change table; under `--json` cospec's `{version: 1, specs}` document carries + * the delegated `root`. Never touches cospec's own rule families — spec + * *validation* stays `cospec validate --specs`; this is read-only listing. */ -async function runSpecs( - ctx: CommandContext, - cwd: string, - storeArgs: readonly string[], -): Promise { +async function runSpecs(ctx: CommandContext, root: Root): Promise { let result: Awaited> try { result = await passthroughOpenspec( - { command: ['list'], threaded: ['--json', ...storeArgs], args: ['--specs'] }, - { cwd }, + { command: ['list'], threaded: ['--json', ...root.storeArgs], args: ['--specs'] }, + { cwd: root.cwd }, ) } catch (err) { if (err instanceof OpenspecCallError) { @@ -70,6 +88,20 @@ async function runSpecs( return EXIT.failure } + // A read the binary refuses (an unreadable capability directory) is its + // answer, relayed: its document under `--json`, else its message and fix. + const failure = upstreamFailure(parsed as Record) + if (failure !== undefined) { + if (ctx.flags.json) + process.stdout.write(respellRemedies(`${JSON.stringify(parsed, null, 2)}\n`)) + else + for (const s of failure) { + process.stderr.write(`cospec: ${respellRemedies(s.message)}\n`) + if (typeof s.fix === 'string') process.stderr.write(`Fix: ${respellRemedies(s.fix)}\n`) + } + return EXIT.failure + } + if (result.exitCode !== 0) { const message = parsed.status?.map((s) => s.message).join('\n') ?? result.stderr process.stderr.write(`${message}\n`) @@ -79,7 +111,8 @@ async function runSpecs( const specs = parsed.specs ?? [] if (ctx.flags.json) { - process.stdout.write(`${JSON.stringify({ version: 1, specs }, null, 2)}\n`) + const doc = { version: 1, specs, ...(parsed.root === undefined ? {} : { root: parsed.root }) } + process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`) return EXIT.success } @@ -100,81 +133,230 @@ async function runSpecs( interface Row { change: string type: string - state: 'in-progress' | 'building' + /** `not-a-change` for a namespace folder (design D6), which cospec used to call an empty change. */ + state: 'in-progress' | 'building' | 'not-a-change' gate: string gateState: Gate['state'] tasks: { total: number; complete: number } archiveReady: boolean + /** A namespace folder's nested changes (design D2), as the binary's row carries them. */ + nested?: string[] +} + +/** A row cospec could not compute: a file only its own columns read would not open. */ +interface FailedRow { + change: string + error: string +} + +/** + * cospec's native columns for the change directory `id`, computed as ever — a + * namespace folder marked `not-a-change` with its nested ids. A change file + * that cannot be read (errno) fails this row alone, as the binary never reads + * `blocking-changes.md`; an unreadable `tasks.md` counts as no tasks, as the + * binary counts it, its warning added to `warnings`. + */ +function nativeRow( + base: string, + id: string, + archived: Map, + active: Set, + warnings: ReadWarning[], +): Row | FailedRow { + try { + return computeRow(base, id, archived, active, warnings) + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (!(error instanceof Error) || typeof code !== 'string') throw error + return { change: id, error: error.message } + } +} + +function computeRow( + base: string, + id: string, + archived: Map, + active: Set, + warnings: ReadWarning[], +): Row { + const dir = join(changesDir(base), id) + const finding = findNestedChangesIn(changesDir(base), id) + const schema = 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) + + const parsedTasks = readChangeTasks(dir, warnings) + const total = parsedTasks.items.length + const complete = parsedTasks.items.filter((t) => t.checked).length + + let archiveReady = false + if (cospec && !empty) { + const facts = TYPE_ARTIFACTS[schema as keyof typeof TYPE_ARTIFACTS] + const requiredDone = facts.applyRequires.every((a) => artifactDone(dir, a)) + archiveReady = requiredDone && total > 0 && complete === total && gate.state === 'clear' + } + + return { + change: id, + type: schema || '(none)', + state: finding !== undefined ? 'not-a-change' : empty ? 'in-progress' : 'building', + gate: gateLabel(gate), + gateState: gate.state, + tasks: { total, complete }, + archiveReady, + ...(finding === undefined ? {} : { nested: finding.nested }), + } +} + +function isFailedRow(row: Row | FailedRow): row is FailedRow { + return 'error' in row +} + +/** The binary's failure diagnostics, when its answer is a failure document. */ +function upstreamFailure( + doc: Record, +): { message: string; fix?: string }[] | undefined { + if (!Array.isArray(doc.status)) return undefined + const errors = (doc.status as { severity?: string; message: string; fix?: string }[]).filter( + (s) => s.severity === 'error', + ) + return errors.length > 0 ? errors : undefined +} + +/** The warnings `list` prints: `Warning: ` on stderr, or `warnings` under `--json`. */ +const WARNING_IDENTITY: Identities = { 'warnings[]': { cospec: 'message', upstream: 'message' } } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * The one delegated `openspec list --json` call (design D6). Its failure + * document (`status`, exit 1) is an answer, not a violation; anything but one + * document carrying `changes` or `status` is. + */ +async function delegatedList(root: Root, args: string[]): Promise> { + const label = wrappedCallLabel(['list', '--json', ...root.storeArgs, ...args]) + let doc: Record | undefined + await passthroughOpenspec( + { command: ['list'], threaded: ['--json', ...root.storeArgs], args }, + { + cwd: root.cwd, + expect: { + exitCodes: [0, 1], + postCondition: (result) => { + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + return `${label} did not print one JSON document` + } + if (!isRecord(parsed)) return `${label} printed no JSON object` + if (!Array.isArray(parsed.changes) && !Array.isArray(parsed.status)) + return `${label} printed neither changes nor a diagnostic` + doc = parsed + return true + }, + }, + }, + ) + return doc! } +/** The binary's `list` null-shape under `--json` (`{changes: [], root: null}`). */ +const LIST_FAILURE_PAYLOAD = { changes: [], root: null } as const +const SPECS_FAILURE_PAYLOAD = { specs: [], root: null } as const + export async function run(ctx: CommandContext): Promise { const { flags } = ctx const parsed = ctx.parsed! - const root = await resolveRoot(ctx) + const specsMode = hasFlag(parsed, '--specs') + const root = await resolveRootOrDocument( + ctx, + 'list_error', + specsMode ? SPECS_FAILURE_PAYLOAD : LIST_FAILURE_PAYLOAD, + ) + if (root === undefined) return EXIT.failure const base = root.base - if (hasFlag(parsed, '--specs')) return runSpecs(ctx, root.cwd, root.storeArgs) + if (specsMode) return runSpecs(ctx, root) const onlyBlocked = hasFlag(parsed, '--blocked') + const upstream = await delegatedList( + root, + flagValue(parsed, '--sort') === 'name' ? ['--sort', 'name'] : [], + ) - const changes = listChanges(base) - const archived = archiveMap(base) - const active = new Set(changes.map((c) => c.id)) - - const rows: Row[] = changes.map((change) => { - const blockersPath = join(change.dir, 'blocking-changes.md') - const gate = existsSync(blockersPath) - ? computeGate(parseBlockers(readFileSync(blockersPath, 'utf8')), archived, active) - : ({ state: 'clear', hard: [], soft: [] } satisfies Gate) - - const empty = !hasAnyArtifact(change.dir) - const cospec = isCospecType(change.schema) - - const tasksPath = join(change.dir, 'tasks.md') - const parsedTasks = existsSync(tasksPath) - ? parseTasks(readFileSync(tasksPath, 'utf8')) - : { items: [], malformed: [], groups: [] } - const total = parsedTasks.items.length - const complete = parsedTasks.items.filter((t) => t.checked).length - - let archiveReady = false - if (cospec && !empty) { - const facts = TYPE_ARTIFACTS[change.schema as keyof typeof TYPE_ARTIFACTS] - const requiredDone = facts.applyRequires.every((id) => artifactDone(change.dir, id)) - archiveReady = requiredDone && total > 0 && complete === total && gate.state === 'clear' - } + // A read failure the binary refuses (an unreadable tasks.md or change + // directory) is its answer, relayed: its document, or its messages. + const failure = upstreamFailure(upstream) + if (failure !== undefined) { + if (flags.json) process.stdout.write(respellRemedies(`${JSON.stringify(upstream, null, 2)}\n`)) + else + for (const s of failure) { + // The binary's text answer: its message, then its fix, each spelled through cospec. + process.stderr.write(`cospec list: ${respellRemedies(s.message)}\n`) + if (typeof s.fix === 'string') process.stderr.write(`Fix: ${respellRemedies(s.fix)}\n`) + } + return EXIT.failure + } - return { - change: change.id, - type: change.schema || '(none)', - state: empty ? 'in-progress' : 'building', - gate: gateLabel(gate), - gateState: gate.state, - tasks: { total, complete }, - archiveReady, - } + const upstreamRows = (Array.isArray(upstream.changes) ? upstream.changes : []) as Record< + string, + unknown + >[] + + const { archived, warning } = readArchive(base) + const active = new Set(listChanges(base).map((c) => c.id)) + const warnings: ReadWarning[] = warning === undefined ? [] : [warning] + const rows = upstreamRows.map((upRow) => { + const native = nativeRow(base, String(upRow.name), archived, active, warnings) + return mergeUpstream(native, upRow).value }) + const failed = rows.some(isFailedRow) - const shown = onlyBlocked ? rows.filter((r) => r.gateState !== 'clear') : rows + const shown = onlyBlocked ? rows.filter((r) => isFailedRow(r) || r.gateState !== 'clear') : rows if (flags.json) { - process.stdout.write(`${JSON.stringify({ version: 1, changes: shown }, null, 2)}\n`) - return EXIT.success + const { changes: _rows, ...rest } = upstream + const doc = mergeUpstream( + { version: 1, changes: shown, ...(warnings.length === 0 ? {} : { warnings }) }, + rest, + WARNING_IDENTITY, + ).value + process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`) + return failed ? EXIT.failure : EXIT.success } + for (const w of warnings) process.stderr.write(`Warning: ${w.message}\n`) if (shown.length === 0) { process.stdout.write(onlyBlocked ? 'No blocked changes.\n' : 'No active changes.\n') return EXIT.success } const nameWidth = Math.max(...shown.map((r) => r.change.length), 6) - const typeWidth = Math.max(...shown.map((r) => r.type.length), 4) + const typeWidth = Math.max(...shown.map((r) => (isFailedRow(r) ? 0 : r.type.length)), 4) const lines = shown.map((r) => { + if (isFailedRow(r)) return ` ${r.change.padEnd(nameWidth)} ERROR — ${r.error}` const tasks = - r.state === 'in-progress' ? 'no artifacts yet' : `${r.tasks.complete}/${r.tasks.total} tasks` + r.state === 'not-a-change' + ? 'not a change' + : r.state === 'in-progress' + ? 'no artifacts yet' + : `${r.tasks.complete}/${r.tasks.total} tasks` const ready = r.archiveReady ? ' archive-ready' : '' return ` ${r.change.padEnd(nameWidth)} ${r.type.padEnd(typeWidth)} ${r.gate.padEnd(18)} ${tasks}${ready}` }) process.stdout.write(`${lines.join('\n')}\n`) - return EXIT.success + // The binary's nested-folder warnings follow the table, as its text does. + const upstreamWarnings = (Array.isArray(upstream.warnings) ? upstream.warnings : []) as { + message: string + }[] + for (const w of upstreamWarnings) process.stdout.write(`\nWarning: ${w.message}\n`) + return failed ? EXIT.failure : EXIT.success } diff --git a/apps/cli/src/commands/new.ts b/apps/cli/src/commands/new.ts index 53748b83..44a81c19 100644 --- a/apps/cli/src/commands/new.ts +++ b/apps/cli/src/commands/new.ts @@ -14,6 +14,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' +import { userSchemasDir } from '../core/change-metadata.ts' import { CHANGE_ID_RE, changesDir, @@ -94,28 +95,6 @@ function reportUnknownType(type: string, json: boolean): number { return EXIT.failure } -/** - * The user-level schema directory the wrapped binary reads (its - * `getUserSchemasDir()`, `/schemas`): `$XDG_DATA_HOME/openspec` - * when that is set, else `%LOCALAPPDATA%\openspec` on Windows, else - * `~/.local/share/openspec` — never `~/.config`, which holds only its config. - */ -export function userSchemasDir( - env: NodeJS.ProcessEnv = process.env, - home: string = homedir(), - platform: NodeJS.Platform = process.platform, -): string { - const xdg = env.XDG_DATA_HOME - if (xdg !== undefined && xdg.length > 0) return join(xdg, 'openspec', 'schemas') - if (platform === 'win32') { - const local = env.LOCALAPPDATA - return local !== undefined && local.length > 0 - ? join(local, 'openspec', 'schemas') - : join(home, 'AppData', 'Local', 'openspec', 'schemas') - } - return join(home, '.local', 'share', 'openspec', 'schemas') -} - /** * Whether the wrapped binary can resolve cospec type `type` from `base`, where * it looks before its package built-ins (OpenSpec's own schemas, never a diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 3dd9f5b2..81f39fb4 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -4,24 +4,47 @@ // A change with a `.openspec.yaml` but no artifacts yet renders as "in progress" // rather than openspec's bare "Unknown item" (PMF10 / product gap #3). -import { existsSync, readFileSync } from 'node:fs' +import { closeSync, existsSync, openSync, readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import type { CommandContext } from '../cli.ts' import { EXIT } from '../cli.ts' import { parseBlockers } from '../core/blockers.ts' -import { isCospecType, listChanges, resolveChange, type Change } from '../core/change.ts' +import { + changeMetadataRefused, + listSchemas, + loadSchema, + schemaDir, +} from '../core/change-metadata.ts' +import { + archiveDir, + changesDir, + describeNestedChange, + findNestedChangesIn, + isCospecType, + listChanges, + projectConfigSchema, + resolveChange, + type Change, +} from '../core/change.ts' import { flagValue, hasFlag } from '../core/command-table.ts' -import { passthroughOpenspec } from '../core/openspec.ts' -import { respellRemedies } from '../core/remedies.ts' -import { resolveRoot } from '../core/root.ts' +import { answeringErrno } from '../core/errno.ts' +import { passthroughOpenspec, wrappedCallLabel } from '../core/openspec.ts' +import { respellRemedies, respellWholeRemedy } from '../core/remedies.ts' +import type { ResolvedRoot } from '../core/root.ts' import { artifactRequires, enforcedApplyRequires, TYPE_ARTIFACTS, type CospecType, } from '../core/rules/type-facts.ts' -import { parseTasks } from '../core/tasks.ts' +import { parseTasks, type ParsedTasks } from '../core/tasks.ts' +import { + mergeUpstream, + resolveRootOrDocument, + rootOutput, + type Identities, +} from '../core/upstream-keys.ts' import { computeVerificationVerdict, type VerificationVerdict } from '../core/verification.ts' import { archiveMap, artifactDone, closest, computeGate, hasSpecFiles, type Gate } from './apply.ts' @@ -69,13 +92,142 @@ export interface ChangeStatus { /** read-only verification verdict (DESIGN §3.6) — never a gate; `cospec apply` * and `cospec archive` are the only commands that gate on verification. */ verification: VerificationVerdict + /** The next step (`resolveNext`), when there is one. */ + next?: string +} + +/** An artifact's state in its schema's build order, as the binary's `artifacts[].status` names them. */ +export type ArtifactState = 'done' | 'ready' | 'blocked' | 'skipped' + +/** + * The next step for a change (design D4), the one function the JSON `next` + * and the human `Next:` line both print: the first ready artifact the change + * requires to apply; else `cospec apply ` once every required artifact is + * done (a `skip_specs`-skipped one counts as done); else the first ready + * artifact of any kind; else nothing. Unlike the binary's `nextSteps`, an + * optional artifact still unwritten never holds the change back from its gate. + */ +export function resolveNext( + states: readonly { id: string; state: ArtifactState }[], + required: ReadonlySet, + changeId: string, +): string | undefined { + const instructions = (id: string): string => `cospec instructions ${id} --change ${changeId}` + const readyRequired = states.find((a) => a.state === 'ready' && required.has(a.id)) + if (readyRequired !== undefined) return instructions(readyRequired.id) + const settled = (id: string): boolean => { + const state = states.find((a) => a.id === id)?.state + return state === 'done' || state === 'skipped' + } + if ([...required].every(settled)) return `cospec apply ${changeId}` + const ready = states.find((a) => a.state === 'ready') + return ready === undefined ? undefined : instructions(ready.id) +} + +/** + * A cospec-typed change's artifact states from its own matrix: done is the + * file present, skipped a `skip_specs` change's absent `specs`, ready every + * artifact it requires done or skipped. + */ +function cospecStates( + type: CospecType, + done: ReadonlyMap, + skipSpecs: boolean, +): { id: string; state: ArtifactState }[] { + const facts = TYPE_ARTIFACTS[type] + const settled = (id: string): boolean => done.get(id) === true || (id === 'specs' && skipSpecs) + return facts.declared.map((id) => { + if (done.get(id) === true) return { id, state: 'done' } + if (id === 'specs' && skipSpecs) return { id, state: 'skipped' } + return { id, state: artifactRequires(type, id).every(settled) ? 'ready' : 'blocked' } + }) +} + +/** A warning a status or list document carries (`--json`) or prints on stderr (text). */ +export interface ArchiveWarning { + code: 'archive_unreadable' + message: string +} + +/** The warning for a change whose `tasks.md` could not be read (`readChangeTasks`). */ +export interface TasksWarning { + code: 'tasks_unreadable' + message: string +} + +export type ReadWarning = ArchiveWarning | TasksWarning + +const NO_TASKS: ParsedTasks = { items: [], malformed: [], groups: [] } + +/** + * A change's `tasks.md`, read as the binary's `countTaskFile` reads it: an + * absent file is no tasks, and so is one any other errno refuses, with a + * warning naming the file pushed onto `warnings`. Status has already asked + * the binary whether such a change can be reported at all + * (`hasUnreadableEntry`): it refuses the change where its runtime's `realpath` + * confinement check refuses the file (Bun on macOS), and counts the file as no + * tasks elsewhere. + */ +export function readChangeTasks(changeDir: string, warnings: ReadWarning[]): ParsedTasks { + const path = join(changeDir, 'tasks.md') + let text: string + try { + text = readFileSync(path, 'utf8') + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (typeof code !== 'string') throw error + if (code !== 'ENOENT') + warnings.push({ + code: 'tasks_unreadable', + message: `could not read ${path} (${code}); its tasks are counted as none`, + }) + return NO_TASKS + } + return parseTasks(text) +} + +/** + * The archive index the gate column reads (design D4). The binary never reads + * `openspec/changes/archive/` for `status` or `list`, so an unreadable one + * must not fail them: the gate is computed from an empty index — which can + * only err toward `blocked`, never a false `clear` — and the warning says + * why. `validate` and `apply` read past it the same way + * (`readValidateContext`); `archive` reads it through `archiveMap` and still + * refuses. + */ +export function readArchive(base: string): { + archived: Map + warning?: ArchiveWarning +} { + try { + return { archived: archiveMap(base) } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (typeof code !== 'string' || code === 'ENOENT') throw error + return { + archived: new Map(), + warning: { + code: 'archive_unreadable', + message: + `could not read ${archiveDir(base)} (${code}); blocker gates are computed as if no change ` + + 'were archived', + }, + } + } } /** * Full status for a cospec-typed change with at least one artifact. Assumes the - * caller has excluded the empty-change and legacy cases. + * caller has excluded the empty-change and legacy cases. `archived` is the + * archive index its gate reads (`readArchive`); read here when not given. An + * unreadable `tasks.md` adds its warning to `warnings`. */ -export function computeStatus(base: string, change: Change): ChangeStatus { +export function computeStatus( + base: string, + change: Change, + archived?: Map, + warnings: ReadWarning[] = [], +): ChangeStatus { const type = change.schema as CospecType const facts = TYPE_ARTIFACTS[type] // Grandfathering: `required` mirrors the schemaVersion-filtered set the @@ -94,15 +246,12 @@ export function computeStatus(base: string, change: Change): ChangeStatus { const gate = existsSync(blockersPath) ? computeGate( parseBlockers(readFileSync(blockersPath, 'utf8')), - archiveMap(base), + archived ?? archiveMap(base), new Set(listChanges(base).map((c) => c.id)), ) : ({ state: 'clear', hard: [], soft: [] } satisfies Gate) - const tasksPath = join(change.dir, 'tasks.md') - const parsedTasks = existsSync(tasksPath) - ? parseTasks(readFileSync(tasksPath, 'utf8')) - : { items: [], malformed: [], groups: [] } + const parsedTasks = readChangeTasks(change.dir, warnings) const total = parsedTasks.items.length const complete = parsedTasks.items.filter((t) => t.checked).length @@ -119,6 +268,11 @@ export function computeStatus(base: string, change: Change): ChangeStatus { verificationText, ) + const next = resolveNext( + cospecStates(type, done, change.skipSpecs === true), + applyRequires, + change.id, + ) return { change: change.id, type: change.schema, @@ -129,6 +283,7 @@ export function computeStatus(base: string, change: Change): ChangeStatus { tasks: { total, complete }, archiveReady, verification, + ...(next === undefined ? {} : { next }), } } @@ -151,11 +306,21 @@ function renderHuman(status: ChangeStatus): string { ` verification: ${v.verified}/${v.total} verified, ${v.deferred} deferred, ${v.unresolved} unresolved`, ) } + if (status.next !== undefined) lines.push(`Next: ${status.next}`) return `${lines.join('\n')}\n` } -/** The empty-change entry shape (`.openspec.yaml` present, no artifacts yet). */ -function emptyChangeEntry(change: Change) { +/** + * The empty-change entry shape: a cospec-typed change (`.openspec.yaml` + * present) with no artifacts yet, its next step from its own matrix. + */ +function emptyChangeEntry(change: Change, type: CospecType) { + const required = new Set(enforcedApplyRequires(type, change.schemaVersion ?? 1)) + const next = resolveNext( + cospecStates(type, new Map(), change.skipSpecs === true), + required, + change.id, + ) return { change: change.id, type: change.schema, @@ -163,13 +328,27 @@ function emptyChangeEntry(change: Change) { artifacts: [] as ArtifactStatus[], gate: 'clear', archiveReady: false, - next: `cospec instructions proposal --change ${change.id}`, + ...(next === undefined ? {} : { next }), } } -/** The legacy/unknown-schema entry shape. */ -function legacyChangeEntry(change: Change) { - return { change: change.id, type: change.schema, legacy: true as const } +/** + * A change on a schema cospec doesn't type: its identity, and — from the + * binary's own status for it — the next step (`resolveNext` over the + * binary's `artifacts[].status` and `applyRequires`). + */ +function legacyChangeEntry( + change: Change, + upstream: Record | undefined, +): { change: string; type: string; legacy: true; next?: string } { + const entry = { change: change.id, type: change.schema, legacy: true as const } + const next = upstream === undefined ? undefined : upstreamNext(upstream, change.id) + return next === undefined ? entry : { ...entry, next } +} + +function emptyHuman(entry: { change: string; type: string; next?: string }): string { + const next = entry.next === undefined ? '' : `; next: ${entry.next}` + return `${entry.change} (${entry.type}): in progress — no artifacts yet${next}\n` } export type ChangeEntry = @@ -183,64 +362,374 @@ export interface ChangeEntryFailure { } /** - * One change's status entry — empty, legacy, or full — for a single change. - * Never throws itself; a caller sweeping every change (`--all`) wraps this in - * a try/catch per change so one bad change cannot abort the sweep. + * The change as status grades it (design D4): `--schema` overrides its schema, + * as the binary's does; a directory with no `.openspec.yaml` takes its schema + * as the binary does — the root's `config.yaml` `schema:`, else + * `spec-driven` — at `schemaVersion` 1. + */ +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) + return bare ? { ...change, schema, schemaVersion: 1 } : { ...change, schema } +} + +/** `--schema ` as the binary forwards it, or nothing. */ +function schemaArgs(override: string | undefined): string[] { + return override === undefined ? [] : ['--schema', override] +} + +/** + * The binary's refusal of an unknown `--schema` (its `validateSchemaExists`, + * project, user and package tiers), from a delegated `--json` call so the + * list of available schemas is the binary's: its document under `--json`, + * its message on stderr otherwise, exit 1. + */ +async function refuseUnknownSchema( + root: ResolvedRoot, + args: string[], + json: boolean, +): Promise { + const doc = await delegatedStatus(root, args) + if (json) process.stdout.write(`${JSON.stringify(respelledUpstream(doc), null, 2)}\n`) + else + for (const s of upstreamFailure(doc) ?? []) + process.stderr.write(`cospec status: ${s.message}\n`) + return EXIT.failure +} + +/** + * Whether the binary's status for this change must answer it: a schema cospec + * doesn't type, whose artifacts only its own schema names, written or not. + */ +function answeredUpstream(change: Change): boolean { + return !isCospecType(change.schema) +} + +/** + * One change's status entry — legacy, empty, or full. A legacy entry (any + * schema cospec doesn't type, with or without artifacts) takes its next step + * from `upstream`, the binary's status for the change; only a cospec-typed + * change is empty. Never throws itself; a caller sweeping every change + * (`--all`) wraps this in a try/catch per change so one bad change cannot + * abort the sweep. */ -export function buildChangeEntry(base: string, change: Change): ChangeEntry { - if (!hasAnyArtifact(change.dir)) return emptyChangeEntry(change) - if (!isCospecType(change.schema)) return legacyChangeEntry(change) - return computeStatus(base, change) +export function buildChangeEntry( + base: string, + change: Change, + upstream?: Record, + archived?: Map, + warnings: ReadWarning[] = [], +): ChangeEntry { + if (!isCospecType(change.schema)) return legacyChangeEntry(change, upstream) + if (!hasAnyArtifact(change.dir)) return emptyChangeEntry(change, change.schema) + return computeStatus(base, change, archived, warnings) +} + +/** An errno failure reading a change's files: its message, as the binary reports it. */ +function readFailure(error: unknown): string | undefined { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return error instanceof Error && typeof code === 'string' ? error.message : undefined +} + +/** What `binaryDecides` memoizes across a sweep: each schema's load, and `listSchemas`. */ +interface DecideCache { + loads: Map + listed?: string[] +} + +/** + * Whether the binary decides if a cospec-typed change can be reported at all, + * so status asks it in text mode as under `--json`: cospec cannot read some + * entry of the change (`hasUnreadableEntry`), the binary's + * `readChangeMetadata` refuses its `.openspec.yaml` (`changeMetadataRefused`: + * unreadable, not YAML, failing `ChangeMetadataSchema`, or naming a schema + * `listSchemas` does not list), or the binary cannot load the change's schema + * — `loadSchema`, its `resolveSchema`, finds no candidate in any tier, or + * cannot read, parse or validate the one it finds. Any of these, a spawn the + * binary answers without refusing costs only the spawn. + */ +function binaryDecides( + base: string, + change: Change, + cache: DecideCache = { loads: new Map() }, +): boolean { + if (hasUnreadableEntry(change.dir)) return true + if (changeMetadataRefused(change.dir, () => (cache.listed ??= listSchemas(base)))) return true + let loaded = cache.loads.get(change.schema) + if (loaded === undefined) { + try { + loadSchema(change.schema, base) + loaded = true + } catch (error) { + // Every failure to load (its own or an errno listing the schemas) is + // the binary's to report: the change goes to the binary. + if (!(error instanceof Error)) throw error + loaded = false + } + cache.loads.set(change.schema, loaded) + } + return !loaded +} + +/** + * Whether cospec cannot read some entry of a change: the directory itself, or + * a file or directory under it (dot-entries aside, which the binary's artifact + * globs never match). Such a change is the binary's to report or refuse: it + * refuses where its runtime's `realpath` refuses the entry (Bun on macOS) and + * reads past it elsewhere, so status asks it, in text mode too. + */ +export function hasUnreadableEntry(dir: string): boolean { + const failed = (error: unknown): boolean => { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (!(error instanceof Error) || typeof code !== 'string') throw error + return code !== 'ENOENT' + } + const walk = (at: string): boolean => { + let entries + try { + entries = readdirSync(at, { withFileTypes: true }) + } catch (error) { + return failed(error) + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const path = join(at, entry.name) + if (entry.isDirectory()) { + if (walk(path)) return true + } else if (entry.isFile()) { + try { + closeSync(openSync(path, 'r')) + } catch (error) { + if (failed(error)) return true + } + } + } + return false + } + return walk(dir) +} + +/** A namespace folder's explanation (design D2), when `id` names one. */ +function namespaceExplanation(base: string, id: string): string | undefined { + const finding = findNestedChangesIn(changesDir(base), id) + return finding === undefined ? undefined : describeNestedChange(finding) +} + +function printWarnings(warnings: readonly ReadWarning[]): void { + for (const warning of warnings) process.stderr.write(`Warning: ${warning.message}\n`) +} + +/** The document's `warnings`, when there are any to carry. */ +function warningsKey(warnings: readonly ReadWarning[]): { warnings?: ReadWarning[] } { + return warnings.length === 0 ? {} : { warnings: [...warnings] } +} + +/** The archive's warning, if any, then the tasks warnings in change order. */ +function readWarnings( + archive: ArchiveWarning | undefined, + tasks: readonly ReadWarning[], +): ReadWarning[] { + return [...(archive === undefined ? [] : [archive]), ...tasks] } function isFailure(entry: ChangeEntry | ChangeEntryFailure): entry is ChangeEntryFailure { return 'error' in entry } -function renderEntryHuman(entry: ChangeEntry | ChangeEntryFailure): string { +// --- the binary's status, for a schema cospec doesn't type ---------------------- + +interface UpstreamArtifact { + id: string + status: ArtifactState + missingDeps?: string[] +} + +function upstreamArtifacts(doc: Record): UpstreamArtifact[] | undefined { + return Array.isArray(doc.artifacts) ? (doc.artifacts as UpstreamArtifact[]) : undefined +} + +/** `resolveNext` over the binary's own artifact states and `applyRequires`. */ +function upstreamNext(doc: Record, id: string): string | undefined { + const artifacts = upstreamArtifacts(doc) + if (artifacts === undefined) return undefined + const required = new Set(Array.isArray(doc.applyRequires) ? (doc.applyRequires as string[]) : []) + return resolveNext( + artifacts.map((a) => ({ id: a.id, state: a.status })), + required, + id, + ) +} + +/** + * The binary's diagnostics for a change it could not report, if it could not, + * each spelled through the remedy allowlist as every relay of them is. + */ +export function upstreamFailure(doc: Record): { message: string }[] | undefined { + if (upstreamArtifacts(doc) !== undefined) return undefined + return Array.isArray(doc.status) ? respellDiagnostics(doc.status) : undefined +} + +const INDICATOR: Record = { + done: '[x]', + skipped: '[~]', + ready: '[ ]', + blocked: '[-]', +} + +/** + * A port of the binary's `printStatusText` (`commands/workflow/status.js`) + * over its `status --json` document, uncoloured, with the `Next:` line from + * `resolveNext` — so nothing the binary wrote as prose is relayed or + * respelled. + */ +export function renderUpstreamHuman( + doc: Record, + next: string | undefined, +): string { + const artifacts = upstreamArtifacts(doc) ?? [] + const done = artifacts.filter((a) => a.status === 'done').length + const skipped = artifacts.filter((a) => a.status === 'skipped').length + const lines = [`Change: ${String(doc.changeName)}`, `Schema: ${String(doc.schemaName)}`] + if (typeof doc.changeRoot === 'string' && doc.changeRoot.length > 0) + lines.push(`Change root: ${doc.changeRoot}`) + lines.push( + `Progress: ${done}/${artifacts.length - skipped} artifacts complete${skipped > 0 ? ` (${skipped} skipped)` : ''}`, + ) + lines.push('') + for (const a of artifacts) { + let line = `${INDICATOR[a.status]} ${a.id}` + if (a.status === 'skipped') line += ' (skipped: change declares skip_specs)' + if (a.status === 'blocked' && a.missingDeps !== undefined && a.missingDeps.length > 0) + line += ` (blocked by: ${a.missingDeps.join(', ')})` + lines.push(line) + } + const complete = doc.isPlanningComplete === true + if (complete || next !== undefined) lines.push('') + if (complete) lines.push('All planning artifacts complete!') + if (next !== undefined) lines.push(`Next: ${next}`) + return `${lines.join('\n')}\n` +} + +function renderEntryHuman( + entry: ChangeEntry | ChangeEntryFailure, + upstream: Record | undefined, +): string { if (isFailure(entry)) return `${entry.change}: ERROR — ${entry.error}\n` if ('legacy' in entry) { - return `${entry.change} (${entry.type}): legacy schema — use \`cospec status --change ${entry.change}\` for details\n` + const failure = upstream === undefined ? undefined : upstreamFailure(upstream) + if (upstream === undefined || failure !== undefined) + return `${entry.change}: ERROR — ${(failure ?? []).map((s) => s.message).join('\n')}\n` + return renderUpstreamHuman(upstream, entry.next) } - if ('next' in entry) { - return `${entry.change} (${entry.type}): in progress — no artifacts yet; next: ${entry.next}\n` + if (entry.state === 'in-progress') { + return emptyHuman(entry) } return renderHuman(entry) } +/** The binary's sweep entries by change name. */ +function sweepEntries(doc: Record): Map> { + const changes = Array.isArray(doc.changes) ? (doc.changes as Record[]) : [] + return new Map(changes.map((c) => [String(c.changeName), c])) +} + /** * `cospec status --all` (OpenSpec 1.11 parity): a cospec-native sweep over * every active change, sorted by id. Unlike a single change lookup, one bad * change never aborts the sweep — it becomes a per-change failure entry and - * the whole run still exits nonzero. + * the whole run still exits nonzero. The binary's sweep is fetched once, and + * only when an entry needs it: under `--json`, for a change on a schema + * cospec doesn't type, or for a change the binary decides whether it can be + * reported at all (`binaryDecides`: an entry cospec cannot read, metadata the + * binary refuses, a schema it cannot load) — and a change it refuses is a failure entry. */ -async function runAll(ctx: CommandContext): Promise { +async function runAll(ctx: CommandContext, override: string | undefined): Promise { const { flags } = ctx - const root = await resolveRoot(ctx) + const root = await resolveRootOrDocument(ctx, 'change_error', BATCH_FAILURE_PAYLOAD) + if (root === undefined) return EXIT.failure const base = root.base - const changes = listChanges(base).toSorted((a, b) => a.id.localeCompare(b.id)) - + // Checked before any change is enumerated, as the binary checks it. + if (override !== undefined && schemaDir(override, base) === undefined) + return refuseUnknownSchema(root, ['--all', ...schemaArgs(override)], flags.json) + const changes = listChanges(base) + .toSorted((a, b) => a.id.localeCompare(b.id)) + .map((change) => gradedChange(base, change, override)) + + const sweepArgs = ['--all', ...schemaArgs(override)] + const cache: DecideCache = { loads: new Map() } + const upstream = + flags.json || + changes.some(answeredUpstream) || + changes.some((change) => binaryDecides(base, change, cache)) + ? await delegatedStatus(root, sweepArgs) + : undefined + const byName = upstream === undefined ? new Map() : sweepEntries(upstream) + + const { archived, warning } = readArchive(base) + const tasksWarnings = new Map() const entries: (ChangeEntry | ChangeEntryFailure)[] = changes.map((change) => { + // A namespace folder is a failure entry carrying its explanation, as the + // binary's sweep carries it. + const nested = namespaceExplanation(base, change.id) + if (nested !== undefined) return { change: change.id, error: nested } + // A cospec-typed change the binary refuses (one it cannot read, say) is a + // failure entry carrying the binary's message; a change on another schema + // keeps its legacy entry, whose failure the binary's entry carries. + if (!answeredUpstream(change)) { + const refused = upstreamFailure(byName.get(change.id) ?? {}) + if (refused !== undefined) + return { change: change.id, error: refused.map((s) => s.message).join('\n') } + } + const own: ReadWarning[] = [] try { - return buildChangeEntry(base, change) + return buildChangeEntry(base, change, byName.get(change.id), archived, own) } catch (err) { return { change: change.id, error: (err as Error).message } + } finally { + if (own.length > 0) tasksWarnings.set(change.id, own) } }) + const warnings = readWarnings(warning, [...tasksWarnings.values()].flat()) + // A change the binary could not report fails the sweep when the binary's + // answer is the one it gets. + const upstreamFailed = entries.some((entry) => { + if (isFailure(entry) || !('legacy' in entry)) return false + const up = byName.get(entry.change) + return up === undefined || upstreamFailure(up) !== undefined + }) if (flags.json) { - process.stdout.write(`${JSON.stringify({ changes: entries, root: base }, null, 2)}\n`) + const inProgress = new Set(entries.filter(isInProgress).map((e) => e.change)) + const changes = Array.isArray(upstream!.changes) ? upstream!.changes : [] + const sweep = { + ...upstream!, + changes: changes.map((c: unknown) => + isRecord(c) && inProgress.has(String(c.changeName)) ? forInProgress(c) : c, + ), + } + const doc = mergeUpstream( + { changes: entries, root: rootOutput(root), ...warningsKey(warnings) }, + respelledUpstream(sweep), + SWEEP_IDENTITIES, + ).value + process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`) } else if (entries.length === 0) { process.stdout.write('cospec status: no active changes\n') } else { - process.stdout.write(entries.map(renderEntryHuman).join('\n')) + printWarnings(warnings) + process.stdout.write( + entries.map((entry) => renderEntryHuman(entry, byName.get(entry.change))).join('\n'), + ) } - return entries.some(isFailure) ? EXIT.failure : EXIT.success + return entries.some(isFailure) || upstreamFailed ? EXIT.failure : EXIT.success } const MUTEX_MESSAGE = 'The --all and --change options are mutually exclusive.' +/** The binary's `--all --json` failure null-shape (`BATCH_STATUS_FAILURE_PAYLOAD`). */ +const BATCH_FAILURE_PAYLOAD = { changes: [], root: null } as const + /** * A lookup refusal under `--json`: one document on stdout in upstream's * `failWithError` shape (`{status: [{severity, code, message}]}`, code @@ -252,9 +741,136 @@ function changeErrorDocument(message: string): number { return EXIT.failure } -export async function run(ctx: CommandContext): Promise { +/** Array identities between cospec's status documents and the binary's. */ +const ENTRY_IDENTITIES: Identities = { 'artifacts[]': { cospec: 'id', upstream: 'id' } } +const SWEEP_IDENTITIES: Identities = { + 'changes[]': { cospec: 'change', upstream: 'changeName' }, + 'changes[].artifacts[]': { cospec: 'id', upstream: 'id' }, +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * The one delegated `openspec status … --json` call an invocation makes + * (design D4): the binary's own document for `args` (`--change ` or + * `--all`), in the resolved root. Its failure document (`status`, exit 1) + * is an answer, not a violation; anything but one document naming the change + * (or the sweep) or carrying `status` is. + */ +async function delegatedStatus( + root: ResolvedRoot, + args: string[], +): Promise> { + const change = args[0] === '--change' ? args[1] : undefined + const label = wrappedCallLabel(['status', '--json', ...root.storeArgs, ...args]) + let doc: Record | undefined + await passthroughOpenspec( + { command: ['status'], threaded: ['--json', ...root.storeArgs], args }, + { + cwd: root.cwd, + expect: { + exitCodes: [0, 1], + postCondition: (result) => { + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + return `${label} did not print one JSON document` + } + if (!isRecord(parsed)) return `${label} printed no JSON object` + const named = + change === undefined ? Array.isArray(parsed.changes) : parsed.changeName === change + if (!named && !Array.isArray(parsed.status)) + return `${label} printed neither the change's status nor a diagnostic` + doc = parsed + return true + }, + }, + }, + ) + return doc! +} + +/** The binary's diagnostics, each `message` and `fix` spelled through the remedy allowlist. */ +function respellDiagnostics(status: unknown[]): { message: string }[] { + return status.map((d) => { + if (!isRecord(d)) return d as { message: string } + const fix = typeof d.fix === 'string' ? { fix: respellRemedies(d.fix) } : {} + const message = typeof d.message === 'string' ? respellRemedies(d.message) : d.message + return { ...d, message, ...fix } as { message: string } + }) +} + +/** + * A binary status entry with each `nextSteps` sentence spelled through + * cospec, and each `status[]` diagnostic's message and fix. + */ +function respellEntry(entry: unknown): unknown { + if (!isRecord(entry)) return entry + const steps = Array.isArray(entry.nextSteps) + ? { + nextSteps: entry.nextSteps.map((step: unknown) => + typeof step === 'string' ? respellWholeRemedy(step) : step, + ), + } + : {} + const status = Array.isArray(entry.status) ? { status: respellDiagnostics(entry.status) } : {} + return { ...entry, ...steps, ...status } +} + +/** The binary's document, single or sweep, its remedies spelled through cospec. */ +export function respelledUpstream(doc: Record): Record { + const single = respellEntry(doc) as Record + return Array.isArray(single.changes) + ? { ...single, changes: single.changes.map(respellEntry) } + : single +} + +/** + * The binary's entry for an in-progress cospec change, without its + * `artifacts`: the entry's `artifacts: []` is cospec's own pre-existing value, + * so the binary's artifact objects are never appended into it (D3 never + * overwrites a cospec value). + */ +function forInProgress(upstream: Record): Record { + const { artifacts: _artifacts, ...rest } = upstream + return rest +} + +function isInProgress(entry: unknown): boolean { + return isRecord(entry) && entry.state === 'in-progress' +} + +/** cospec's entry, the binary's document for the same change merged in, and `root`. */ +function mergedEntry( + root: ResolvedRoot, + entry: Record, + upstream: Record, +): Record { + return mergeUpstream( + { ...entry, root: rootOutput(root) }, + respelledUpstream(isInProgress(entry) ? forInProgress(upstream) : upstream), + ENTRY_IDENTITIES, + ).value +} + +/** + * `cospec status`: an errno failure it lets escape (an unreadable + * `openspec/changes/`) is the binary's one `change_error` document under + * `--json`, carrying the sweep's null-shape for `--all`. + */ +export function run(ctx: CommandContext): Promise { + const payload = hasFlag(ctx.parsed!, '--all') ? BATCH_FAILURE_PAYLOAD : {} + return answeringErrno(ctx.flags.json, { code: 'change_error', payload }, () => status(ctx)) +} + +async function status(ctx: CommandContext): Promise { const { flags } = ctx const parsed = ctx.parsed! + // A schema override, as the binary's `--schema` is — never a filter. + const override = flagValue(parsed, '--schema') // A positional beside `--change` or `--all` never gets here: the table // refuses it as an excess argument, as upstream (which has none) does. @@ -273,10 +889,11 @@ export async function run(ctx: CommandContext): Promise { } return EXIT.failure } - return runAll(ctx) + return runAll(ctx, override) } - const root = await resolveRoot(ctx) + const root = await resolveRootOrDocument(ctx, 'change_error') + if (root === undefined) return EXIT.failure const base = root.base let id = flagValue(parsed, '--change') ?? parsed.positionals[0] @@ -287,7 +904,7 @@ export async function run(ctx: CommandContext): Promise { } else if (active.length === 0) { process.stdout.write( flags.json - ? `${JSON.stringify({ changes: [], root: base, message: 'No active changes.' }, null, 2)}\n` + ? `${JSON.stringify({ changes: [], message: 'No active changes.', root: rootOutput(root) }, null, 2)}\n` : 'cospec status: no active changes\n', ) return EXIT.success @@ -302,8 +919,8 @@ export async function run(ctx: CommandContext): Promise { } } - const change = resolveChange(base, id) - if (change === undefined) { + const found = resolveChange(base, id) + if (found === undefined) { const suggestion = closest( id, active.map((c) => c.id), @@ -316,57 +933,82 @@ export async function run(ctx: CommandContext): Promise { if (suggestion !== undefined) process.stderr.write(`Did you mean '${suggestion}'?\n`) return EXIT.failure } + // A namespace folder is refused, as the binary refuses it. + const nested = namespaceExplanation(base, found.id) + if (nested !== undefined) { + if (flags.json) return changeErrorDocument(nested) + process.stderr.write(`cospec status: ${nested}\n`) + return EXIT.failure + } - // Empty change: has .openspec.yaml but no artifacts yet (never "Unknown item"). - if (!hasAnyArtifact(change.dir)) { + // Checked after the change resolves and before it is reported, as the + // binary checks it; with neither `--change` nor `--all` it is not checked. + if ( + override !== undefined && + flagValue(parsed, '--change') !== undefined && + schemaDir(override, base) === undefined + ) + return refuseUnknownSchema(root, ['--change', found.id, ...schemaArgs(override)], flags.json) + const change = gradedChange(base, found, override) + + // A schema cospec doesn't type: the binary's own status answers it, in + // both modes, with the binary's outcome. + if (answeredUpstream(change)) { + const upstream = await delegatedStatus(root, ['--change', change.id, ...schemaArgs(override)]) + const failure = upstreamFailure(upstream) + const entry = legacyChangeEntry(change, upstream) if (flags.json) { - process.stdout.write( - `${JSON.stringify( - { - change: change.id, - type: change.schema, - state: 'in-progress', - artifacts: [], - gate: 'clear', - archiveReady: false, - next: `cospec instructions proposal --change ${change.id}`, - }, - null, - 2, - )}\n`, - ) + process.stdout.write(`${JSON.stringify(mergedEntry(root, entry, upstream), null, 2)}\n`) + } else if (failure !== undefined) { + for (const s of failure) process.stderr.write(`cospec status: ${s.message}\n`) } else { - process.stdout.write( - `${change.id} (${change.schema}): in progress — no artifacts yet; next: cospec instructions proposal --change ${change.id}\n`, - ) + process.stdout.write(renderUpstreamHuman(upstream, entry.next)) } - return EXIT.success + return failure === undefined ? EXIT.success : EXIT.failure } - // Legacy / unknown schema: no cospec artifact matrix. `--json` reports it - // minimally; text relays the binary's own status for the change, its - // `Next:` remedy spelled through cospec. - if (!isCospecType(change.schema)) { - if (flags.json) { - process.stdout.write( - `${JSON.stringify({ change: change.id, type: change.schema, legacy: true }, null, 2)}\n`, - ) - return EXIT.success - } - const result = await passthroughOpenspec( - { - command: ['status'], - threaded: [...(flags.noColor ? ['--no-color'] : []), ...root.storeArgs], - args: ['--change', change.id], - }, - { cwd: root.cwd }, - ) - if (result.stdout.length > 0) process.stdout.write(respellRemedies(result.stdout)) - if (result.stderr.length > 0) process.stderr.write(respellRemedies(result.stderr)) - return result.exitCode === 0 ? EXIT.success : EXIT.failure + // The binary's status for the change, under `--json` or when the binary + // decides whether it can be reported (`binaryDecides`): any error in it is + // the binary's refusal, and the answer — its document under `--json`, its + // message in text. + const upstream = + flags.json || binaryDecides(base, change) + ? await delegatedStatus(root, ['--change', change.id, ...schemaArgs(override)]) + : undefined + const refused = upstream === undefined ? undefined : upstreamFailure(upstream) + if (refused !== undefined) { + if (flags.json) + process.stdout.write(`${JSON.stringify(respelledUpstream(upstream!), null, 2)}\n`) + else for (const s of refused) process.stderr.write(`cospec status: ${s.message}\n`) + return EXIT.failure } - const status = computeStatus(base, change) - process.stdout.write(flags.json ? `${JSON.stringify(status, null, 2)}\n` : renderHuman(status)) + // Empty change: has .openspec.yaml but no artifacts yet (never "Unknown item"). + // A tasks.md the binary reads past counts as no tasks, with a warning. + const { archived, warning } = readArchive(base) + const tasksWarnings: ReadWarning[] = [] + let entry: ChangeEntry + try { + entry = buildChangeEntry(base, change, undefined, archived, tasksWarnings) + } catch (error) { + // A change file that cannot be read fails the lookup, as the binary's does. + const message = readFailure(error) + if (message === undefined) throw error + if (flags.json) return changeErrorDocument(message) + process.stderr.write(`cospec status: ${message}\n`) + return EXIT.failure + } + const warnings = readWarnings(warning, tasksWarnings) + if (!flags.json) { + printWarnings(warnings) + process.stdout.write( + 'state' in entry && entry.state === 'in-progress' + ? emptyHuman(entry) + : renderHuman(entry as ChangeStatus), + ) + return EXIT.success + } + const doc = mergedEntry(root, { ...entry, ...warningsKey(warnings) }, upstream!) + process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`) return EXIT.success } diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index d2c7dcaf..0771f15a 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -14,28 +14,46 @@ import type { CommandContext } from '../cli.ts' import { readRetireCapabilitiesMarker } from '../core/change-metadata.ts' import { archiveDir, + changesDir, + describeNestedChange, + findNestedChangesIn, isValidSchemaVersion, listChanges, openspecDir, - resolveChange, resolveSchema, } from '../core/change.ts' -import { hasFlag } from '../core/command-table.ts' +import { flagValue, hasFlag } from '../core/command-table.ts' import { parseLivingSpec } from '../core/deltas.ts' -import { spawnOpenspec, type Root, threadedArgv } from '../core/openspec.ts' +import { answeringErrno, errnoMessage } from '../core/errno.ts' +import { + isOpenspecErrorStatus, + openspecBelow, + runOpenspec, + type Root, + type StatusDiagnostic, + threadedArgv, + wrappedCallLabel, + wrappedOpenspecVersion, +} from '../core/openspec.ts' +import { respellRemedies } from '../core/remedies.ts' import { exitCode as reportExitCode, renderHuman, - renderJson, + toFindings, + toJson, + type FindingsScope, type ItemReport, } from '../core/report.ts' -import { resolveRoot } from '../core/root.ts' +import { type ResolvedRoot, RootSelectionError, rootSelectionDocument } from '../core/root.ts' import { runChangeRules, specsRules } from '../core/rules/index.ts' import type { Issue, IssueLevel } from '../core/rules/issue.ts' import { + itemMissingIssue, nameKebabIssues, + nestedChangeIssue, openspecYamlIssues, schemaClassificationIssues, + unreadableArtifactIssue, } from '../core/rules/meta.ts' import { deriveSchemaInfo, @@ -53,36 +71,108 @@ import { } from '../core/rules/type-facts.ts' import { capabilityForDeltaFile, + type DiscoveredSpec, discoverSpecFiles, isDeltaSpecFile, unreadDeltaExpectation, } from '../core/spec-paths.ts' +import { resolveRootOrDocument, rootOutput } from '../core/upstream-keys.ts' +import type { ArchiveWarning } from './status.ts' // --- Change loading (filesystem → LoadedChange) --------------------------- -function listFilesRelative(dir: string): string[] { - const out: string[] = [] - const walk = (abs: string): void => { - for (const entry of readdirSync(abs, { withFileTypes: true })) { - const child = join(abs, entry.name) - if (entry.isDirectory()) walk(child) - else if (entry.isFile()) out.push(relative(dir, child).split(sep).join('/')) +/** + * A file that exists but could not be read: the change-relative path its + * issue is reported against, its errno code, and — for a file outside the + * change (the living spec a delta targets) — the root-relative file itself. + */ +interface ReadFailure { + path: string + code: string + file?: string +} + +/** + * The one way a change is read (design D7): every errno but `ENOENT` is + * recorded against the file's change-relative path instead of thrown, so an + * unreadable artifact fails the change — `meta/unreadable-artifact` — never + * the command. Anything that is not an errno failure propagates. + */ +class ChangeReader { + readonly failures: ReadFailure[] = [] + + constructor(private readonly dir: string) {} + + private record(error: unknown, abs: string, as?: { path: string; file: string }): void { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (!(error instanceof Error) || typeof code !== 'string') throw error + if (code === 'ENOENT') return + this.failures.push( + as === undefined + ? { path: relative(this.dir, abs).split(sep).join('/') || '.', code } + : { ...as, code }, + ) + } + + /** + * A file's text, `undefined` when it is absent or could not be read. `as` + * reports a file outside the change against a change path instead. + */ + read(abs: string, as?: { path: string; file: string }): string | undefined { + try { + return readFileSync(abs, 'utf8') + } catch (error) { + this.record(error, abs, as) + return undefined } } - walk(dir) - return out.toSorted() + + /** + * Every file under the change, change-relative and sorted. An unreadable + * directory an artifact can live in — the change itself, or `specs/` and a + * subtree of it outside every dot-directory — is recorded. Any other (a + * dot-directory, a scratch directory) is one neither cospec's artifacts nor + * the binary ever read: its files could only ever be `meta/unexpected-file` + * advisories, so the walk passes it by. + */ + files(): string[] { + const out: string[] = [] + const walk = (abs: string): void => { + let entries + try { + entries = readdirSync(abs, { withFileTypes: true }) + } catch (error) { + const rel = relative(this.dir, abs).split(sep).join('/') + if (holdsArtifacts(rel)) this.record(error, abs) + else if (typeof (error as NodeJS.ErrnoException | undefined)?.code !== 'string') throw error + return + } + for (const entry of entries) { + const child = join(abs, entry.name) + if (entry.isDirectory()) walk(child) + else if (entry.isFile()) out.push(relative(this.dir, child).split(sep).join('/')) + } + } + walk(this.dir) + return out.toSorted() + } } -function readIfExists(path: string): string | undefined { - return existsSync(path) ? readFileSync(path, 'utf8') : undefined +/** Whether a change-relative directory can hold an artifact: the change, or `specs/` outside dot-directories. */ +function holdsArtifacts(rel: string): boolean { + if (rel === '') return true + const segments = rel.split('/') + return segments[0] === 'specs' && !segments.some((segment) => segment.startsWith('.')) } -function loadOpenspecYaml(changeDir: string): LoadedChange['openspecYaml'] { +function loadOpenspecYaml(changeDir: string, reader: ChangeReader): LoadedChange['openspecYaml'] { const path = join(changeDir, '.openspec.yaml') if (!existsSync(path)) return { present: false, parseable: false } + const text = reader.read(path) + if (text === undefined) return { present: true, parseable: false } let doc: unknown try { - doc = parseYaml(readFileSync(path, 'utf8')) + doc = parseYaml(text) } catch { return { present: true, parseable: false } } @@ -117,9 +207,14 @@ function loadOpenspecYaml(changeDir: string): LoadedChange['openspecYaml'] { } } -function loadChange(base: string, id: string, dir: string): LoadedChange { - const files = existsSync(dir) ? listFilesRelative(dir) : [] - const designText = readIfExists(join(dir, 'design.md')) +function loadChange( + base: string, + id: string, + dir: string, +): { load: LoadedChange; unreadable: ReadFailure[] } { + const reader = new ChangeReader(dir) + const files = existsSync(dir) ? reader.files() : [] + const designText = reader.read(join(dir, 'design.md')) // Capability comes from the file's whole path under `specs/`, not its first // segment: the nested `specs///spec.md` layout openspec grew // in 1.6.0 is one capability named `/`, and that is the name @@ -135,7 +230,7 @@ function loadChange(base: string, id: string, dir: string): LoadedChange { .map((f) => ({ path: f, capability: capabilityForDeltaFile(f) ?? '', - text: readFileSync(join(dir, f), 'utf8'), + text: reader.read(join(dir, f)) ?? '', })) // Everything else under `specs/` that a merge would never read. Only @@ -153,25 +248,30 @@ function loadChange(base: string, id: string, dir: string): LoadedChange { .map((f) => ({ path: f, expected: unreadDeltaExpectation(f), - text: readFileSync(join(dir, f), 'utf8'), + text: reader.read(join(dir, f)) ?? '', })) + // The living spec each delta targets is read like an artifact: one that + // cannot be read fails the change against the delta's path, naming the file. const livingSpecs: LoadedChange['livingSpecs'] = new Map() for (const cap of new Set(deltaFiles.map((d) => d.capability))) { if (cap === '') continue const livingPath = join(openspecDir(base), 'specs', ...cap.split('/'), 'spec.md') - if (existsSync(livingPath)) - livingSpecs.set(cap, parseLivingSpec(readFileSync(livingPath, 'utf8'))) + const text = reader.read(livingPath, { + path: `specs/${cap}/spec.md`, + file: `openspec/specs/${cap}/spec.md`, + }) + if (text !== undefined) livingSpecs.set(cap, parseLivingSpec(text)) } - return { + const load: LoadedChange = { id, - openspecYaml: loadOpenspecYaml(dir), + openspecYaml: loadOpenspecYaml(dir, reader), files, - proposalText: readIfExists(join(dir, 'proposal.md')), - blockersText: readIfExists(join(dir, 'blocking-changes.md')), - tasksText: readIfExists(join(dir, 'tasks.md')), - verificationText: readIfExists(join(dir, 'verification.md')), + proposalText: reader.read(join(dir, 'proposal.md')), + blockersText: reader.read(join(dir, 'blocking-changes.md')), + tasksText: reader.read(join(dir, 'tasks.md')), + verificationText: reader.read(join(dir, 'verification.md')), designExists: designText !== undefined, designText, deltaFiles, @@ -179,6 +279,7 @@ function loadChange(base: string, id: string, dir: string): LoadedChange { livingSpecs, retireMarker: readRetireCapabilitiesMarker(dir), } + return { load, unreadable: reader.failures } } /** @@ -219,6 +320,7 @@ interface OpenspecItem { id: string valid: boolean issues: OpenspecIssue[] + durationMs?: number } interface OpenspecValidateJson { items: OpenspecItem[] @@ -266,7 +368,8 @@ function mapDelegated(issue: OpenspecIssue, deltaPaths = false): Issue { rule: 'openspec/validate', path, line: issue.line, - message: issue.message, + // Every allowlisted upstream remedy spelled through cospec, every other byte as written. + message: respellRemedies(issue.message), } } @@ -282,11 +385,19 @@ function mapDelegated(issue: OpenspecIssue, deltaPaths = false): Issue { * that may be narrower, so the wrapped binary stays the safety net rather than * becoming noise to filter. */ +/** + * A delegated-message matcher: a `RegExp`, or a function-backed matcher of + * the same `exec` shape where one regex could not match in linear time. + */ +interface MessageMatcher { + exec(message: string): readonly (string | undefined)[] | null +} + interface DuplicateClass { /** the cospec rule whose finding already covers this defect. */ rule: string - /** the delegated message for the same defect. */ - delegated: RegExp + /** the delegated message for the same defect; `[1]` is its key when `nativeKey` is set. */ + delegated: MessageMatcher /** * When set, the two findings must also name the same requirement and sit on * the same file: a *different* requirement's loss is a second real finding @@ -296,6 +407,37 @@ interface DuplicateClass { nativeKey?: RegExp } +/** The fixed head of 1.13.1's structurally-invalid refusal; `[1]` is the capability. */ +export const TARGET_INVALID_HEAD = + /^Archive would refuse this delta: (.+?): target spec is structurally invalid and cannot be updated until fixed:$/ + +/** + * One defect line of that refusal, of a kind cospec's rule reads. The quoted + * header is spec content — the author's own text, `"` included — so its span + * is `.*` up to the fixed suffix, on one line; no group repeats around it. + */ +export const TARGET_INVALID_LINE = + /^line \d+: (?:Main spec contains delta header ".*"\.|Requirement header ".*" (?:duplicates the requirement declared on line \d+\.|appears outside the main ## Requirements section\.))/ + +/** + * The `archive/target-invalid` twin, matched in linear time (design D7): the + * fixed head once, then each line on its own. One regex over the whole list + * backtracked a quoted span against its trailing text once per repeated line — + * exponential on a quote-heavy message (CodeQL js/redos) — and narrowing the + * span to `[^"\n]*` to stop that missed every header holding a `"`. + */ +export const TARGET_INVALID: MessageMatcher = { + exec(message: string) { + const lines = message.split('\n') + if (lines.at(-1) === '') lines.pop() + const head = TARGET_INVALID_HEAD.exec(lines[0] ?? '') + if (head === null || lines.length < 2) return null + return lines.slice(1).every((line) => TARGET_INVALID_LINE.test(line)) + ? [message, head[1]] + : null + }, +} + const DUPLICATE_CLASSES: readonly DuplicateClass[] = [ // 1.11.0 purpose-placeholder vs specs/purpose-tbd. { rule: 'specs/purpose-tbd', delegated: /^Purpose section is still a placeholder/ }, @@ -631,19 +773,11 @@ const DUPLICATE_CLASSES: readonly DuplicateClass[] = [ // keyed on the capability both messages name. Only when every defect the // binary lists is one of the three kinds cospec's rule reads — a delta // header, a misplaced or a duplicate requirement — so a listed defect - // cospec does not check still reaches the reader. - // - // The quoted header text is spec content, not cospec's own — an attacker - // could seed a heading with repeated `".`-like runs. `[^\n]*"` before a - // required literal let the engine backtrack the quoted span against the - // trailing `[^\n]*` once per repeated "line N: …" entry, which is - // exponential in the number of lines (CodeQL js/redos). `[^"\n]*` makes - // each quoted span's end unambiguous — real header text never contains a - // literal `"` — so there is exactly one way to match and no backtracking. + // cospec does not check still reaches the reader. Matched line by line + // (`TARGET_INVALID`), linear in the message whatever its quoted headers hold. { rule: 'archive/target-invalid', - delegated: - /^Archive would refuse this delta: (.+?): target spec is structurally invalid and cannot be updated until fixed:(?:\nline \d+: (?:Main spec contains delta header "[^"\n]*"\.|Requirement header "[^"\n]*" (?:duplicates the requirement declared on line \d+\.|appears outside the main ## Requirements section\.))[^\n]*)+\n?$/, + delegated: TARGET_INVALID, nativeKey: /^living spec openspec\/specs\/(.+?)\/spec\.md is structurally invalid — /, }, // 1.13.1's two case-collision refusals, paired with the fold arms @@ -684,22 +818,70 @@ export function mergeDelegated(native: Issue[], delegated: Issue[]): Issue[] { } /** - * Run `openspec validate ` and return its parsed items. Tolerant: a - * non-JSON body (e.g. the plain-text `Unknown item` an artifact-less change - * yields) resolves to no items rather than throwing — cospec's own rules - * already diagnose those states. + * The binary's answer to one delegated `openspec validate --json`: its + * report's items, or — when it refused the request (an `ambiguous_item`, an + * errno it could not read past) — its failure document's diagnostics. + */ +type Delegated = { items: OpenspecItem[] } | { refused: StatusDiagnostic[] } + +/** + * Run `openspec validate ` under the wrapped-call discipline: exit 0 or + * 1, and one JSON document that is either a validation report or a failure + * document. A refusal is an answer the caller reports, never a silent empty + * report. */ -async function delegate(root: Root, args: string[]): Promise { - const res = await spawnOpenspec( - threadedArgv(['validate'], ['--strict', '--json', '--no-interactive', ...root.storeArgs], args), - root.cwd, +async function delegate(root: Root, args: string[]): Promise { + const argv = threadedArgv( + ['validate'], + ['--strict', '--json', '--no-interactive', ...root.storeArgs], + args, ) - try { - const parsed = JSON.parse(res.stdout) as OpenspecValidateJson - return Array.isArray(parsed.items) ? parsed.items : [] - } catch { - return [] - } + const label = wrappedCallLabel(argv) + let answer: Delegated | undefined + await runOpenspec(argv, { + cwd: root.cwd, + expect: { + exitCodes: [0, 1], + postCondition: (result) => { + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + return `${label} did not print one JSON document` + } + if (isOpenspecErrorStatus(parsed)) { + answer = { refused: (parsed as { status: StatusDiagnostic[] }).status } + return true + } + const items = (parsed as Partial | null)?.items + if (!Array.isArray(items)) + return `${label} printed neither a validation report nor a diagnostic` + answer = { items } + return true + }, + }, + }) + return answer! +} + +/** + * The binary's issues for item `id` from a delegated answer. A refusal is the + * item's: each diagnostic one `openspec/validate` issue at its severity, its + * remedies spelled through cospec, so the item fails rather than passing on + * an empty report. + */ +function delegatedIssues(answer: Delegated, id: string, deltaPaths: boolean): Issue[] { + if ('refused' in answer) + return answer.refused.map((d) => ({ + level: normalizeLevel(d.severity), + rule: 'openspec/validate', + path: '.', + message: respellRemedies(d.message), + ...(d.fix === undefined ? {} : { hint: respellRemedies(d.fix) }), + })) + return answer.items + .filter((item) => item.id === id) + .flatMap((item) => item.issues.map((i) => mapDelegated(i, deltaPaths))) } // --- per-item validation --------------------------------------------------- @@ -739,15 +921,53 @@ export function archivedSlugsFor(dirName: string): string[] { return [stripped, dirName] } -export function buildValidateContext(base: string): ValidateContext { - const archiveSlugs = new Set( +/** Every slug `openspec/changes/archive/` could hold; throws when it cannot be read. */ +function archiveSlugsIn(base: string): Set { + return new Set( existsSync(archiveDir(base)) ? readdirSync(archiveDir(base), { withFileTypes: true }) .filter((e) => e.isDirectory()) .flatMap((e) => archivedSlugsFor(e.name)) : [], ) - return { archiveSlugs, activeSlugs: new Set(listChanges(base).map((c) => c.id)) } +} + +/** The validate context as `archive` reads it: an unreadable archive refuses. */ +export function buildValidateContext(base: string): ValidateContext { + return { + archiveSlugs: archiveSlugsIn(base), + activeSlugs: new Set(listChanges(base).map((c) => c.id)), + } +} + +/** + * The validate context `validate` and `apply` read (task 11.7). The binary's + * `validate` and `instructions apply` never read `openspec/changes/archive/`, + * so an unreadable one must not fail them: it is read as empty, which can only + * add an issue (a blocker or revert citation naming an archived change reads + * as dangling) or keep a blocker open, never clear one, and the warning names + * the directory. `archive` keeps `buildValidateContext`, which refuses. + */ +export function readValidateContext(base: string): { + ctx: ValidateContext + warning?: ArchiveWarning +} { + const activeSlugs = new Set(listChanges(base).map((c) => c.id)) + try { + return { ctx: { archiveSlugs: archiveSlugsIn(base), activeSlugs } } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (typeof code !== 'string' || code === 'ENOENT') throw error + return { + ctx: { archiveSlugs: new Set(), activeSlugs }, + warning: { + code: 'archive_unreadable', + message: + `could not read ${archiveDir(base)} (${code}); validation and blocker gates are computed ` + + 'as if no change were archived', + }, + } + } } /** @@ -761,7 +981,26 @@ export async function validateChange( ctx: ValidateContext, opts: { strict: boolean; fast: boolean }, ): Promise { - const load = loadChange(root.base, change.id, change.dir) + // A namespace folder is reported as one, and nothing else runs on it. + const nested = findNestedChangesIn(changesDir(root.base), change.id) + if (nested !== undefined) + return buildReport( + change.id, + [nestedChangeIssue(describeNestedChange(nested))], + undefined, + opts.strict, + ) + + const { load, unreadable } = loadChange(root.base, change.id, change.dir) + // An artifact that cannot be read fails the change, not the command, and + // nothing is delegated for it. + if (unreadable.length > 0) + return buildReport( + change.id, + unreadable.map((f) => unreadableArtifactIssue(f.path, f.code, f.file)), + load.openspecYaml.schema, + opts.strict, + ) const y = load.openspecYaml // meta/openspec-yaml precondition — cannot classify without a schema. @@ -782,10 +1021,8 @@ export async function validateChange( load.deltaFiles.length > 0 && load.proposalText !== undefined ) { - const items = await delegate(root, [change.id]) - const delegated = items - .filter((item) => item.id === change.id) - .flatMap((item) => item.issues.map((i) => mapDelegated(i, true))) + const answer = await delegate(root, [change.id, '--type', 'change']) + const delegated = delegatedIssues(answer, change.id, true) return buildReport(change.id, mergeDelegated(issues, delegated), y.schema, opts.strict) } return buildReport(change.id, issues, y.schema, opts.strict) @@ -798,11 +1035,11 @@ export async function validateChange( ...nameKebabIssues(change.id), ...schemaClassificationIssues(stub), ] - if (resolution.kind === 'legacy') { - const items = await delegate(root, [change.id]) - for (const item of items) - if (item.id === change.id) issues.push(...item.issues.map((i) => mapDelegated(i, true))) - } + // `--type change`: a change sharing a living spec's name is still a change. + if (resolution.kind === 'legacy') + issues.push( + ...delegatedIssues(await delegate(root, [change.id, '--type', 'change']), change.id, true), + ) return buildReport(change.id, issues, y.schema, opts.strict) } @@ -812,64 +1049,449 @@ export async function validateChange( * openspec itself uses (`/`) rather than being missed * entirely by a one-level readdir. */ -function livingSpecFiles(base: string): { id: string; specFile: string }[] { - return discoverSpecFiles(join(openspecDir(base), 'specs')) +function livingSpecFiles(base: string): DiscoveredSpec[] { + return discoverSpecFiles(join(openspecDir(base), 'specs'), { reportUnreadable: true }) +} + +/** A living spec's text, or the errno code that kept it from being read (`meta/unreadable-artifact`). */ +type LivingRead = { text: string } | { code: string } + +function readLivingSpec(cap: DiscoveredSpec): LivingRead { + if (cap.unreadable !== undefined) return { code: cap.unreadable } + try { + return { text: readFileSync(cap.specFile, 'utf8') } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (!(error instanceof Error) || typeof code !== 'string' || code === 'ENOENT') throw error + return { code } + } } -async function validateSpecs(root: Root, only: string | undefined): Promise { +async function validateSpecs( + root: Root, + only: string | undefined, + strict: boolean, +): Promise { const caps = livingSpecFiles(root.base).filter((c) => only === undefined || c.id === only) if (caps.length === 0) return [] - const delegated = new Map() - for (const item of await delegate(root, ['--specs'])) delegated.set(item.id, item.issues) + // One delegation serves every spec, so each item's time runs from its start. + const start = Date.now() + const reads = caps.map((cap) => ({ cap, read: readLivingSpec(cap) })) + const delegated = new Map() + const readable = reads.filter(({ read }) => 'text' in read).map(({ cap }) => cap.id) + const alone = async (): Promise => { + for (const id of readable) + delegated.set(id, delegatedIssues(await delegate(root, [id, '--type', 'spec']), id, false)) + } + // The binary's sweep answers for every spec only in a sweep where every + // spec is readable. A named spec is asked for alone, as is every readable + // spec when one is not (the binary's sweep may refuse the whole run over it: + // Bun's `realpath` on macOS) or when the sweep refuses anyway. + if (only === undefined && readable.length === reads.length) { + const sweep = await delegate(root, ['--specs']) + if ('items' in sweep) + for (const item of sweep.items) + delegated.set( + item.id, + item.issues.map((i) => mapDelegated(i)), + ) + else await alone() + } else await alone() - return caps.map((cap) => { - const path = `specs/${cap.id}/spec.md` - const living = parseLivingSpec(readFileSync(cap.specFile, 'utf8')) - const issues = mergeDelegated( - specsRules(living, path), - (delegated.get(cap.id) ?? []).map((i) => mapDelegated(i)), - ) - const errors = issues.filter((i) => i.level === 'ERROR').length - return { id: cap.id, kind: 'spec' as const, valid: errors === 0, issues } - }) + return reads.map(({ cap, read }) => + specReport(cap.id, read, delegated.get(cap.id) ?? [], start, strict), + ) +} + +/** One living spec's report: cospec's spec rules merged with the binary's issues for it. */ +function specReport( + id: string, + read: LivingRead, + delegated: readonly Issue[], + start: number, + strict: boolean, +): ItemReport { + const path = `specs/${id}/spec.md` + const issues = + 'code' in read + ? [unreadableArtifactIssue(path, read.code)] + : mergeDelegated(specsRules(parseLivingSpec(read.text), path), [...delegated]) + const errors = issues.filter((i) => i.level === 'ERROR').length + const warnings = issues.filter((i) => i.level === 'WARNING').length + // `--strict` fails a spec on a warning, as the binary's `createReport` does. + const valid = errors === 0 && (!strict || warnings === 0) + const durationMs = Date.now() - start + return { id, kind: 'spec' as const, valid, issues, durationMs } } +/** + * `validate --type spec` on a spec file discovery skips (a dot-directory, + * a capability behind a linked directory): the binary's `validateDirectItem` + * validates the file at `specs//spec.md` anyway, and its bulk `--specs` + * sweep skips it too, so the binary is asked for that one item. + */ +async function validateForcedSpec(root: Root, id: string, strict: boolean): Promise { + const start = Date.now() + const specFile = join(openspecDir(root.base), 'specs', ...id.split('/'), 'spec.md') + const read = readLivingSpec({ id, specFile }) + const delegated = + 'code' in read ? [] : delegatedIssues(await delegate(root, [id, '--type', 'spec']), id, false) + return [specReport(id, read, delegated, start, strict)] +} + +/** The first openspec release whose `validate` takes `--archived`. */ +const ARCHIVED_SINCE = '1.9.0' + +/** + * The binary's answer to `validate --archived`: its report's items, or its + * failure document (an unreadable `changes/archive/`, say) with its exit code. + */ +type ArchivedAnswer = + | { items: ItemReport[] } + | { failure: { status: StatusDiagnostic[] } & Record; exitCode: number } + /** * `cospec validate --archived` — pure delegation (openspec >= 1.9.0). The * wrapped binary walks `changes/archive/` and reports any archived change whose * tasks are not all complete; cospec has no native rule family for archived - * changes, so nothing is merged in. Its envelope is relayed through cospec's - * own renderer so the output and exit code match every other validate surface. - * - * Returns `undefined` when the wrapped binary produced no parseable envelope — - * an openspec below 1.9.0 rejects the flag — so the caller can relay the - * wrapped diagnostics verbatim instead of printing an empty, passing report. + * changes, so nothing is merged in. Its report is relayed through cospec's own + * renderer so the output and exit code match every other validate surface; + * its failure document is the answer as it stands. Whether the binary is too + * old for the flag is read from its version, never guessed from its output. */ -async function validateArchived(root: Root): Promise { - const res = await spawnOpenspec( - threadedArgv(['validate'], ['--json', '--no-interactive', ...root.storeArgs], ['--archived']), - root.cwd, +async function validateArchived(root: Root): Promise { + const args = threadedArgv( + ['validate'], + ['--json', '--no-interactive', ...root.storeArgs], + ['--archived'], ) - let parsed: OpenspecValidateJson - try { - parsed = JSON.parse(res.stdout) as OpenspecValidateJson - } catch { - process.stderr.write(res.stderr) - return undefined - } - if (!Array.isArray(parsed.items)) return undefined - return parsed.items.map((item) => ({ - id: item.id, - kind: 'change' as const, - valid: item.valid, - issues: item.issues.map((i) => mapDelegated(i, true)), + const label = wrappedCallLabel(args) + let answer: ArchivedAnswer | undefined + await runOpenspec(args, { + cwd: root.cwd, + expect: { + exitCodes: [0, 1], + postCondition: (result) => { + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + return `${label} did not print one JSON document` + } + if (isOpenspecErrorStatus(parsed)) { + answer = { + failure: parsed as { status: StatusDiagnostic[] }, + exitCode: result.exitCode, + } + return true + } + const items = (parsed as Partial | null)?.items + if (!Array.isArray(items)) + return `${label} printed neither a validation report nor a diagnostic` + answer = { + items: items.map((item) => ({ + id: item.id, + kind: 'change' as const, + valid: item.valid, + issues: item.issues.map((i) => mapDelegated(i, true)), + ...(typeof item.durationMs === 'number' ? { durationMs: item.durationMs } : {}), + })), + } + return true + }, + }, + }) + return answer! +} + +/** + * The binary's failure document as cospec relays it: each diagnostic's + * message and fix spelled through the remedy allowlist, the document (under + * `--json`) or `cospec: ` lines (text), with the binary's exit code. + */ +function relayFailure( + failure: { status: StatusDiagnostic[] } & Record, + exitCode: number, + json: boolean, +): number { + const status = failure.status.map((d) => ({ + ...d, + message: respellRemedies(d.message), + ...(d.fix === undefined ? {} : { fix: respellRemedies(d.fix) }), })) + if (json) process.stdout.write(`${JSON.stringify({ ...failure, status }, null, 2)}\n`) + else + for (const d of status) + process.stderr.write(`cospec: ${d.message}\n${d.fix === undefined ? '' : `Fix: ${d.fix}\n`}`) + return exitCode +} + +// --- item resolution (the binary's `validateDirectItem`) ------------------------ + +/** The binary's `utils/match` `levenshtein`. */ +function levenshtein(a: string, b: string): number { + const dp = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), + ) + for (let i = 1; i <= a.length; i++) + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost) + } + return dp[a.length]![b.length]! +} + +/** + * The binary's `nearestMatches(input, candidates, 5)`: the five nearest + * candidates by edit distance, stable in candidate order, duplicates kept. + */ +export function nearestMatches(input: string, candidates: readonly string[], max = 5): string[] { + return candidates + .map((candidate) => ({ candidate, distance: levenshtein(input, candidate) })) + .toSorted((a, b) => a.distance - b.distance) + .slice(0, max) + .map((s) => s.candidate) +} + +/** The binary's `normalizeType`: `change` or `spec`, any case; anything else is no override. */ +function normalizeType(value: string | undefined): 'change' | 'spec' | undefined { + const v = value?.toLowerCase() + return v === 'change' || v === 'spec' ? v : undefined +} + +/** The binary's `folderStyleNameProblem(value, label)` (`core/id.js`). */ +function folderStyleNameProblem(value: string, label: string): string | undefined { + if (value.length === 0) return `${label} must not be empty` + if (value === '.' || value === '..') return `${label} must not be '${value}'` + if (/[\\/]/u.test(value)) return `${label} must not contain path separators` + return undefined +} + +/** The binary's ambiguity fix, `validate/ambiguous-noun-form` as cospec spells it (no noun-form commands). */ +const AMBIGUOUS_FIX = 'Pass --type change|spec.' + +/** + * An item-resolution refusal: the binary's message after `cospec: ` on + * stderr (and its fix on the next line), or its one-diagnostic document + * under `--json`; exit 1. + */ +function refuseItem(json: boolean, code: string, message: string, fix?: string): number { + if (json) { + const status = [{ severity: 'error', code, message, ...(fix === undefined ? {} : { fix }) }] + process.stdout.write(`${JSON.stringify({ status }, null, 2)}\n`) + } else process.stderr.write(`cospec: ${message}\n${fix === undefined ? '' : `${fix}\n`}`) + return 1 +} + +/** + * `cospec validate ` resolves the name as the binary does (design D7): + * `--type` forces the kind; else membership among the active change ids and + * the living spec ids — a name that is both is refused as ambiguous, one that + * is neither gets the binary's nearest matches. A forced kind first rejects a + * path-shaped name, then reports an item that is not on disk as one + * `meta/item-missing` ERROR. + */ +async function validateItem( + root: Root, + name: string, + typeFlag: string | undefined, + opts: { strict: boolean; fast: boolean; json: boolean }, + warnings: ArchiveWarning[], +): Promise { + const base = root.base + const changeIds = listChanges(base).map((c) => c.id) + const specIds = livingSpecFiles(base).map((s) => s.id) + const isChange = changeIds.includes(name) + const isSpec = specIds.includes(name) + const override = normalizeType(typeFlag) + const kind = override ?? (isChange ? 'change' : isSpec ? 'spec' : undefined) + if (kind === undefined) { + const suggestions = nearestMatches(name, [...changeIds, ...specIds]) + const message = + suggestions.length > 0 + ? `Unknown item '${name}'. Did you mean: ${suggestions.join(', ')}?` + : `Unknown item '${name}'.` + return refuseItem(opts.json, 'unknown_item', message) + } + if (override === undefined && isChange && isSpec) + return refuseItem( + opts.json, + 'ambiguous_item', + `Ambiguous item '${name}' matches both a change and a spec.`, + AMBIGUOUS_FIX, + ) + // Spec ids nest (`/`), so the guard runs per segment; + // change names are flat and keep the whole-value check. + const problem = + kind === 'change' + ? folderStyleNameProblem(name, 'Change name') + : name + .split('/') + .map((segment) => folderStyleNameProblem(segment, 'Spec id')) + .find((p) => p !== undefined) + if (problem !== undefined) return refuseItem(opts.json, 'invalid_item', problem) + + const start = Date.now() + if (kind === 'change') { + const dir = join(base, 'openspec', 'changes', name) + if (!existsSync(dir)) { + const issues = [itemMissingIssue('change', name)] + return [{ id: name, kind: 'change', valid: false, issues, durationMs: Date.now() - start }] + } + const change = listChanges(base).find((c) => c.id === name) ?? { id: name, dir, schema: '' } + const { ctx, warning } = readValidateContext(base) + if (warning !== undefined) warnings.push(warning) + const report = await validateChange(root, change, ctx, opts) + return [{ ...report, durationMs: Date.now() - start }] + } + if (!existsSync(join(openspecDir(base), 'specs', ...name.split('/'), 'spec.md'))) { + const issues = [itemMissingIssue('spec', name)] + return [{ id: name, kind: 'spec', valid: false, issues, durationMs: Date.now() - start }] + } + return isSpec + ? validateSpecs(root, name, opts.strict) + : validateForcedSpec(root, name, opts.strict) +} + +/** + * A change whose validation threw an errno failure: one + * `meta/unreadable-artifact` ERROR naming the file it could not read. Anything + * that is not an errno failure propagates. + */ +export function erroredChange(base: string, id: string, error: unknown): ItemReport { + const { code, path } = (error ?? {}) as NodeJS.ErrnoException + if (errnoMessage(error) === undefined || code === undefined) throw error + const file = path === undefined ? undefined : relative(base, path).split(sep).join('/') + const issues = [unreadableArtifactIssue('.', code, file)] + return { id, kind: 'change', valid: false, issues } +} + +// --- --concurrency --------------------------------------------------------------- + +/** The binary's bulk default when neither `--concurrency` nor `OPENSPEC_CONCURRENCY` names one. */ +const DEFAULT_CONCURRENCY = 6 + +/** The binary's `normalizeConcurrency`: a positive `parseInt`, else nothing (never refused). */ +function normalizeConcurrency(value: string | undefined): number | undefined { + if (value === undefined || value.length === 0) return undefined + const n = Number.parseInt(value, 10) + return Number.isNaN(n) || n <= 0 ? undefined : n +} + +/** How many change validations run at once: `--concurrency`, else `OPENSPEC_CONCURRENCY`, else 6. */ +export function concurrencyBound( + flag: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): number { + return ( + normalizeConcurrency(flag) ?? + normalizeConcurrency(env.OPENSPEC_CONCURRENCY) ?? + DEFAULT_CONCURRENCY + ) +} + +/** + * `fn` over `items` with at most `limit` calls in flight, the results in + * input order whatever order they settle in (design D7). A rejection rejects + * the whole pool, as `Promise.all` did. + */ +export async function mapPool( + items: readonly T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = Array.from({ length: items.length }) + let next = 0 + const worker = async (): Promise => { + while (next < items.length) { + const index = next++ + results[index] = await fn(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return results +} + +// --- --report (the binary's request validation) --------------------------------- + +/** The binary's one fix for every refused report request. */ +const REPORT_FIX = + 'Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes.' + +/** + * The binary's `--report` request validation, checked before any root is + * resolved: the refusal message, or undefined for an acceptable request. + */ +function reportRequestProblem( + report: string, + name: string | undefined, + archived: boolean, + bulk: boolean, +): string | undefined { + if (report !== 'full' && report !== 'findings') return `Unknown validation report '${report}'.` + if (name !== undefined) return 'A validation report cannot be combined with an item name.' + if (archived && bulk) return 'A validation report cannot combine archived and active scopes.' + if (!archived && !bulk) return 'A validation report requires an explicit bulk scope.' + return undefined +} + +/** The binary's `findingsScope` for an accepted `--report findings` request. */ +function findingsScope(o: { + archived: boolean + all: boolean + changes: boolean + specs: boolean +}): FindingsScope { + if (o.archived) return 'archived' + if (o.all || (o.changes && o.specs)) return 'all' + return o.changes ? 'changes' : 'specs' +} + +/** A report in the requested shape: the full report, or its findings projection. */ +function renderReport( + items: ItemReport[], + opts: { json: boolean; strict: boolean; noColor: boolean; findings?: FindingsScope }, + root: ResolvedRoot, + kinds: readonly ItemReport['kind'][], + warnings: readonly ArchiveWarning[] = [], +): string { + const upstream = { root: rootOutput(root), kinds } + const extra = warnings.length === 0 ? {} : { warnings: [...warnings] } + if (opts.findings !== undefined && opts.json) + return `${JSON.stringify({ ...toFindings(toJson(items, upstream), opts.findings), ...extra }, null, 2)}\n` + if (opts.json) return `${JSON.stringify({ ...toJson(items, upstream), ...extra }, null, 2)}\n` + for (const warning of warnings) process.stderr.write(`Warning: ${warning.message}\n`) + return renderHuman(items, { + strict: opts.strict, + noColor: opts.noColor, + findingsOnly: opts.findings !== undefined, + }) } // --- command entrypoint ----------------------------------------------------- -export async function run(ctx: CommandContext): Promise { +/** + * The binary's refusal for a bulk `validate --json` with no root + * (`root-selection.js`, `allowImplicitRoot: false`), its fix spelled cospec. + */ +const NO_OPENSPEC_ROOT = new RootSelectionError({ + code: 'no_openspec_root', + message: 'No OpenSpec root found from the current directory.', + target: 'openspec.root', + fix: respellRemedies('Run openspec init to create a root here.'), +}) + +/** + * `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)) +} + +async function validate(ctx: CommandContext): Promise { const { flags } = ctx const parsed = ctx.parsed! const strict = hasFlag(parsed, '--strict') @@ -879,11 +1501,48 @@ export async function run(ctx: CommandContext): Promise { const wantSpecs = hasFlag(parsed, '--specs') const wantArchived = hasFlag(parsed, '--archived') const name = parsed.positionals[0] + const bulk = wantAll || wantChanges || wantSpecs + + // `--report` is validated before any root is resolved, as the binary does. + const report = flagValue(parsed, '--report') + let findings: FindingsScope | undefined + if (report !== undefined) { + const problem = reportRequestProblem(report, name, wantArchived, bulk) + if (problem !== undefined) { + if (flags.json) { + const status = [ + { + severity: 'error', + code: 'invalid_validation_report_request', + message: problem, + fix: REPORT_FIX, + }, + ] + process.stdout.write(`${JSON.stringify({ status }, null, 2)}\n`) + } else process.stderr.write(`Error: ${problem}\nFix: ${REPORT_FIX}\n`) + return 1 + } + if (report === 'findings') + findings = findingsScope({ + archived: wantArchived, + all: wantAll, + changes: wantChanges, + specs: wantSpecs, + }) + } + const renderOpts = { json: flags.json, strict, noColor: flags.noColor, findings } - const root = await resolveRoot(ctx) + const root = await resolveRootOrDocument(ctx, 'validate_error') + if (root === undefined) return 1 const base = root.base - if (!existsSync(openspecDir(base))) { + // A name alone is resolved even with no `openspec/` directory, as the + // binary resolves it against its implicit root: nothing matches it. + if (!existsSync(openspecDir(base)) && (bulk || wantArchived || name === undefined)) { + if (flags.json) { + process.stdout.write(rootSelectionDocument(NO_OPENSPEC_ROOT)) + return 1 + } process.stderr.write(`cospec: no openspec/ directory at ${base} — run 'cospec init' first\n`) return 1 } @@ -892,52 +1551,65 @@ export async function run(ctx: CommandContext): Promise { // changes/archive/, which active-change discovery deliberately excludes, and // it must never quietly alter an ordinary invocation. if (wantArchived) { - const archived = await validateArchived(root) - if (archived === undefined) { + const version = await wrappedOpenspecVersion() + if (openspecBelow(version, ARCHIVED_SINCE)) { process.stderr.write( - 'cospec: the wrapped OpenSpec `validate --archived` call produced no report — it needs ' + - 'OpenSpec >=1.9.0\n', + `cospec: validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ` + + `${version}\n`, ) return 1 } - const body = flags.json - ? renderJson(archived) - : renderHuman(archived, { strict, noColor: flags.noColor }) - process.stdout.write(body) - return reportExitCode(archived, strict) + const archived = await validateArchived(root) + if ('failure' in archived) return relayFailure(archived.failure, archived.exitCode, flags.json) + process.stdout.write(renderReport(archived.items, renderOpts, root, ['change'])) + return reportExitCode(archived.items, strict) } - const changes = listChanges(base) - const ctxRules = buildValidateContext(base) - const items: ItemReport[] = [] - - if (name !== undefined) { - // item-name auto-detection: change first, then living spec. - const change = resolveChange(base, name) - if (change !== undefined) { - items.push(await validateChange(root, change, ctxRules, { strict, fast })) - } else if (existsSync(join(openspecDir(base), 'specs', name, 'spec.md'))) { - items.push(...(await validateSpecs(root, name))) - } else { - process.stderr.write(`cospec: unknown item '${name}'\n`) - return 1 - } + // The kinds in scope, each counted in `summary.byType` as the binary counts it. + const kinds: ItemReport['kind'][] = [] + const warnings: ArchiveWarning[] = [] + if (name !== undefined && !bulk) { + // A bulk flag beside a name runs the bulk scope and ignores the name, as + // the binary does; a name alone is resolved as the binary resolves it. + const resolved = await validateItem( + root, + name, + flagValue(parsed, '--type'), + { strict, fast, json: flags.json }, + warnings, + ) + if (typeof resolved === 'number') return resolved + items.push(...resolved) + kinds.push(...new Set(resolved.map((item) => item.kind))) } else { - const doChanges = wantChanges || wantAll || (!wantChanges && !wantSpecs) - const doSpecs = wantSpecs || wantAll || (!wantChanges && !wantSpecs) + const changes = listChanges(base) + const doChanges = wantChanges || wantAll || !bulk + const doSpecs = wantSpecs || wantAll || !bulk + if (doChanges) kinds.push('change') + if (doSpecs) kinds.push('spec') if (doChanges) { - const reports = await Promise.all( - changes.map((change) => validateChange(root, change, ctxRules, { strict, fast })), - ) + const { ctx: ctxRules, warning } = readValidateContext(base) + if (warning !== undefined) warnings.push(warning) + const bound = concurrencyBound(flagValue(parsed, '--concurrency')) + // One change that throws an errno failure is that change's ERROR, never + // the whole sweep's, as the binary's queue records a failed item. + const reports = await mapPool(changes, bound, async (change) => { + const start = Date.now() + try { + const report = await validateChange(root, change, ctxRules, { strict, fast }) + return { ...report, durationMs: Date.now() - start } + } catch (error) { + const failed = erroredChange(base, change.id, error) + return { ...failed, durationMs: Date.now() - start } + } + }) items.push(...reports) } - if (doSpecs) items.push(...(await validateSpecs(root, undefined))) + if (doSpecs) items.push(...(await validateSpecs(root, undefined, strict))) } - const output = flags.json - ? renderJson(items) - : renderHuman(items, { strict, noColor: flags.noColor }) - process.stdout.write(output) + // The findings report's exit code is always the full report's. + process.stdout.write(renderReport(items, renderOpts, root, kinds, warnings)) return reportExitCode(items, strict) } diff --git a/apps/cli/src/core/change-metadata.ts b/apps/cli/src/core/change-metadata.ts index ec09e042..6da5603a 100644 --- a/apps/cli/src/core/change-metadata.ts +++ b/apps/cli/src/core/change-metadata.ts @@ -91,6 +91,44 @@ function readBooleanMarker(changeDir: string, key: 'retire_capabilities'): Marke return unhonorable(`${where}${issue.message}`) } +/** + * Whether openspec's `readChangeMetadata` (`utils/change-metadata.ts`) throws + * for a change, so every command that reads the change's metadata refuses it: + * its `.openspec.yaml` exists but cannot be read, is not YAML, fails + * `ChangeMetadataSchema` (a malformed `created`, an empty `goal`, a + * non-boolean `skip_specs`, an `initiative` that is not exactly `{store, id}`, + * …), or names a schema `listSchemas` does not list. `listed` answers the + * root's `listSchemas`, so a sweep computes it once. + */ +export function changeMetadataRefused(changeDir: string, listed: () => readonly string[]): boolean { + let raw: string + try { + raw = readFileSync(join(changeDir, METADATA_FILENAME), 'utf8') + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (!(err instanceof Error) || typeof code !== 'string') throw err + return code !== 'ENOENT' + } + let parsed: unknown + try { + parsed = parseYaml(raw) + } catch (err) { + if (!(err instanceof Error)) throw err + return true + } + if (changeMetadataIssue(parsed) !== undefined) return true + try { + return !listed().includes((parsed as Record).schema as string) + } catch (err) { + // `listed()` runs `listSchemas`, whose own errno failure (the schemas + // directory unreadable or not a directory) is the binary's to report, like + // every other read failure in this function. + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (!(err instanceof Error) || typeof code !== 'string') throw err + return true + } +} + // --- zod 4, as far as openspec's two schemas reach --------------------------- interface ZodIssue { @@ -185,17 +223,29 @@ export function changeMetadataIssue(value: unknown): ZodIssue | undefined { // --- listSchemas / resolveSchema --------------------------------------------- -/** openspec's `getGlobalDataDir()`/schemas (`core/global-config.ts`). */ -function userSchemasDir(): string { - const xdg = process.env.XDG_DATA_HOME +/** + * The user-level schema directory the wrapped binary reads, its + * `getGlobalDataDir()` + `schemas` (`core/global-config.ts`): + * `$XDG_DATA_HOME/openspec` when that is set and non-empty, else + * `%LOCALAPPDATA%\openspec` on Windows (`~/AppData/Local/openspec` without it), + * else `~/.local/share/openspec` — never `~/.config`, which holds only its + * config. The one place cospec computes it: every reader of the user tier + * (`resolveSchema`, `cospec new`, the schema listing here) calls this. + */ +export function userSchemasDir( + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), + platform: NodeJS.Platform = process.platform, +): string { + const xdg = env.XDG_DATA_HOME if (xdg !== undefined && xdg.length > 0) return join(xdg, 'openspec', 'schemas') - if (process.platform === 'win32') { - const local = process.env.LOCALAPPDATA + if (platform === 'win32') { + const local = env.LOCALAPPDATA return local !== undefined && local.length > 0 ? join(local, 'openspec', 'schemas') - : join(homedir(), 'AppData', 'Local', 'openspec', 'schemas') + : join(home, 'AppData', 'Local', 'openspec', 'schemas') } - return join(homedir(), '.local', 'share', 'openspec', 'schemas') + return join(home, '.local', 'share', 'openspec', 'schemas') } /** @@ -277,7 +327,7 @@ function schemaCandidateDir(schemasDir: string, name: string): string | undefine } /** openspec's `getSchemaDir(name, projectRoot)`: project, then user, then package. */ -function schemaDir(name: string, projectRoot: string): string | undefined { +export function schemaDir(name: string, projectRoot: string): string | undefined { if ( name.length === 0 || name === '.' || @@ -300,11 +350,18 @@ function schemaDir(name: string, projectRoot: string): string | undefined { return undefined } +/** One artifact of a schema `loadSchema` has validated. */ +export interface LoadedSchemaArtifact { + id: string + generates: string +} + /** - * openspec's `resolveSchema(name, projectRoot)`, for its throw alone: the - * schema is found, read, parsed and validated, or the error says why. + * openspec's `resolveSchema(name, projectRoot)`: the schema is found, read, + * parsed and validated, or the error says why. Returns its artifacts, in + * declaration order. */ -function loadSchema(name: string, projectRoot: string): void { +export function loadSchema(name: string, projectRoot: string): LoadedSchemaArtifact[] { const normalized = name.replace(/\.ya?ml$/, '') const dir = schemaDir(normalized, projectRoot) if (dir === undefined) @@ -326,6 +383,10 @@ function loadSchema(name: string, projectRoot: string): void { } const problem = schemaProblem(parsed) if (problem !== undefined) throw new Error(`Invalid schema at '${path}': ${problem}`) + return ((parsed as Record).artifacts as Record[]).map((a) => ({ + id: a.id as string, + generates: a.generates as string, + })) } /** openspec's `relativePathSchema(fieldName)` refinement. */ diff --git a/apps/cli/src/core/change.ts b/apps/cli/src/core/change.ts index 9cf27cdb..82299975 100644 --- a/apps/cli/src/core/change.ts +++ b/apps/cli/src/core/change.ts @@ -1,9 +1,10 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs' +import { join, resolve } from 'node:path' import { parse as parseYaml } from 'yaml' +import { loadSchema, userSchemasDir } from './change-metadata.ts' +import { artifactOutputExists } from './glob.ts' import { openspecPackageDir } from './openspec.ts' /** @@ -125,44 +126,27 @@ function listDirs(path: string): string[] { .map((entry) => entry.name) } -/** Active changes: every dir under `openspec/changes/` except `archive/`. */ +/** + * Active changes: every dir under `openspec/changes/` except `archive/` and + * dot-directories, as the binary's `getAvailableChanges` enumerates them. + */ export function listChanges(cwd: string): Change[] { + return listChangeDirs(cwd).filter((change) => !change.id.startsWith('.')) +} + +/** + * Every directory `openspec list` lists: all but `archive/`, dot-directories + * included, sorted by name. + */ +export function listChangeDirs(cwd: string): Change[] { const base = changesDir(cwd) return listDirs(base) .filter((name) => name !== 'archive') .toSorted() - .map((id) => { - const dir = join(base, id) - const yaml = readOpenspecYaml(dir) - return { - id, - dir, - schema: yaml?.schema ?? '', - created: yaml?.created, - schemaVersion: yaml?.schemaVersion, - skipSpecs: yaml?.skipSpecs, - retireCapabilities: yaml?.retireCapabilities, - } - }) + .map((id) => changeAt(join(base, id), id)) } -/** - * Change ids are kebab-case slugs (this is the canonical grammar `cospec new` - * validates against). Anything else — an empty string, a path separator, or a - * `..` traversal segment — can never name a real change, so id-taking readers - * reject it up front rather than joining it onto `changesDir` and resolving a - * path outside the changes tree. - */ -export const CHANGE_ID_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ - -/** - * Resolve an active change by exact id. `undefined` when the id is not a valid - * kebab slug or the change does not exist. - */ -export function resolveChange(cwd: string, id: string): Change | undefined { - if (!CHANGE_ID_RE.test(id)) return undefined - const dir = join(changesDir(cwd), id) - if (!existsSync(dir)) return undefined +function changeAt(dir: string, id: string): Change { const yaml = readOpenspecYaml(dir) return { id, @@ -175,6 +159,43 @@ export function resolveChange(cwd: string, id: string): Change | undefined { } } +/** + * The kebab-case slug grammar `cospec new` creates a change under. A change + * made any other way is still looked up by its directory name + * (`changeLookupNameProblem`); `meta/name-kebab` reports the name. + */ +export const CHANGE_ID_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ + +/** + * Why the binary's `validateChangeLookupName` refuses `name` as a change to + * look up, or undefined: a relative path segment, a path separator, a NUL, a + * leading dot, or the reserved `archive` — anything that would escape the + * changes directory or address an entry its change listing excludes. Empty + * names never reach the binary's check, which answers them first. + */ +export function changeLookupNameProblem(name: string): string | undefined { + if (name.length === 0) return 'Change name cannot be empty' + if (name === '.' || name === '..') return 'Change name cannot be a relative path segment' + if (name.includes('/') || name.includes('\\')) return 'Change name cannot contain path separators' + if (name.includes('\0')) return 'Change name cannot contain null characters' + if (name.startsWith('.')) return 'Change name cannot start with a dot' + if (name === 'archive') return "'archive' is reserved for archived changes" + return undefined +} + +/** + * Resolve an active change by exact name, as the binary's + * `validateChangeExists` does: a name it accepts for lookup, naming a + * directory under `openspec/changes/`. `undefined` otherwise — a regular + * file of that name included. + */ +export function resolveChange(cwd: string, id: string): Change | undefined { + if (changeLookupNameProblem(id) !== undefined) return undefined + const dir = join(changesDir(cwd), id) + if (!existsSync(dir) || !statSync(dir).isDirectory()) return undefined + return changeAt(dir, id) +} + const ARCHIVE_ENTRY = /^(\d{4}-\d{2}-\d{2})-(.+)$/ export interface ArchiveEntry { @@ -247,7 +268,7 @@ export function resolveSchema(cwd: string, name: string): SchemaResolution { if (existsSync(projectSchema)) return { name, kind: 'legacy', isCospecType: false, source: 'project' } - const userSchema = join(homedir(), '.config', 'openspec', 'schemas', name, 'schema.yaml') + const userSchema = join(userSchemasDir(), name, 'schema.yaml') if (existsSync(userSchema)) return { name, kind: 'legacy', isCospecType: false, source: 'user' } try { @@ -260,3 +281,180 @@ export function resolveSchema(cwd: string, name: string): SchemaResolution { return { name, kind: 'unknown', isCospecType: false } } + +// --- Namespace folders -------------------------------------------------------- +// +// A port of the binary's `utils/nested-change` (design D2): a directory under +// `openspec/changes/` that only wraps nested change directories +// (`changes/mobile/refresh-token/`) is a namespace folder, not a change. The +// probe only ever adds a diagnostic, so — as upstream — a path that cannot be +// read counts as holding nothing rather than failing the command around it. + +/** Files that only ever sit at the root of a change directory. */ +const CHANGE_ROOT_MARKERS = ['.openspec.yaml', 'proposal.md', 'tasks.md', 'design.md'] as const + +/** How far below a candidate the search looks: upstream's `MAX_NESTING_DEPTH`. */ +const MAX_NESTING_DEPTH = 3 + +export interface NestedChangeFinding { + /** The folder's name under `openspec/changes/`. */ + name: string + /** Each nested change as `/[/…]`, sorted. */ + nested: string[] +} + +function isErrno(error: unknown, code: string): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === code +} + +/** `stat(path).isFile()`, false for a path that cannot be stat'ed (upstream's `.catch`). */ +function isRegularFile(path: string): boolean { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +/** `readdir(dir)`, or no entries when it cannot be read (upstream's `.catch(() => [])`). */ +function entriesOrNone(dir: string): Dirent[] { + try { + return readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } +} + +/** + * upstream's `hasAnyFileUnder`: any non-dot file or symlink at any depth. + * A missing directory holds nothing; any other read failure is thrown for the + * caller to decide. + */ +function hasAnyFileUnder(dir: string): boolean { + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch (error) { + if (isErrno(error, 'ENOENT')) return false + throw error + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + if (entry.isFile() || entry.isSymbolicLink()) return true + if (entry.isDirectory() && hasAnyFileUnder(join(dir, entry.name))) return true + } + return false +} + +/** + * The root's `openspec/config.yaml` (else `config.yml`) `schema:` — upstream's + * `readProjectConfig(root)?.schema`. A config that cannot be read or parsed + * names none, as upstream falls back to its default then. + */ +export function projectConfigSchema(base: string): string | undefined { + const yaml = join(openspecDir(base), 'config.yaml') + const path = existsSync(yaml) ? yaml : join(openspecDir(base), 'config.yml') + if (!existsSync(path)) return undefined + let doc: unknown + try { + doc = parseYaml(readFileSync(path, 'utf8')) + } catch { + return undefined + } + if (doc === null || typeof doc !== 'object') return undefined + const schema = (doc as Record).schema + return typeof schema === 'string' && schema.length > 0 ? schema : undefined +} + +/** + * 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`) + * generates one. A schema that cannot be resolved gives no signal. + */ +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' + let artifacts: { generates: string }[] + try { + artifacts = loadSchema(name, projectRoot) + } catch { + return false + } + try { + return artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates)) + } catch { + // upstream's bare `catch`: an output it cannot resolve (one leaving the + // change, a linked directory cycle) gives no signal. + return false + } +} + +/** upstream's `looksLikeChange`: a root marker, a populated `specs/`, or a schema output. */ +function looksLikeChange(dir: string, projectRoot: string): boolean { + if (CHANGE_ROOT_MARKERS.some((marker) => isRegularFile(join(dir, marker)))) return true + try { + if (hasAnyFileUnder(join(dir, 'specs'))) return true + } catch { + // upstream's `.catch(() => false)`: an unreadable `specs/` is no signal. + } + return hasSchemaOutput(dir, projectRoot) +} + +function collectNested( + dir: string, + prefix: string, + depth: number, + found: string[], + projectRoot: string, +): void { + if (depth > MAX_NESTING_DEPTH) return + for (const entry of entriesOrNone(dir)) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + const child = join(dir, entry.name) + const id = `${prefix}/${entry.name}` + if (looksLikeChange(child, projectRoot)) { + found.push(id) + continue + } + collectNested(child, id, depth + 1, found, projectRoot) + } +} + +/** + * Whether `changes//` is a namespace folder holding nested change + * directories rather than a change of its own (upstream's + * `findNestedChangesIn`). `undefined` for every ordinary change, a scaffolded + * empty one included, for `archive` and for a dot-directory. + */ +export function findNestedChangesIn(dir: string, name: string): NestedChangeFinding | undefined { + if (name === 'archive' || name.startsWith('.')) return undefined + const folder = join(dir, name) + // changes/ is always /openspec/changes, for project and store roots. + const projectRoot = resolve(dir, '..', '..') + if (looksLikeChange(folder, projectRoot)) return undefined + if (entriesOrNone(folder).some((e) => !e.name.startsWith('.') && !e.isDirectory())) + return undefined + const nested: string[] = [] + collectNested(folder, name, 1, nested, projectRoot) + return nested.length === 0 ? undefined : { name, nested: nested.toSorted() } +} + +/** `findNestedChangesIn` across every candidate name, for the commands that enumerate. */ +export function findNestedChanges(dir: string, names: readonly string[]): NestedChangeFinding[] { + return names.flatMap((name) => findNestedChangesIn(dir, name) ?? []) +} + +/** upstream's `describeNestedChange`: the one explanation every surface prints, verbatim. */ +export function describeNestedChange(finding: NestedChangeFinding): string { + const list = finding.nested.map((id) => `openspec/changes/${id}/`).join(', ') + const example = finding.nested[0]!.split('/').join('-') + return ( + `"${finding.name}" is not a change: it is a folder wrapping ${list}. ` + + 'A change must be a directory directly under openspec/changes/, so those ' + + 'nested directories are invisible to OpenSpec while the folder around them ' + + 'is reported as a change. Nested paths are supported under openspec/specs/ ' + + `only. Rename each nested change to a flat name (for example "${example}").` + ) +} diff --git a/apps/cli/src/core/command-table.ts b/apps/cli/src/core/command-table.ts index 24622665..d1319898 100644 --- a/apps/cli/src/core/command-table.ts +++ b/apps/cli/src/core/command-table.ts @@ -485,7 +485,6 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ placeholder: '', values: ['change', 'spec'], description: 'Specify item type when ambiguous', - status: pending('cli-surface-parity'), }), upstream({ name: '--report', @@ -493,14 +492,12 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ placeholder: '', values: ['full', 'findings'], description: 'Select bulk report content', - status: pending('cli-surface-parity'), }), upstream({ name: '--concurrency', takesValue: true, placeholder: '', description: 'Max concurrent validations', - status: pending('cli-surface-parity'), }), ], }, @@ -531,7 +528,6 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ takesValue: true, placeholder: '', description: 'Schema override', - status: pending('cli-surface-parity'), }), ], }, @@ -558,7 +554,6 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ placeholder: '', values: ['recent', 'name'], description: 'Sort order: "recent" (default) or "name"', - status: pending('cli-surface-parity'), }), ], }, @@ -1051,7 +1046,7 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ }, { name: '__complete', - summary: 'Dynamic completion source (changes|specs|types)', + summary: 'Dynamic completion source (changes|specs|types|schemas|archived-changes)', hidden: true, parse: 'table', json: 'accepted', @@ -1060,9 +1055,7 @@ export const COMMAND_TABLE: readonly CommandRow[] = [ cospecArg({ name: 'source', required: true, - values: ['changes', 'specs', 'types'], - // Upstream's hidden `__complete ` also serves these two. - pendingValues: { schemas: 'cli-surface-parity', 'archived-changes': 'cli-surface-parity' }, + values: ['changes', 'specs', 'types', 'schemas', 'archived-changes'], }), ], flags: [], diff --git a/apps/cli/src/core/completions/bash.ts b/apps/cli/src/core/completions/bash.ts index d77afaf5..3d6cc031 100644 --- a/apps/cli/src/core/completions/bash.ts +++ b/apps/cli/src/core/completions/bash.ts @@ -33,6 +33,14 @@ export function renderBashCompletion(spec: CompletionSpec): string { .map((c) => caseArm(c.name, [`sources='${c.positional.join(' ')}'`])) .join('\n') + const subcommandArms = spec.commands + .flatMap((c) => + Object.entries(c.subcommandPositional).map(([sub, sources]) => + caseArm(`'${c.name} ${sub}'`, [`sources='${sources.join(' ')}'`]), + ), + ) + .join('\n') + const flagValueArms = spec.commands .filter((c) => Object.keys(c.flagValues).length > 0) .map((c) => @@ -53,7 +61,7 @@ _cospec_dynamic() { } _cospec() { - local cur prev cmd i flags sources items + local cur prev cmd sub i j subpos flags sources items cur="\${COMP_WORDS[COMP_CWORD]}" prev="" [[ $COMP_CWORD -gt 0 ]] && prev="\${COMP_WORDS[COMP_CWORD-1]}" @@ -73,6 +81,16 @@ _cospec() { return 0 fi + # The subcommand after the command, and how many positionals follow it. + sub="" + subpos=0 + for (( j = i + 1; j < COMP_CWORD; j++ )); do + case "\${COMP_WORDS[j]}" in + -*) ;; + *) if [[ -z $sub ]]; then sub="\${COMP_WORDS[j]}"; else subpos=$((subpos + 1)); fi ;; + esac + done + sources="" case "$cmd" in ${flagValueArms} @@ -90,6 +108,12 @@ ${globalArms} return 0 fi + if [[ -z $sources && -n $sub && $subpos -eq 0 ]]; then + case "$cmd $sub" in +${subcommandArms} + esac + fi + if [[ -z $sources ]]; then case "$cmd" in ${positionalArms} diff --git a/apps/cli/src/core/completions/fish.ts b/apps/cli/src/core/completions/fish.ts index 49706973..4eaff368 100644 --- a/apps/cli/src/core/completions/fish.ts +++ b/apps/cli/src/core/completions/fish.ts @@ -52,6 +52,10 @@ export function renderFishCompletion(spec: CompletionSpec): string { } if (command.positional.length > 0) lines.push(`complete -c cospec ${seen} -a "${dynamicArg(command.positional)}"`) + for (const [sub, sources] of Object.entries(command.subcommandPositional)) + lines.push( + `complete -c cospec -n '__fish_seen_subcommand_from ${command.name}; and __fish_seen_subcommand_from ${sub}' -a "${dynamicArg(sources)}"`, + ) } lines.push('') diff --git a/apps/cli/src/core/completions/spec.ts b/apps/cli/src/core/completions/spec.ts index 800d5f97..7cf257af 100644 --- a/apps/cli/src/core/completions/spec.ts +++ b/apps/cli/src/core/completions/spec.ts @@ -21,7 +21,7 @@ import { } from '../command-table.ts' /** A completion source resolved at Tab time by the hidden `cospec __complete`. */ -export type DynamicSource = 'changes' | 'specs' | 'types' +export type DynamicSource = 'changes' | 'specs' | 'types' | 'schemas' export interface CompletionCommand { name: string @@ -32,6 +32,8 @@ export interface CompletionCommand { positional: DynamicSource[] /** Flags whose VALUE is dynamically completed (e.g. `--change `). */ flagValues: Record + /** Sources completing a subcommand's first positional, per subcommand (`schema which `). */ + subcommandPositional: Record /** * The global flags offered after the command's name: its row's accepted * globals (`rowGlobalFlags`, so no `--store` on a `store: 'refused'` row) @@ -57,13 +59,22 @@ const POSITIONAL: Record = { show: ['changes', 'specs'], } -/** Flags whose value is a dynamic id, per command. */ +/** + * Flags whose value is a dynamic id, per command. Every row that declares + * `--schema` also completes its value from `schemas` (`buildCompletionSpec`), + * where the wrapped binary's own scripts complete schema names. + */ const FLAG_VALUES: Record> = { status: { '--change': 'changes' }, instructions: { '--change': 'changes' }, 'sync-blockers': { '--change': 'changes' }, } +/** Subcommand positionals completed dynamically: the schema a `schema` subcommand names. */ +const SUBCOMMAND_POSITIONAL: Record> = { + schema: { which: ['schemas'], validate: ['schemas'], fork: ['schemas'] }, +} + /** * The offered (handled + accepted-no-op) flags of a surface, as completion * tokens: short form immediately before its long form, same order `flagLabel` @@ -94,7 +105,13 @@ export function buildCompletionSpec(): CompletionSpec { summary: row.summary, flags: offeredFlagTokens(row.flags), positional: POSITIONAL[row.name] ?? [], - flagValues: FLAG_VALUES[row.name] ?? {}, + flagValues: { + ...FLAG_VALUES[row.name], + ...(offeredFlags(row).some((f) => f.name === '--schema') + ? { '--schema': 'schemas' as const } + : {}), + }, + subcommandPositional: SUBCOMMAND_POSITIONAL[row.name] ?? {}, globalFlags: [...offeredFlagTokens(rowGlobalFlags(row)), '-V', '--version'], })) return { commands, globalFlags } diff --git a/apps/cli/src/core/completions/zsh.ts b/apps/cli/src/core/completions/zsh.ts index 39ef83b2..10ef0161 100644 --- a/apps/cli/src/core/completions/zsh.ts +++ b/apps/cli/src/core/completions/zsh.ts @@ -44,6 +44,16 @@ export function renderZshCompletion(spec: CompletionSpec): string { ) .join('\n') + const subcommandArms = spec.commands + .flatMap((c) => + Object.entries(c.subcommandPositional).map(([sub, sources]) => + caseArm(`'${c.name} ${sub}'`, [ + `(( subpos == 0 )) && { ${sources.map((source) => `_cospec_dynamic ${source}`).join('; ')}; return }`, + ]), + ), + ) + .join('\n') + const flagValueArms = spec.commands .filter((c) => Object.keys(c.flagValues).length > 0) .map((c) => @@ -74,13 +84,19 @@ ${commands} ) global_flags=(${globals}) - local cmd='' prev='' i + local cmd='' sub='' prev='' i j subpos=0 for (( i = 2; i < CURRENT; i++ )); do case \${words[i]} in -*) ;; *) cmd=\${words[i]}; break ;; esac done + for (( j = i + 1; j < CURRENT; j++ )); do + case \${words[j]} in + -*) ;; + *) if [[ -z $sub ]]; then sub=\${words[j]}; else (( subpos++ )); fi ;; + esac + done (( CURRENT > 1 )) && prev=\${words[CURRENT-1]} if [[ -z $cmd ]]; then @@ -106,6 +122,12 @@ ${globalArms} return fi + if [[ -n $sub ]]; then + case "$cmd $sub" in +${subcommandArms} + esac + fi + case $cmd in ${positionalArms} esac diff --git a/apps/cli/src/core/errno.ts b/apps/cli/src/core/errno.ts new file mode 100644 index 00000000..b4fd133b --- /dev/null +++ b/apps/cli/src/core/errno.ts @@ -0,0 +1,34 @@ +// An errno failure a command lets escape (an unreadable directory, say), +// answered under `--json` as the binary's `failWithError` answers it (design +// D10): one document, exit 1. Every other error keeps propagating. + +/** An errno failure's message (`EACCES: permission denied, scandir '…'`); undefined for anything else. */ +export function errnoMessage(error: unknown): string | undefined { + const { code, syscall } = (error ?? {}) as NodeJS.ErrnoException + return error instanceof Error && typeof code === 'string' && typeof syscall === 'string' + ? error.message + : undefined +} + +/** + * Run `command`, answering an errno failure it throws (an unreadable + * `openspec/changes/`, say) under `--json` with the binary's one + * `{...payload, status: [{severity: 'error', code, message}]}` document and + * exit 1. In text mode, and for anything that is not an errno failure, the + * error propagates to the top-level handler, which prints its message. + */ +export async function answeringErrno( + json: boolean, + failure: { code: string; payload?: Readonly> }, + command: () => Promise, +): Promise { + try { + return await command() + } catch (error) { + const message = errnoMessage(error) + if (!json || message === undefined) throw error + const status = [{ severity: 'error', code: failure.code, message }] + process.stdout.write(`${JSON.stringify({ ...failure.payload, status }, null, 2)}\n`) + return 1 + } +} diff --git a/apps/cli/src/core/glob.ts b/apps/cli/src/core/glob.ts new file mode 100644 index 00000000..20c3e0ba --- /dev/null +++ b/apps/cli/src/core/glob.ts @@ -0,0 +1,219 @@ +// The pinned binary's `core/artifact-graph/outputs.js` `artifactOutputExists`, +// ported line for line, over the same fast-glob the binary matches with: cospec +// pins `fast-glob` to the version the pinned openspec resolves, so braces, +// numeric ranges, extglobs and negation read as the binary reads them, and +// `test/contract/glob.test.ts` holds this module to the binary's own modules. + +import { lstatSync, realpathSync, statSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path' + +import fg from 'fast-glob' +import ProviderSync from 'fast-glob/out/providers/sync.js' +import Settings from 'fast-glob/out/settings.js' +import type { MicromatchOptions } from 'fast-glob/out/types/index.js' +import { expandBraceExpansion, makeRe as fgMakeRe } from 'fast-glob/out/utils/pattern.js' + +/** Reads the micromatch options fast-glob's providers match with. */ +class MatchOptions extends ProviderSync { + get options(): MicromatchOptions { + return this._getMicromatchOptions() + } +} + +let matchOptions: MicromatchOptions | undefined + +/** The micromatch options fast-glob matches with under its default settings. */ +function defaultMatchOptions(): MicromatchOptions { + matchOptions ??= new MatchOptions(new Settings({})).options + return matchOptions +} + +/** fast-glob's brace expansion of one pattern. */ +export function expandBraces(pattern: string): string[] { + return expandBraceExpansion(pattern) +} + +/** The regex fast-glob matches `pattern` with under its default settings. */ +export function makeRe(pattern: string): RegExp { + return fgMakeRe(pattern, defaultMatchOptions()) +} + +function errorCode(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code +} + +/** The binary's `isGlobPattern`. */ +function isGlobPattern(pattern: string): boolean { + return pattern.includes('*') || pattern.includes('?') || pattern.includes('[') +} + +function toPosixPath(p: string): string { + return p.replace(/\\/g, '/') +} + +/** The binary's `FileSystemUtils.canonicalizeExistingPath`. */ +function canonicalizeExistingPath(targetPath: string): string { + try { + return realpathSync.native(targetPath) + } catch { + try { + return realpathSync(targetPath) + } catch { + return resolve(targetPath) + } + } +} + +/** The binary's `FileSystemUtils.isPathWithin`. */ +function isPathWithin(allowedDirectory: string, targetPath: string): boolean { + const rel = relative(allowedDirectory, targetPath) + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) +} + +/** The binary's `FileSystemUtils.canonicalizePotentialPath`. */ +function canonicalizePotentialPath(targetPath: string): string { + let existingPath = targetPath + const missingSegments: string[] = [] + for (;;) { + try { + // lstat distinguishes a missing path from a dangling symlink, which + // realpath reports as ENOENT either way. + lstatSync(existingPath) + return resolve(realpathSync.native(existingPath), ...missingSegments) + } catch (err) { + if (errorCode(err) !== 'ENOENT') throw err + let dangling = false + try { + dangling = lstatSync(existingPath).isSymbolicLink() + } catch (lstatErr) { + if (errorCode(lstatErr) !== 'ENOENT') throw lstatErr + } + if (dangling) + throw new Error(`Cannot verify dangling symbolic link: ${existingPath}`, { cause: err }) + const parent = dirname(existingPath) + if (parent === existingPath) + throw new Error(`Cannot resolve an existing parent for ${targetPath}`, { cause: err }) + missingSegments.unshift(basename(existingPath)) + existingPath = parent + } + } +} + +/** The binary's `FileSystemUtils.assertPathWithin`. */ +function assertPathWithin(allowedDirectory: string, targetPath: string): void { + const resolvedDirectory = resolve(allowedDirectory) + const resolvedTarget = resolve(targetPath) + if (!isPathWithin(resolvedDirectory, resolvedTarget)) + throw new Error(`Path is outside the allowed directory: ${targetPath}`) + const canonicalDirectory = canonicalizePotentialPath(resolvedDirectory) + const canonicalTarget = canonicalizePotentialPath(resolvedTarget) + if (!isPathWithin(canonicalDirectory, canonicalTarget)) + throw new Error(`Path is outside the allowed directory: ${targetPath}`) +} + +/** + * The binary's `assertGlobDirectoryTraversal`: every directory the pattern's + * directory segments can reach stays inside the change, and no linked + * directory cycle is walked. + */ +function assertGlobDirectoryTraversal( + changeDir: string, + currentDir: string, + directorySegments: readonly string[], + segmentIndex = 0, + visited = new Set(), + canonicalChangeDir = canonicalizeExistingPath(changeDir), + ancestors = new Set(), +): void { + if (segmentIndex >= directorySegments.length) return + const canonicalDir = canonicalizeExistingPath(currentDir) + assertPathWithin(canonicalChangeDir, canonicalDir) + const visitKey = `${canonicalDir}\0${segmentIndex}` + if (ancestors.has(visitKey)) + throw new Error( + `Cannot resolve artifact outputs through a linked directory cycle: ${currentDir}`, + ) + if (visited.has(visitKey)) return + visited.add(visitKey) + ancestors.add(visitKey) + try { + const segment = directorySegments[segmentIndex]! + // `**` may consume no directory at all. + if (segment === '**') + assertGlobDirectoryTraversal( + changeDir, + canonicalDir, + directorySegments, + segmentIndex + 1, + visited, + canonicalChangeDir, + ancestors, + ) + const matches = fg.sync(segment === '**' ? '*' : segment, { + cwd: canonicalDir, + onlyFiles: false, + followSymbolicLinks: false, + deep: 1, + }) + for (const match of matches) { + const candidate = join(canonicalDir, match) + try { + if (!statSync(candidate).isDirectory()) continue + } catch (err) { + if (errorCode(err) === 'ENOENT') continue + throw err + } + const canonicalCandidate = canonicalizeExistingPath(candidate) + assertPathWithin(canonicalChangeDir, canonicalCandidate) + assertGlobDirectoryTraversal( + changeDir, + canonicalCandidate, + directorySegments, + segment === '**' ? segmentIndex : segmentIndex + 1, + visited, + canonicalChangeDir, + ancestors, + ) + } + } finally { + ancestors.delete(visitKey) + } +} + +/** + * The binary's `resolveArtifactOutputs`: the files an artifact's `generates` + * names inside `changeDir`, canonical and sorted. Throws, as the binary does, + * for a pattern or a match that leaves the change. + */ +export function resolveArtifactOutputs(changeDir: string, generates: string): string[] { + const outputPath = join(changeDir, generates) + assertPathWithin(changeDir, outputPath) + if (!isGlobPattern(generates)) { + try { + return statSync(outputPath).isFile() ? [canonicalizeExistingPath(outputPath)] : [] + } catch { + // The binary's bare `catch`: any stat failure is no output. + return [] + } + } + const normalizedPattern = toPosixPath(generates) + assertGlobDirectoryTraversal(changeDir, changeDir, normalizedPattern.split('/').slice(0, -1)) + const matches = fg + .sync(normalizedPattern, { + cwd: changeDir, + onlyFiles: true, + absolute: true, + followSymbolicLinks: true, + }) + .map((match) => { + const normalizedMatch = normalize(match) + assertPathWithin(changeDir, normalizedMatch) + return canonicalizeExistingPath(normalizedMatch) + }) + return [...new Set(matches)].toSorted() +} + +/** The binary's `artifactOutputExists`: whether `generates` names at least one file. */ +export function artifactOutputExists(changeDir: string, generates: string): boolean { + return resolveArtifactOutputs(changeDir, generates).length > 0 +} diff --git a/apps/cli/src/core/openspec.ts b/apps/cli/src/core/openspec.ts index e7182c47..a573e9b6 100644 --- a/apps/cli/src/core/openspec.ts +++ b/apps/cli/src/core/openspec.ts @@ -336,6 +336,24 @@ export function checkVersion(actual: string, allowDrift: boolean): void { ) } +/** + * True only when `version` parses and is below `floor`: an unparseable + * version (drift allowed) is never judged too old, so the wrapped call is + * made and its own answer decides. + */ +export function openspecBelow(version: string, floor: string): boolean { + const parsed = parseSemver(version) + return parsed !== null && compareSemver(parsed, parseSemverOrThrow(floor)) < 0 +} + +let versionRead: Promise | undefined + +/** The wrapped binary's `--version`, read once per process (memoized). */ +export function wrappedOpenspecVersion(): Promise { + versionRead ??= spawnRaw(['--version'], process.cwd()).then((res) => res.stdout.trim()) + return versionRead +} + let versionAsserted: Promise | undefined /** Assert the wrapped version once per process (memoized). */ @@ -343,8 +361,7 @@ function assertVersion(): Promise { versionAsserted ??= (async () => { const allowDrift = process.env.COSPEC_ALLOW_OPENSPEC_DRIFT === '1' if (allowDrift) return - const res = await spawnRaw(['--version'], process.cwd()) - checkVersion(res.stdout, false) + checkVersion(await wrappedOpenspecVersion(), false) })() return versionAsserted } @@ -689,12 +706,62 @@ export function openspecList(root: Root): Promise { return runJson(root, ['list'], []) } -/** Typed `openspec instructions apply --change --json`. */ -export function openspecApplyInstructions( +/** A diagnostic of the binary's failure document (`{status: [...]}`). */ +export interface StatusDiagnostic { + severity: string + code?: string + message: string + fix?: string +} + +/** + * The binary's answer to `instructions apply`: its payload, or — when it + * refuses the change (a schema it cannot read, say) — its failure document. + */ +export type ApplyInstructionsAnswer = + | { instructions: ApplyInstructionsJson } + | { refused: { status: StatusDiagnostic[] } & Record } + +/** + * Typed `openspec instructions apply --change --json`: exit 0 with its + * payload, or exit 1 with its failure document, which is an answer the caller + * relays rather than a violation. Anything else throws `OpenspecCallError`. + */ +export async function openspecApplyInstructions( root: Root, changeId: string, -): Promise { - return runJson(root, ['instructions', 'apply'], ['--change', changeId]) +): Promise { + const argv = threadedArgv( + ['instructions', 'apply'], + ['--json', ...root.storeArgs], + ['--change', changeId], + ) + const label = wrappedCallLabel(argv) + let answer: ApplyInstructionsAnswer | undefined + await runOpenspec(argv, { + cwd: root.cwd, + expect: { + exitCodes: [0, 1], + postCondition: (result) => { + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + return `could not parse JSON from ${label}` + } + if (result.exitCode === 1) { + if (!isOpenspecErrorStatus(parsed)) return `${label} exited 1 without a failure document` + answer = { refused: parsed as { status: StatusDiagnostic[] } & Record } + return true + } + const instruction = (parsed as Partial | null)?.instruction + if (typeof instruction !== 'string') return `${label} printed no apply instructions` + answer = { instructions: parsed as ApplyInstructionsJson } + return true + }, + }, + }) + return answer! } /** Typed `openspec instructions --change --json`. */ diff --git a/apps/cli/src/core/report.ts b/apps/cli/src/core/report.ts index c03a9e53..25cb9847 100644 --- a/apps/cli/src/core/report.ts +++ b/apps/cli/src/core/report.ts @@ -25,6 +25,8 @@ export interface ItemReport { type?: string valid: boolean issues: Issue[] + /** Milliseconds spent validating the item, as the binary's `items[].durationMs`. */ + durationMs?: number } export interface ReportSummary { @@ -63,6 +65,11 @@ export interface RenderOptions { noColor?: boolean /** Human header prefix; defaults to `cospec validate`. */ title?: string + /** + * The findings report (`--report findings`): a change with no issue is + * folded into the header's counts, as a valid spec always is. + */ + findingsOnly?: boolean } const COLORS = { @@ -128,7 +135,8 @@ export function renderHuman(items: ItemReport[], opts: RenderOptions = {}): stri } } - for (const item of changes) renderItem(item) + for (const item of changes) + if (!(opts.findingsOnly === true && item.issues.length === 0)) renderItem(item) const invalidSpecs = specs.filter((item) => !item.valid || item.issues.length > 0) for (const item of invalidSpecs) renderItem(item) @@ -148,19 +156,89 @@ export function renderHuman(items: ItemReport[], opts: RenderOptions = {}): stri return `${lines.join('\n')}\n` } +/** The binary's `{items, passed, failed}` count (its `summary.totals`). */ +export interface ItemTotals { + items: number + passed: number + failed: number +} + export interface ReportJson { version: 1 items: ItemReport[] - summary: { errors: number; warnings: number; byRule: Record } + summary: { + errors: number + warnings: number + byRule: Record + totals?: ItemTotals + byType?: Partial> + } + root?: unknown +} + +/** The binary's keys a `validate` report carries beside cospec's own (design D7). */ +export interface UpstreamReportKeys { + /** The resolver's root, as the binary's `root` object. */ + root: unknown + /** The kinds in scope, each of which gets a `summary.byType` count. */ + kinds: readonly ItemReport['kind'][] } -/** The machine report object (DESIGN §4.4 `--json`). */ -export function toJson(items: ItemReport[]): ReportJson { +function totals(items: readonly ItemReport[]): ItemTotals { + const passed = items.filter((item) => item.valid).length + return { items: items.length, passed, failed: items.length - passed } +} + +/** + * The machine report object (DESIGN §4.4 `--json`). With `upstream`, the + * binary's own report keys join cospec's — `summary.totals`, + * `summary.byType` for each kind in scope, and `root` — and `version` + * stays cospec's `1`. + */ +export function toJson(items: ItemReport[], upstream?: UpstreamReportKeys): ReportJson { const { errors, warnings, byRule } = summarize(items) - return { version: 1, items, summary: { errors, warnings, byRule } } + if (upstream === undefined) return { version: 1, items, summary: { errors, warnings, byRule } } + const byType = Object.fromEntries( + upstream.kinds.map((kind) => [kind, totals(items.filter((item) => item.kind === kind))]), + ) + return { + version: 1, + items, + summary: { errors, warnings, byRule, totals: totals(items), byType }, + root: upstream.root, + } } /** Pretty-printed JSON string of `toJson`. */ -export function renderJson(items: ItemReport[]): string { - return `${JSON.stringify(toJson(items), null, 2)}\n` +export function renderJson(items: ItemReport[], upstream?: UpstreamReportKeys): string { + return `${JSON.stringify(toJson(items, upstream), null, 2)}\n` +} + +/** The scope a findings report names (the binary's `findingsScope`). */ +export type FindingsScope = 'all' | 'changes' | 'specs' | 'archived' + +/** + * The findings report (`--report findings`, design D7): the binary's + * `projectValidationFindings` — only the items with at least one issue, under + * its `report` object (its own nested `version: "1.0"`) — inside cospec's + * `version: 1` envelope. `summary` and `root` are the full report's. + */ +export function toFindings( + full: { items: ItemReport[]; summary: unknown; root?: unknown }, + scope: FindingsScope, +): Record { + const itemFindings = full.items.filter((item) => item.issues.length > 0) + return { + version: 1, + report: { + kind: 'validation-findings', + version: '1.0', + scope, + returnedItems: itemFindings.length, + totalItems: full.items.length, + }, + itemFindings, + summary: full.summary, + ...(full.root === undefined ? {} : { root: full.root }), + } } diff --git a/apps/cli/src/core/rules/issue.ts b/apps/cli/src/core/rules/issue.ts index c31838a3..4e5f77b7 100644 --- a/apps/cli/src/core/rules/issue.ts +++ b/apps/cli/src/core/rules/issue.ts @@ -24,4 +24,5 @@ export interface ItemReport { type?: string valid: boolean issues: Issue[] + durationMs?: number } diff --git a/apps/cli/src/core/rules/meta.ts b/apps/cli/src/core/rules/meta.ts index b5ef9015..44504e2f 100644 --- a/apps/cli/src/core/rules/meta.ts +++ b/apps/cli/src/core/rules/meta.ts @@ -336,3 +336,62 @@ export function metaRules( return issues } + +/** + * The binary's two next-step bullets for a namespace folder + * (`commands/validate.js` `printNextSteps`), joined as one hint. + */ +const NESTED_CHANGE_HINT = + 'Move each nested change directly under openspec/changes/, folding the namespace into its name; ' + + 'Only specs may be nested by domain; change directories are always flat' + +/** + * `meta/nested-change` (design D2): the directory is a namespace folder, not a + * change. `explanation` is the binary's sentence (`describeNestedChange`), + * carried verbatim; no other rule runs on the folder. + */ +export function nestedChangeIssue(explanation: string): Issue { + return { + level: 'ERROR', + rule: 'meta/nested-change', + path: '.', + message: explanation, + hint: NESTED_CHANGE_HINT, + } +} + +/** + * `meta/unreadable-artifact` (design D7): a change file that exists but could + * not be read. `path` is change-relative; `code` is the errno code + * (`EACCES`, `EISDIR`, …); `file` names a file outside the change (the + * living spec a delta targets) reported against `path`. + */ +export function unreadableArtifactIssue(path: string, code: string, file = path): Issue { + return { + level: 'ERROR', + rule: 'meta/unreadable-artifact', + path, + message: `could not read ${file} (${code})`, + hint: 'fix the file permissions (or replace the entry with a readable file) and re-run', + } +} + +/** + * `meta/item-missing` (design D7): `--type` forced a kind whose item is not on + * disk — no change directory, or no living spec file. + */ +export function itemMissingIssue(kind: 'change' | 'spec', id: string): Issue { + return kind === 'change' + ? { + level: 'ERROR', + rule: 'meta/item-missing', + path: '.', + message: `no change directory at openspec/changes/${id}/`, + } + : { + level: 'ERROR', + rule: 'meta/item-missing', + path: `specs/${id}/spec.md`, + message: `no living spec at openspec/specs/${id}/spec.md`, + } +} diff --git a/apps/cli/src/core/rules/views.ts b/apps/cli/src/core/rules/views.ts index 2a0d735a..aede3fef 100644 --- a/apps/cli/src/core/rules/views.ts +++ b/apps/cli/src/core/rules/views.ts @@ -18,7 +18,9 @@ import type { Issue } from './issue.ts' * - `deltas/scenario-depth` refuses a visible `### Scenario:` only: the binary * merely INFOs a commented one and archives it, so reading it verbatim would * refuse what the binary archives, and a commented one that does split a - * requirement is `archive/split-requirement`'s, on the verbatim view. + * requirement is `archive/split-requirement`'s, on the verbatim view. Proven + * by `test/contract/validation-parity.test.ts` row 36.1 ("commented mis-depth + * scenario: the binary archives it and cospec raises no deltas/scenario-depth"). * - `specs/purpose-tbd` lints a living spec's Purpose and is read by neither * `apply` nor `archive`. */ diff --git a/apps/cli/src/core/spec-paths.ts b/apps/cli/src/core/spec-paths.ts index a2482da9..dc17c02d 100644 --- a/apps/cli/src/core/spec-paths.ts +++ b/apps/cli/src/core/spec-paths.ts @@ -19,6 +19,12 @@ export interface DiscoveredSpec { id: string /** Path to the `spec.md` file; absolute when the specs root is absolute. */ specFile: string + /** + * The errno code that kept a regular `spec.md` from being resolved, set only + * under `reportUnreadable` (Bun's `realpath` opens the file, so a mode-000 + * spec fails confinement before it is ever read). + */ + unreadable?: string } function errorCode(err: unknown): string | undefined { @@ -104,8 +110,15 @@ function assertDiscoveredSpecPath( * (EACCES, EIO, …) is thrown rather than swallowed: this feeds the archive * merge path, where silently dropping an unreadable capability would recreate * the data-loss class #1353 closed. + * - `reportUnreadable` (validate only) lists a regular `spec.md` whose + * resolution fails with an errno as `unreadable`, never dropped: it is a + * real file in a walked (never linked) directory, so it is still confined, + * and the caller reports it without reading it. */ -export function discoverSpecFiles(specsRoot: string): DiscoveredSpec[] { +export function discoverSpecFiles( + specsRoot: string, + opts: { reportUnreadable?: boolean } = {}, +): DiscoveredSpec[] { const results: DiscoveredSpec[] = [] const walk = (dir: string, segments: string[]): void => { let entries @@ -124,7 +137,14 @@ export function discoverSpecFiles(specsRoot: string): DiscoveredSpec[] { if (entry.name !== 'spec.md' || segments.length === 0) continue const specFile = join(dir, entry.name) if (entry.isFile()) { - assertDiscoveredSpecPath(specsRoot, dir, specFile) + try { + assertDiscoveredSpecPath(specsRoot, dir, specFile) + } catch (err) { + const code = errorCode(err) + if (opts.reportUnreadable !== true || code === undefined || code === 'ENOENT') throw err + results.push({ id: segments.join('/'), specFile, unreadable: code }) + continue + } results.push({ id: segments.join('/'), specFile }) } else if (entry.isSymbolicLink()) { let target diff --git a/apps/cli/src/core/upstream-keys.ts b/apps/cli/src/core/upstream-keys.ts new file mode 100644 index 00000000..f2b7bf8a --- /dev/null +++ b/apps/cli/src/core/upstream-keys.ts @@ -0,0 +1,137 @@ +// The additive merge (design D3): upstream's keys join cospec's `--json` +// documents from one delegated call, and no cospec key or value is ever +// removed or changed. A key both documents carry keeps cospec's value; when the +// two differ the key is reported as a collision, so the key oracle +// (`test/contract/support/key-oracle.ts`) can prove that only its named +// collisions ever arise. + +import { + RawSelectionError, + resolveRoot, + RootSelectionError, + rootSelectionDocument, + type ResolvedRoot, + type RootSource, +} from './root.ts' + +/** The binary's `root` object (`toRootOutput`): `{path, source, store_id?}`. */ +export interface RootOutput { + path: string + source: RootSource + store_id?: string +} + +/** `root` as the binary prints it, from the root cospec resolved. */ +export function rootOutput(root: ResolvedRoot): RootOutput { + return { + path: root.base, + source: root.source, + ...(root.store === undefined ? {} : { store_id: root.store }), + } +} + +/** + * How the entries of an array of objects are paired: the key each side names + * the entry by (`change` and `changeName`, `name`, an artifact's `id`). + */ +export interface EntryIdentity { + readonly cospec: string + readonly upstream: string +} + +/** + * Identities by path: `changes[]`, `changes[].artifacts[]` — keys joined by + * `.`, an array entry written `[]`. + */ +export type Identities = Readonly> + +export interface MergeResult { + value: T + /** Paths where both documents carry a key with different values; cospec's won. */ + collisions: string[] +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function sameJson(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + +/** + * `cospec` with every key of `upstream` it lacks added, recursing into keys + * both carry as plain objects and merging arrays of objects entry by entry by + * `identities` (an upstream entry with no cospec counterpart is appended). A + * key present on both sides keeps cospec's value; a differing one is listed in + * `collisions`. + */ +export function mergeUpstream( + cospec: T, + upstream: unknown, + identities: Identities = {}, +): MergeResult { + const collisions: string[] = [] + + const merge = (cs: unknown, up: unknown, path: string): unknown => { + if (isPlainObject(cs) && isPlainObject(up)) { + const out: Record = { ...cs } + for (const [key, value] of Object.entries(up)) { + const at = path === '' ? key : `${path}.${key}` + out[key] = key in cs ? merge(cs[key], value, at) : value + } + return out + } + const identity = identities[`${path}[]`] + if (Array.isArray(cs) && Array.isArray(up) && identity !== undefined) { + const out = cs.map((entry) => { + if (!isPlainObject(entry)) return entry + const twin = up.find( + (u) => isPlainObject(u) && u[identity.upstream] === entry[identity.cospec], + ) + return twin === undefined ? entry : merge(entry, twin, `${path}[]`) + }) + for (const u of up) { + if (!isPlainObject(u)) continue + const matched = cs.some( + (entry) => isPlainObject(entry) && entry[identity.cospec] === u[identity.upstream], + ) + if (!matched) out.push(u) + } + return out + } + if (!sameJson(cs, up)) collisions.push(path) + return cs + } + + return { value: merge(cospec, upstream, '') as T, collisions } +} + +/** + * `resolveRoot` for a command answering `--json` with its own failure + * document (design D10): a selection failure prints one + * `{...payload, status}` document — `payload` the command's null-shape, as + * the binary's `failurePayload` is — and the command exits 1, so nothing + * reaches the top-level handler. A selection diagnostic keeps its own code; a + * raw resolver failure (`RawSelectionError`, which the binary rethrows rather + * than diagnoses) carries the command's code, as the binary's per-command + * handler reports it. `undefined` means the document is written. Outside + * `--json` the error propagates unchanged. + */ +export async function resolveRootOrDocument( + ctx: { cwd: string; flags: { store?: string; json?: boolean } }, + code: string, + payload: Readonly> = {}, +): Promise { + try { + return await resolveRoot(ctx) + } catch (error) { + if (ctx.flags.json !== true || !(error instanceof RootSelectionError)) throw error + const reported = + error instanceof RawSelectionError + ? new RootSelectionError({ code, message: error.diagnostic.message }) + : error + process.stdout.write(rootSelectionDocument(reported, payload)) + return undefined + } +} diff --git a/apps/cli/test/contract/cli-surface.test.ts b/apps/cli/test/contract/cli-surface.test.ts new file mode 100644 index 00000000..bfd90838 --- /dev/null +++ b/apps/cli/test/contract/cli-surface.test.ts @@ -0,0 +1,2779 @@ +// cli-surface-parity, probed against the REAL pinned binary (design D5, D11). +// +// Every row builds its fixture in a temp root, runs the pinned binary through +// the upstream oracle and cospec from source under the identical sandbox +// environment (`oracleEnv(root)`), and compares the two answers at test time — +// no row holds a hand-typed copy of an upstream string. The key oracle +// (`support/key-oracle.ts`) compares whole `--json` documents; the rest compare +// the part of an answer their row names. + +import { afterAll, describe, expect, test } from 'bun:test' +import { + chmodSync, + cpSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +import { computeStatus } from '../../src/commands/status.ts' +import { COSPEC_TYPES, resolveChange } from '../../src/core/change.ts' +import { openspecPackageDir } from '../../src/core/openspec.ts' +import { respellRemedies, respellWholeRemedy } from '../../src/core/remedies.ts' +import { errnoShape } from '../fixtures/errno.ts' +import { + cleanupAll, + cospec, + emptyMachineStateEnv, + mkTempRepo, + REPO_ROOT, + type SpawnResult, + writeFiles, +} from '../fixtures/support.ts' +import { buildValidFeat } from './fixtures.ts' +import { + byCodeAndName, + byKey, + byKindAndId, + checkNativeKeys, + compareDocuments, + NAMED_COLLISIONS, + type OracleSpec, +} from './support/key-oracle.ts' +import { makeSandbox } from './support/root-sandbox.ts' +import { oracle, oracleEnv, type OracleRun } from './support/upstream-oracle.ts' + +afterAll(cleanupAll) + +/** Mode-000 rows cannot fail for root, which reads any file. */ +const RUNNING_AS_ROOT = process.getuid?.() === 0 +const unlessRoot = RUNNING_AS_ROOT ? describe.skip : describe + +// --- fixture builders -------------------------------------------------------- + +const PROPOSAL = `# Proposal + +## Why + +The widget rendering path needs a restated requirement so the spec matches the +shipped code; without it the capability is documented wrongly. + +## What Changes + +- Restate the widget rendering requirement. + +## Impact + +- None. + +## Surfaces + +- [ ] interactive — a user-visible/interactive surface (UI, TUI, CLI UX) +` + +const DELTA = `## ADDED Requirements + +### Requirement: Widget rendering + +The system SHALL render a widget when requested. + +#### Scenario: Render a widget + +- **WHEN** a caller requests a widget +- **THEN** a widget is rendered +` + +const LIVING = (name: string): string => `# ${name} Specification + +## Purpose + +The ${name} capability, described well enough to pass the purpose check. + +## Requirements + +### Requirement: ${name} works + +The system SHALL make ${name} work. + +#### Scenario: It works + +- **WHEN** a caller uses ${name} +- **THEN** it works +` + +/** + * A root as `cospec init` leaves it: every cospec type installed as a project + * schema (the binary resolves `feat` from there), `config.yaml` naming `feat`, + * and the three planning directories. + */ +function cospecRoot(configSchema: string | null = 'feat'): string { + const root = mkTempRepo({ git: true }) + for (const type of COSPEC_TYPES) + cpSync(join(REPO_ROOT, 'openspec/schemas', type), join(root, 'openspec/schemas', type), { + recursive: true, + }) + mkdirSync(join(root, 'openspec/specs'), { recursive: true }) + mkdirSync(join(root, 'openspec/changes/archive'), { recursive: true }) + writeFiles(root, { + 'openspec/config.yaml': configSchema === null ? '# no schema\n' : `schema: ${configSchema}\n`, + }) + return root +} + +/** A change directory holding `files`; `.openspec.yaml` names `schema` unless it is `null`. */ +function writeChange( + root: string, + id: string, + files: Record = {}, + schema: string | null = 'feat', + schemaVersion = 2, +): string { + const dir = `openspec/changes/${id}` + const out: Record = {} + if (schema !== null) + out[`${dir}/.openspec.yaml`] = + `schema: ${schema}\ncreated: 2026-09-01\nschemaVersion: ${schemaVersion}\n` + for (const [rel, body] of Object.entries(files)) out[`${dir}/${rel}`] = body + writeFiles(root, out) + mkdirSync(join(root, dir), { recursive: true }) + return join(root, dir) +} + +/** Every file and directory under `dir`, deepest first. */ +function treeOf(dir: string): string[] { + const out: string[] = [] + const walk = (d: string): void => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const p = join(d, entry.name) + if (entry.isDirectory()) walk(p) + out.push(p) + } + } + walk(dir) + out.push(dir) + return out +} + +/** + * Stage each named change's mtimes: `ids[0]` oldest. Run after every write, + * since writing a file resets its mtime and the binary takes the newest file + * under the change. + */ +function stageMtimes(root: string, ids: readonly string[]): void { + const base = Date.parse('2026-09-01T00:00:00Z') / 1000 + ids.forEach((id, i) => { + const t = base + i * 3600 + for (const p of treeOf(join(root, 'openspec/changes', id))) utimesSync(p, t, t) + }) +} + +/** `changes///` holding only a `.openspec.yaml`: a namespace folder. */ +function namespaceFolder(root: string, name = 'mobile', child = 'refresh-token'): void { + writeFiles(root, { + [`openspec/changes/${name}/${child}/.openspec.yaml`]: 'schema: feat\ncreated: 2026-09-01\n', + }) +} + +/** + * The list fixture: `alpha` (a `feat` change with only `proposal.md`), `beta` + * (a `fix` change with tasks, half done), `gamma` (a `chore` change), a + * namespace folder `mobile`, staged so the binary's recent-first order is + * `gamma, beta, alpha` with `mobile` newest. + */ +function listFixture(): string { + const root = cospecRoot() + writeChange(root, 'alpha', { 'proposal.md': PROPOSAL }) + writeChange( + root, + 'beta', + { + 'proposal.md': PROPOSAL, + 'tasks.md': '## 1. Work\n\n- [x] 1.1 First\n- [ ] 1.2 Second\n', + }, + 'fix', + ) + writeChange(root, 'gamma', { 'proposal.md': PROPOSAL }, 'chore') + namespaceFolder(root) + stageMtimes(root, ['alpha', 'beta', 'gamma', 'mobile']) + return root +} + +/** + * One candidate per detector signal (design D2), each named for the case: + * `ns-*` must be reported as a namespace folder, `ch-*` must not. + */ +function detectorMatrix(root: string): void { + writeFiles(root, { + // A child with a root marker. + 'openspec/changes/ns-marker/c/proposal.md': PROPOSAL, + // A child with a delta file and nothing else. + 'openspec/changes/ns-delta/c/specs/widgets/spec.md': DELTA, + // A child with only a dot-file under specs/: not a change, so no nesting. + 'openspec/changes/ch-dotspec/c/specs/.keep': '', + // A child with only an output of the root's schema (`feat`'s verification.md). + 'openspec/changes/ns-schema/c/verification.md': '# Verification\n', + // A file of its own keeps the directory a change. + 'openspec/changes/ch-ownfile/README.md': '# notes\n', + 'openspec/changes/ch-ownfile/c/.openspec.yaml': 'schema: feat\n', + // A dot-file of its own does not. + 'openspec/changes/ns-owndot/.DS_Store': '', + 'openspec/changes/ns-owndot/c/.openspec.yaml': 'schema: feat\n', + // Depths one to four. + 'openspec/changes/ns-depth1/c/.openspec.yaml': 'schema: feat\n', + 'openspec/changes/ns-depth2/a/c/.openspec.yaml': 'schema: feat\n', + 'openspec/changes/ns-depth3/a/b/c/.openspec.yaml': 'schema: feat\n', + 'openspec/changes/ch-depth4/a/b/c/d/.openspec.yaml': 'schema: feat\n', + // A dot-directory child is never searched. + 'openspec/changes/ch-dotchild/.c/.openspec.yaml': 'schema: feat\n', + // Two nested changes, reported sorted. + 'openspec/changes/ns-two/zeta/.openspec.yaml': 'schema: feat\n', + 'openspec/changes/ns-two/eta/proposal.md': PROPOSAL, + // A change-looking child is not descended into. + 'openspec/changes/ns-shallow/c/.openspec.yaml': 'schema: feat\n', + 'openspec/changes/ns-shallow/c/deeper/.openspec.yaml': 'schema: feat\n', + // A marker of its own: a change, whatever it holds. + 'openspec/changes/ch-marker/proposal.md': PROPOSAL, + 'openspec/changes/ch-marker/sub/.openspec.yaml': 'schema: feat\n', + // A dot-directory at the top is never a candidate. + 'openspec/changes/.hidden/c/.openspec.yaml': 'schema: feat\n', + // `archive` is never a candidate. + 'openspec/changes/archive/2026-01-01-old/c/.openspec.yaml': 'schema: feat\n', + }) +} + +/** A project fork of the package's `spec-driven` schema, installed as `name`. */ +function projectFork(root: string, name = 'house'): void { + cpSync(join(openspecPackageDir(), 'schemas/spec-driven'), join(root, 'openspec/schemas', name), { + recursive: true, + }) + const path = join(root, 'openspec/schemas', name, 'schema.yaml') + writeFileSync(path, readFileSync(path, 'utf8').replace(/^name: .*$/m, `name: ${name}`)) +} + +/** A change on the package's built-in `spec-driven` schema. */ +function specDrivenChange(root: string, id = 'legacy-one'): void { + writeChange(root, id, { 'proposal.md': PROPOSAL }, 'spec-driven') +} + +/** A change whose schema resolves nowhere. */ +function unknownSchemaChange(root: string, id = 'ghost'): void { + writeChange(root, id, { 'proposal.md': PROPOSAL }, 'nope') +} + +/** A hand-made change directory: `proposal.md` and no `.openspec.yaml`. */ +function handMadeChange(root: string, id = 'bare-dir'): void { + writeChange(root, id, { 'proposal.md': PROPOSAL }, null) +} + +/** `gamma` as both an active change and a living spec. */ +function ambiguousGamma(root: string): void { + writeChange(root, 'gamma', { 'proposal.md': PROPOSAL }) + writeFiles(root, { 'openspec/specs/gamma/spec.md': LIVING('gamma') }) +} + +/** + * Put `path` at mode 000 until the returned restore runs. The suite's own + * `cleanupAll` removes the tree, which needs the mode back first. + */ +function lock(path: string): () => void { + const mode = statSync(path).mode & 0o7777 + chmodSync(path, 0o000) + return () => chmodSync(path, mode) +} + +/** + * Whether this runtime's `realpath` refuses a mode-000 file. The binary + * confines every artifact output through `realpathSync.native` before it reads + * one (`FileSystemUtils.canonicalizePotentialPath`): Bun on macOS opens the + * file to resolve it and fails with EACCES; Bun on Linux and Node anywhere + * resolve it without opening it. Where it refuses, the binary refuses the + * change; where it doesn't, the binary counts an unreadable `tasks.md` as no + * tasks, as its `countTaskFile` counts any unreadable task file. + */ +function realpathRefuses(path: string): boolean { + try { + realpathSync.native(path) + return false + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EACCES') return true + throw error + } +} + +// --- runners ------------------------------------------------------------------- + +interface JsonAnswer { + exitCode: number + json: unknown + stdout: string + stderr: string +} + +function parseOne(label: string, stdout: string): unknown { + try { + return JSON.parse(stdout) + } catch (err) { + throw new Error( + `${label} did not print one JSON document: ${JSON.stringify(stdout.slice(0, 400))}`, + { + cause: err, + }, + ) + } +} + +/** The binary's answer for `argv` in `root` (or `cwd`). */ +function upstream(argv: string[], root: string, cwd = root): Promise { + return oracle(argv, root, { cwd }) +} + +/** cospec's answer for `argv` in `root` (or `cwd`), under the oracle's sandbox env. */ +function ours( + argv: string[], + root: string, + cwd = root, + env: Record = {}, +): Promise { + return cospec(argv, { cwd, env: { ...oracleEnv(root), ...env } }) +} + +async function upstreamJson(argv: string[], root: string, cwd = root): Promise { + const run = await upstream(argv, root, cwd) + return { ...run, json: parseOne(`openspec ${argv.join(' ')}`, run.stdout) } +} + +async function oursJson( + argv: string[], + root: string, + cwd = root, + env: Record = {}, +): Promise { + const run = await ours(argv, root, cwd, env) + return { ...run, json: parseOne(`cospec ${argv.join(' ')}`, run.stdout) } +} + +// --- the key oracle's own contract (verification 1.8) ----------------------------- + +describe('the key oracle', () => { + const spec: OracleSpec = { + identities: { 'changes[]': { upstream: byKey('changeName'), cospec: byKey('change') } }, + timing: ['**.lastModified'], + } + const upstreamDoc = { + changes: [{ changeName: 'a', schemaName: 'feat', lastModified: '2026-01-01' }], + root: { path: '/r', source: 'nearest' }, + version: '1.0', + } + const good = { + version: 1, + changes: [ + { change: 'a', type: 'feat', changeName: 'a', schemaName: 'feat', lastModified: 'x' }, + ], + root: { path: '/r', source: 'nearest' }, + } + + test('a document carrying every upstream key passes, whatever its version', () => { + expect(compareDocuments(upstreamDoc, good, spec).failures).toEqual([]) + }) + + test('a string root where the binary has an object fails, naming root', () => { + const failures = compareDocuments(upstreamDoc, { ...good, root: '/r' }, spec).failures + expect(failures).toHaveLength(1) + expect(failures[0]).toStartWith('root: collision') + }) + + test('a missing upstream key fails, naming its path', () => { + const doc = { + ...good, + changes: [{ change: 'a', type: 'feat', changeName: 'a', lastModified: 'x' }], + } + const failures = compareDocuments(upstreamDoc, doc, spec).failures + expect(failures).toEqual([expect.stringContaining('changes[].schemaName: missing')]) + }) + + test('an entry the binary has and cospec lacks fails, naming the identity', () => { + const failures = compareDocuments(upstreamDoc, { ...good, changes: [] }, spec).failures + expect(failures).toEqual([ + expect.stringContaining('changes: no cospec entry for the binary\'s "a"'), + ]) + }) + + test('a timing value of another JSON type fails', () => { + const doc = { ...good, changes: [{ ...good.changes[0], lastModified: 7 }] } + const failures = compareDocuments(upstreamDoc, doc, spec).failures + expect(failures).toEqual([expect.stringContaining('changes[].lastModified')]) + }) + + test('an unnamed collision fails; the named items[].type one passes only with kind', () => { + const up = { + items: [ + { id: 'a', type: 'change' }, + { id: 's', type: 'spec' }, + ], + } + const vspec: OracleSpec = { + identities: { 'items[]': { upstream: byKindAndId('type'), cospec: byKindAndId('kind') } }, + collisions: ['items[].type'], + } + const ok = { + items: [ + { id: 'a', kind: 'change', type: 'feat' }, + { id: 's', kind: 'spec' }, + ], + } + expect(compareDocuments(up, ok, vspec).failures).toEqual([]) + expect( + compareDocuments(up, ok, { identities: vspec.identities }).failures.join('\n'), + ).toContain('items[].type: collision: cospec has "feat" where the binary has "change"') + const broken = { + items: [ + { id: 'a', kind: 'change', type: 'feat' }, + { id: 's', kind: 'spec', type: 'x' }, + ], + } + expect(compareDocuments(up, broken, vspec).failures).toEqual([ + expect.stringContaining('items[].type: named collision broken'), + ]) + expect(NAMED_COLLISIONS.map((c) => c.path)).toEqual(['version', 'items[].type']) + }) + + test('a respelled path must carry the binary value spelled through cospec', () => { + const up = { + nextSteps: [ + 'Run openspec instructions design --change "a" --json before writing that artifact.', + ], + } + const rspec: OracleSpec = { respelled: ['nextSteps[]'] } + expect(compareDocuments(up, up, rspec).failures).toEqual([ + expect.stringContaining('nextSteps[]: '), + ]) + const respelled = { + nextSteps: [ + 'Run cospec instructions design --change "a" --json before writing that artifact.', + ], + } + expect(compareDocuments(up, respelled, rspec).failures).toEqual([]) + }) + + test('a dropped or changed snapshotted cospec key fails, naming its path', () => { + const native = { changes: [{ change: 'a', type: 'feat', gate: 'clear' }] } + const ids = { 'changes[]': { upstream: byKey('changeName'), cospec: byKey('change') } } + const snapshot = ['changes[].change', 'changes[].type', 'changes[].gate'] + expect(checkNativeKeys(good, native, snapshot, ids)).toEqual([ + expect.stringContaining('changes[].gate: cospec key dropped'), + ]) + const changed = { changes: [{ change: 'a', type: 'fix', gate: 'clear' }] } + expect(checkNativeKeys(changed, native, snapshot, ids)).toEqual([ + expect.stringContaining('changes[].type: cospec value changed'), + ]) + expect(checkNativeKeys(native, native, snapshot, ids)).toEqual([]) + }) + + test('a kept path is compared by presence and type, never by its entries', () => { + const up = { artifacts: [{ id: 'proposal', status: 'ready' }] } + const kspec: OracleSpec = { + identities: { 'artifacts[]': { upstream: byKey('id'), cospec: byKey('id') } }, + kept: ['artifacts'], + } + expect(compareDocuments(up, { artifacts: [] }, kspec).failures).toEqual([]) + expect(compareDocuments(up, {}, kspec).failures).toEqual([ + expect.stringContaining('artifacts: missing'), + ]) + expect(compareDocuments(up, { artifacts: 'none' }, kspec).failures).toEqual([ + expect.stringContaining('artifacts: kept value is a string'), + ]) + expect(compareDocuments(up, { artifacts: [] }, { ...kspec, kept: [] }).failures).toEqual([ + expect.stringContaining('no cospec entry'), + ]) + }) + + test('an empty upstream array is reported so a row can require its fixture to fill it', () => { + const { emptyArrays } = compareDocuments({ warnings: [] }, { warnings: [] }, {}) + expect(emptyArrays).toEqual(['warnings']) + }) +}) + +// --- the fixtures stand up against the binary ------------------------------------ + +describe('cli-surface fixtures', () => { + test('the list fixture lists recent-first with the namespace folder marked', async () => { + const root = listFixture() + const up = await upstreamJson(['list', '--json'], root) + const rows = (up.json as { changes: { name: string; nested?: string[] }[] }).changes + expect(rows.map((r) => r.name)).toEqual(['mobile', 'gamma', 'beta', 'alpha']) + expect(rows[0]!.nested).toEqual(['mobile/refresh-token']) + }) + + test('the detector matrix marks exactly the ns-* candidates', async () => { + const root = cospecRoot() + detectorMatrix(root) + const up = await upstreamJson(['list', '--json'], root) + const rows = (up.json as { changes: { name: string; nested?: string[] }[] }).changes + const marked = rows + .filter((r) => r.nested !== undefined) + .map((r) => r.name) + .toSorted() + expect(marked).toEqual( + rows + .map((r) => r.name) + .filter((n) => n.startsWith('ns-')) + .toSorted(), + ) + expect(rows.find((r) => r.name === 'ns-two')!.nested).toEqual(['ns-two/eta', 'ns-two/zeta']) + }) + + test('the schema fixtures resolve as the binary resolves them', async () => { + const root = cospecRoot() + projectFork(root) + writeChange(root, 'forked', { 'proposal.md': PROPOSAL }, 'house') + specDrivenChange(root) + unknownSchemaChange(root) + handMadeChange(root) + const status = async (id: string) => + (await upstreamJson(['status', '--change', id, '--json'], root)).json as Record< + string, + unknown + > + expect((await status('forked')).schemaName).toBe('house') + expect((await status('legacy-one')).schemaName).toBe('spec-driven') + expect((await status('bare-dir')).schemaName).toBe('feat') + expect(JSON.stringify(await status('ghost'))).toContain("Unknown schema 'nope'") + }) + + test('the ambiguous gamma fixture is refused by the binary', async () => { + const root = cospecRoot() + ambiguousGamma(root) + const up = await upstreamJson(['validate', 'gamma', '--json'], root) + expect(JSON.stringify(up.json)).toContain('ambiguous_item') + }) + + unlessRoot('mode 000', () => { + test("an unreadable tasks.md is the binary's list_error where its realpath refuses the file", async () => { + const root = listFixture() + const tasks = join(root, 'openspec/changes/beta/tasks.md') + const restore = lock(tasks) + try { + const refused = realpathRefuses(tasks) + const up = await upstreamJson(['list', '--json'], root) + expect(up.exitCode).toBe(refused ? 1 : 0) + if (refused) expect(firstStatus(up.json).code).toBe('list_error') + else + expect(rowsOf(up.json).find((r) => r.name === 'beta')).toMatchObject({ + completedTasks: 0, + totalTasks: 0, + }) + } finally { + restore() + } + }) + }) +}) + +// --- shared row helpers ------------------------------------------------------------ + +const BLOCKERS = `# Dependencies + +## Blocked by + +None. + +## Soft-blocked by + +None. +` + +const VERIFICATION = `# Verification + +## 1. It works [critical] + +- [ ] 1.1 @unit (agent) run the tests -> they pass +` + +const TASKS = '## 1. Work\n\n- [ ] 1.1 Do it\n' + +/** A bare `openspec ` anywhere in `text` (paths like `openspec/changes/` excepted). */ +const BARE_OPENSPEC = /(? { + const up = await upstreamJson(['status', '--change', name, '--json'], root) + return firstStatus(up.json).message +} + +type Row = Record + +function rowsOf(doc: unknown, key = 'changes'): Row[] { + return ((doc as Record)[key] ?? []) as Row[] +} + +const STATUS_ENTRY_SNAPSHOT = [ + 'change', + 'type', + 'state', + 'gate', + 'gateState', + 'tasks', + 'archiveReady', + 'verification', + 'error', + 'artifacts[].id', + 'artifacts[].done', + 'artifacts[].required', + 'artifacts[].ready', +] + +const STATUS_SPEC: OracleSpec = { + identities: { 'artifacts[]': { upstream: byKey('id'), cospec: byKey('id') } }, + respelled: ['nextSteps[]'], +} + +const STATUS_ALL_SPEC: OracleSpec = { + identities: { + 'changes[]': { upstream: byKey('changeName'), cospec: byKey('change') }, + 'changes[].artifacts[]': { upstream: byKey('id'), cospec: byKey('id') }, + }, + respelled: ['changes[].nextSteps[]'], +} + +const LIST_SPEC: OracleSpec = { + identities: { + 'changes[]': { upstream: byKey('name'), cospec: byKey('change') }, + 'warnings[]': { upstream: byCodeAndName, cospec: byCodeAndName }, + }, + timing: ['changes[].lastModified'], +} + +const VALIDATE_SPEC: OracleSpec = { + identities: { 'items[]': { upstream: byKindAndId('type'), cospec: byKindAndId('kind') } }, + timing: ['items[].durationMs'], + verdict: ['items[].valid', 'items[].issues', 'summary.totals.*', 'summary.byType.*.*'], + collisions: ['items[].type'], +} + +const FINDINGS_SPEC: OracleSpec = { + verdict: ['itemFindings', 'report.returnedItems', 'summary.totals.*', 'summary.byType.*.*'], +} + +function expectOracle(up: unknown, cs: unknown, spec: OracleSpec): void { + const { failures } = compareDocuments(up, cs, spec) + expect(failures).toEqual([]) +} + +/** A `feat` change whose every required artifact exists and whose `design.md` does not. */ +function requiredDone(root: string, id: string): void { + writeChange(root, id, { + 'proposal.md': PROPOSAL, + 'blocking-changes.md': BLOCKERS, + 'specs/widgets/spec.md': DELTA, + 'verification.md': VERIFICATION, + 'tasks.md': TASKS, + }) +} + +// --- 1. the key oracle rows --------------------------------------------------------- + +describe('1. the key oracle passes and keeps cospec keys', () => { + test('1.1 list --json on the staged-mtime fixture', async () => { + const root = listFixture() + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + expect(cs.exitCode).toBe(up.exitCode) + const { failures, emptyArrays } = compareDocuments(up.json, cs.json, LIST_SPEC) + expect(failures).toEqual([]) + expect(emptyArrays).toEqual([]) + expect(rowsOf(cs.json).map((r) => r.change)).toEqual(rowsOf(up.json).map((r) => r.name)) + expect((cs.json as Row).version).toBe(1) + const cell = ( + change: string, + type: string, + total: number, + complete: number, + state = 'building', + ) => ({ + change, + type, + state, + gate: 'clear', + gateState: 'clear', + tasks: { total, complete }, + archiveReady: false, + }) + const native = { + changes: [ + cell('mobile', '(none)', 0, 0, 'not-a-change'), + cell('gamma', 'chore', 0, 0), + cell('beta', 'fix', 2, 1), + cell('alpha', 'feat', 0, 0), + ], + } + const snapshot = ['change', 'type', 'state', 'gate', 'gateState', 'tasks', 'archiveReady'].map( + (k) => `changes[].${k}`, + ) + expect(checkNativeKeys(cs.json, native, snapshot, LIST_SPEC.identities)).toEqual([]) + }) + + test('1.2 list --specs --json on a two-spec fixture', async () => { + const root = cospecRoot() + writeFiles(root, { + 'openspec/specs/widgets/spec.md': LIVING('widgets'), + 'openspec/specs/gadgets/spec.md': LIVING('gadgets'), + }) + const up = await upstreamJson(['list', '--specs', '--json'], root) + const cs = await oursJson(['list', '--specs', '--json'], root) + expect(cs.exitCode).toBe(up.exitCode) + const spec: OracleSpec = { + identities: { 'specs[]': { upstream: byKey('id'), cospec: byKey('id') } }, + } + const { failures, emptyArrays } = compareDocuments(up.json, cs.json, spec) + expect(failures).toEqual([]) + expect(emptyArrays).toEqual([]) + expect((cs.json as Row).version).toBe(1) + }) + + test('1.3 status --change alpha --json on a feat change with only proposal.md', async () => { + const root = listFixture() + const up = await upstreamJson(['status', '--change', 'alpha', '--json'], root) + const cs = await oursJson(['status', '--change', 'alpha', '--json'], root) + captureStatus('1.3', cs) + expect(cs.exitCode).toBe(up.exitCode) + expectOracle(up.json, cs.json, STATUS_SPEC) + const native = computeStatus(root, resolveChange(root, 'alpha')!) + expect(checkNativeKeys(cs.json, native, STATUS_ENTRY_SNAPSHOT, STATUS_SPEC.identities)).toEqual( + [], + ) + expect((cs.json as Row).root).toEqual((up.json as Row).root) + }) + + test('1.4 status --all --json on the list fixture', async () => { + const root = listFixture() + const up = await upstreamJson(['status', '--all', '--json'], root) + const cs = await oursJson(['status', '--all', '--json'], root) + captureStatus('1.4', cs) + expect(cs.exitCode).toBe(up.exitCode) + const { failures, emptyArrays } = compareDocuments(up.json, cs.json, STATUS_ALL_SPEC) + expect(failures).toEqual([]) + // `linkedContext` is always empty upstream, and an artifact's + // `existingOutputPaths` is empty wherever that artifact is unwritten. + expect( + emptyArrays.filter( + (p) => !p.endsWith('linkedContext') && !p.endsWith('.existingOutputPaths'), + ), + ).toEqual([]) + const message = await explanation(root) + const native = { + changes: [ + ...['alpha', 'beta', 'gamma'].map((id) => computeStatus(root, resolveChange(root, id)!)), + { change: 'mobile', error: message }, + ], + } + const snapshot = STATUS_ENTRY_SNAPSHOT.map((p) => `changes[].${p}`) + expect(checkNativeKeys(cs.json, native, snapshot, STATUS_ALL_SPEC.identities)).toEqual([]) + }) + + test('1.5 validate alpha --json and validate --all --json', async () => { + const root = listFixture() + writeChange(root, 'delta-one', { + 'proposal.md': PROPOSAL, + 'blocking-changes.md': BLOCKERS, + 'specs/widgets/spec.md': DELTA, + 'verification.md': VERIFICATION, + 'tasks.md': TASKS, + }) + writeFiles(root, { 'openspec/specs/gadgets/spec.md': LIVING('gadgets') }) + for (const argv of [ + ['validate', 'delta-one', '--json'], + ['validate', '--all', '--json'], + ]) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + const { failures, emptyArrays } = compareDocuments(up.json, cs.json, VALIDATE_SPEC) + expect(failures).toEqual([]) + expect(emptyArrays).toEqual([]) + const doc = cs.json as Row + expect(doc.version).toBe(1) + // A change item's `type` is its schema (a namespace folder has none). + const schemas: Record = { + alpha: 'feat', + beta: 'fix', + gamma: 'chore', + 'delta-one': 'feat', + } + for (const item of rowsOf(doc, 'items')) + if (item.kind === 'change' && schemas[String(item.id)] !== undefined) + expect({ id: item.id, type: item.type }).toEqual({ + id: item.id, + type: schemas[String(item.id)], + }) + const summary = doc.summary as Row + for (const key of ['errors', 'warnings', 'byRule', 'totals', 'byType']) + expect(summary).toHaveProperty(key) + } + }) + + test('1.6 validate --all --report findings --json', async () => { + const root = listFixture() + writeFiles(root, { 'openspec/specs/gadgets/spec.md': LIVING('gadgets') }) + const argv = ['validate', '--all', '--report', 'findings', '--json'] + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + expectOracle(up.json, cs.json, FINDINGS_SPEC) + const doc = cs.json as Row + expect(doc.version).toBe(1) + const report = doc.report as Row + expect(report.kind).toBe('validation-findings') + expect(report.version).toBe('1.0') + for (const key of ['scope', 'returnedItems', 'totalItems']) expect(report).toHaveProperty(key) + expect(doc).toHaveProperty('itemFindings') + expect(doc).toHaveProperty('root') + }) + + test('1.7 apply nope --json beside instructions apply --change nope --json', async () => { + const root = listFixture() + const up = await upstreamJson(['instructions', 'apply', '--change', 'nope', '--json'], root) + const cs = await oursJson(['apply', 'nope', '--json'], root) + expect(up.exitCode).toBe(1) + expect(cs.exitCode).toBe(1) + const want = firstStatus(up.json) + const got = firstStatus(cs.json) + for (const key of ['severity', 'code', 'message']) expect(got).toHaveProperty(key) + expect(got.code).toBe(want.code) + expect(got.code).toBe('change_error') + }) +}) + +// --- 3. every status entry names its next step --------------------------------------- + +describe('3. status next steps', () => { + test('3.1 a mid-build feat change prints and carries its next step', async () => { + const root = listFixture() + const text = await ours(['status', '--change', 'alpha'], root) + captureStatus('3.1 text', text) + expect(text.exitCode).toBe(0) + expect( + text.stdout.endsWith('Next: cospec instructions blocking-changes --change alpha\n'), + ).toBe(true) + const json = await oursJson(['status', '--change', 'alpha', '--json'], root) + captureStatus('3.1 json', json) + expect((json.json as Row).next).toBe('cospec instructions blocking-changes --change alpha') + }) + + test('3.5 an empty change keeps its next spelling in both modes', async () => { + const root = cospecRoot() + writeChange(root, 'empty') + const text = await ours(['status', '--change', 'empty'], root) + captureStatus('3.5 text', text) + expect(text.exitCode).toBe(0) + expect(text.stdout).toContain('next: cospec instructions proposal --change empty') + const json = await oursJson(['status', '--change', 'empty', '--json'], root) + captureStatus('3.5 json', json) + expect((json.json as Row).next).toBe('cospec instructions proposal --change empty') + }) + + test("3.2 nextSteps equals the binary's, respelled, on five fixtures", async () => { + const root = cospecRoot() + writeChange(root, 'empty') + writeChange(root, 'mid', { 'proposal.md': PROPOSAL }) + requiredDone(root, 'done-but-design') + writeFiles(root, { + 'openspec/changes/skipped/.openspec.yaml': + 'schema: feat\ncreated: 2026-09-01\nschemaVersion: 2\nskip_specs: true\n', + 'openspec/changes/skipped/proposal.md': PROPOSAL, + }) + specDrivenChange(root, 'driven') + for (const id of ['empty', 'mid', 'done-but-design', 'skipped', 'driven']) { + const up = await upstreamJson(['status', '--change', id, '--json'], root) + const cs = await oursJson(['status', '--change', id, '--json'], root) + captureStatus(`3.2 ${id}`, cs) + const want = ((up.json as Row).nextSteps as string[]).map(respellWholeRemedy) + const got = (cs.json as Row).nextSteps as string[] + expect({ id, got }).toEqual({ id, got: want }) + for (const step of got) expect(step).not.toMatch(BARE_OPENSPEC) + } + }) + + test('3.4 each cospec type declares its artifacts in the binary order', async () => { + const root = cospecRoot() + for (const type of COSPEC_TYPES) + writeChange(root, `t-${type}`, { 'proposal.md': PROPOSAL }, type) + for (const type of COSPEC_TYPES) { + const id = `t-${type}` + const up = await upstreamJson(['status', '--change', id, '--json'], root) + const cs = await oursJson(['status', '--change', id, '--json'], root) + const ids = (doc: unknown) => rowsOf(doc, 'artifacts').map((a) => a.id) + expect({ type, order: ids(cs.json) }).toEqual({ type, order: ids(up.json) }) + } + }) +}) + +// --- 4. a namespace folder is reported as one ---------------------------------------------- + +describe('4. namespace folders', () => { + test('4.1 status --change mobile refuses it in text and --json', async () => { + const root = listFixture() + const message = await explanation(root) + const upText = await upstream(['status', '--change', 'mobile'], root) + const csText = await ours(['status', '--change', 'mobile'], root) + captureStatus('4.1 text', csText) + expect(upText.exitCode).toBe(1) + expect(csText.exitCode).toBe(1) + expect(csText.stderr).toContain(message) + const up = await upstreamJson(['status', '--change', 'mobile', '--json'], root) + const cs = await oursJson(['status', '--change', 'mobile', '--json'], root) + captureStatus('4.1 json', cs) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(up.json) + }) + + test('4.2 status --all --json carries the folder as a failure entry', async () => { + const root = listFixture() + const message = await explanation(root) + const up = await upstreamJson(['status', '--all', '--json'], root) + const cs = await oursJson(['status', '--all', '--json'], root) + captureStatus('4.2', cs) + expect(cs.exitCode).toBe(up.exitCode) + expect(cs.exitCode).toBe(1) + const entries = rowsOf(cs.json) + const folder = entries.find((e) => e.change === 'mobile')! + expect(folder.error).toBe(message) + expect(firstStatus(folder).message).toBe(message) + for (const e of entries.filter((x) => x.change !== 'mobile')) + expect(e).toHaveProperty('artifacts') + }) + + test('4.3 list marks the folder in text and --json', async () => { + const root = listFixture() + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + const row = rowsOf(cs.json).find((r) => r.change === 'mobile')! + const upRow = rowsOf(up.json).find((r) => r.name === 'mobile')! + expect(row.state).toBe('not-a-change') + expect(row.nested).toEqual(upRow.nested) + expect((cs.json as Row).warnings).toEqual((up.json as Row).warnings) + const text = await ours(['list'], root) + expect(text.stdout).toMatch(/^\s+mobile\s+.*not a change/m) + const warning = ((up.json as Row).warnings as Row[])[0]!.message as string + expect(text.stdout.endsWith(`Warning: ${warning}\n`)).toBe(true) + }) + + test('4.4 validate reports the folder as one meta/nested-change', async () => { + const root = listFixture() + const message = await explanation(root) + for (const argv of [ + ['validate', 'mobile', '--json'], + ['validate', '--all', '--json'], + ]) { + const cs = await oursJson(argv, root) + expect(cs.exitCode).toBe(1) + const item = rowsOf(cs.json, 'items').find((i) => i.id === 'mobile')! + const issues = item.issues as { level: string; rule: string; message: string }[] + expect(issues).toHaveLength(1) + expect(issues[0]).toMatchObject({ level: 'ERROR', rule: 'meta/nested-change', message }) + } + }) + + test("4.6 the detector matrix nests every row as the binary's list does", async () => { + const root = cospecRoot() + detectorMatrix(root) + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + const nested = (rows: Row[], key: string) => + Object.fromEntries(rows.map((r) => [r[key] as string, r.nested ?? null])) + expect(nested(rowsOf(cs.json), 'change')).toEqual(nested(rowsOf(up.json), 'name')) + }) +}) + +// --- 5. a schema cospec doesn't type gets real status ------------------------------------ + +/** stdout lines, with the `Next:` line split out. */ +function statusText(stdout: string): { body: string[]; next: string | undefined } { + const lines = stdout.split('\n') + const nextLine = lines.find((l) => l.startsWith('Next: ')) + return { body: lines.filter((l) => !l.startsWith('Next: ')), next: nextLine } +} + +/** The artifact a `Next:` line names (`instructions `), for either spelling. */ +function nextArtifact(line: string | undefined): string | undefined { + return line === undefined ? undefined : /instructions (\S+)/.exec(line)?.[1] +} + +describe('5. schemas cospec does not type', () => { + test('5.1 a project fork gets the binary status, spelled cospec', async () => { + const root = cospecRoot() + projectFork(root) + writeChange(root, 'forked', { 'proposal.md': PROPOSAL }, 'house') + const upText = await upstream(['status', '--change', 'forked'], root) + const csText = await ours(['status', '--change', 'forked'], root) + captureStatus('5.1 text', csText) + expect(csText.exitCode).toBe(0) + const u = statusText(upText.stdout) + const c = statusText(csText.stdout) + expect(c.body).toEqual(u.body) + expect(c.next).toBe(`Next: cospec instructions ${nextArtifact(u.next)} --change forked`) + const up = await upstreamJson(['status', '--change', 'forked', '--json'], root) + const cs = await oursJson(['status', '--change', 'forked', '--json'], root) + captureStatus('5.1 json', cs) + expect(cs.exitCode).toBe(0) + expect(cs.json).toMatchObject({ change: 'forked', type: 'house', legacy: true }) + expectOracle(up.json, cs.json, STATUS_SPEC) + }) + + test('5.2 a spec-driven change, singly and in the sweep', async () => { + const root = cospecRoot() + specDrivenChange(root) + const upText = await upstream(['status', '--change', 'legacy-one'], root) + const csText = await ours(['status', '--change', 'legacy-one'], root) + captureStatus('5.2 text', csText) + expect(statusText(csText.stdout).body).toEqual(statusText(upText.stdout).body) + const up = await upstreamJson(['status', '--change', 'legacy-one', '--json'], root) + const cs = await oursJson(['status', '--change', 'legacy-one', '--json'], root) + captureStatus('5.2 json', cs) + expectOracle(up.json, cs.json, STATUS_SPEC) + const sweep = await ours(['status', '--all'], root) + captureStatus('5.2 sweep', sweep) + for (const line of statusText(upText.stdout).body.filter((l) => l.length > 0)) + expect(sweep.stdout).toContain(line) + }) + + test('5.3 an unknown schema fails under --json', async () => { + const root = cospecRoot() + unknownSchemaChange(root) + const up = await upstreamJson(['status', '--change', 'ghost', '--json'], root) + const cs = await oursJson(['status', '--change', 'ghost', '--json'], root) + captureStatus('5.3', cs) + expect(up.exitCode).toBe(1) + expect(cs.exitCode).toBe(1) + expect(firstStatus(cs.json).message).toBe(firstStatus(up.json).message) + expect(firstStatus(cs.json).message).toContain("Unknown schema 'nope'") + }) + + test('5.4 a hand-made change is typed by config.yaml', async () => { + const root = cospecRoot() + handMadeChange(root) + const up = await upstreamJson(['status', '--change', 'bare-dir', '--json'], root) + const cs = await oursJson(['status', '--change', 'bare-dir', '--json'], root) + captureStatus('5.4 feat', cs) + const doc = cs.json as Row + expect(doc.type).toBe('feat') + expect(doc.schemaName).toBe((up.json as Row).schemaName) + expect(doc.schemaName).toBe('feat') + // Graded at schemaVersion 1: `verification` joined feat's gate at v2. + const required = rowsOf(doc, 'artifacts') + .filter((a) => a.required === true) + .map((a) => a.id) + expect(required).not.toContain('verification') + expect(required).toContain('proposal') + + const bare = cospecRoot(null) + handMadeChange(bare) + const up2 = await upstreamJson(['status', '--change', 'bare-dir', '--json'], bare) + const cs2 = await oursJson(['status', '--change', 'bare-dir', '--json'], bare) + captureStatus('5.4 spec-driven', cs2) + expect((up2.json as Row).schemaName).toBe('spec-driven') + expect(cs2.json).toMatchObject({ change: 'bare-dir', legacy: true, schemaName: 'spec-driven' }) + }) + + test('5.5 --schema overrides, and an unknown one is refused as the binary refuses it', async () => { + const root = listFixture() + const all = await oursJson(['status', '--all', '--schema', 'fix', '--json'], root) + captureStatus('5.5 fix', all) + for (const entry of rowsOf(all.json).filter((e) => e.change !== 'mobile')) { + expect(entry.type).toBe('fix') + expect(entry.schemaName).toBe('fix') + } + const refusal = async (argv: string[], dir: string) => { + const up = await upstreamJson(argv, dir) + const cs = await oursJson(argv, dir) + captureStatus(`5.5 ${argv.join(' ')}`, cs) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + expect(cs.json).toEqual(up.json) + } + await refusal(['status', '--change', 'alpha', '--schema', 'nope', '--json'], root) + const empty = cospecRoot() + await refusal(['status', '--all', '--schema', 'nope', '--json'], empty) + await refusal(['status', '--schema', 'nope', '--json'], empty) + }) +}) + +// --- 6. list sorts and survives read failures ----------------------------------------------- + +describe('6. list order and read failures', () => { + test('6.1 --sort recent|name|bogus orders rows as the binary does', async () => { + const root = listFixture() + for (const extra of [[], ['--sort', 'name'], ['--sort', 'bogus']]) { + const up = await upstreamJson(['list', '--json', ...extra], root) + const cs = await oursJson(['list', '--json', ...extra], root) + expect({ extra, order: rowsOf(cs.json).map((r) => r.change) }).toEqual({ + extra, + order: rowsOf(up.json).map((r) => r.name), + }) + } + }) + + unlessRoot('mode 000', () => { + /** The list fixture with `alpha` carrying blockers, its archive at mode 000. */ + function lockedArchiveRoot(): { root: string; restore: () => void } { + const root = listFixture() + writeFiles(root, { 'openspec/changes/alpha/blocking-changes.md': BLOCKERS }) + stageMtimes(root, ['alpha', 'beta', 'gamma', 'mobile']) + return { root, restore: lock(join(root, 'openspec/changes/archive')) } + } + + test('6.2 list: an unreadable archive lists normally with a warning', async () => { + const { root, restore } = lockedArchiveRoot() + try { + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + expect(cs.exitCode).toBe(0) + expect(rowsOf(cs.json).map((r) => r.change)).toEqual(rowsOf(up.json).map((r) => r.name)) + const warnings = ((cs.json as Row).warnings ?? []) as Row[] + const unreadable = warnings.filter((w) => w.code === 'archive_unreadable') + expect(unreadable).toHaveLength(1) + expect(unreadable[0]!.message).toContain('openspec/changes/archive') + const text = await ours(['list'], root) + expect(text.exitCode).toBe(0) + expect(text.stderr).toContain('openspec/changes/archive') + } finally { + restore() + } + }) + + test('6.2 status: an unreadable archive reports with a warning', async () => { + const { root, restore } = lockedArchiveRoot() + try { + const status = await oursJson(['status', '--change', 'alpha', '--json'], root) + captureStatus('6.2', status) + expect(status.exitCode).toBe(0) + const sw = ((status.json as Row).warnings ?? []) as Row[] + expect(sw.map((w) => w.code)).toEqual(['archive_unreadable']) + expect(String(sw[0]!.message)).toContain('openspec/changes/archive') + const text = await ours(['status', '--change', 'alpha'], root) + captureStatus('6.2 text', text) + expect(text.exitCode).toBe(0) + expect(text.stderr).toContain('openspec/changes/archive') + } finally { + restore() + } + }) + + /** + * Row 6.3 for one argv: the binary's answer, as rows 15.11 and 15.12 hold + * it — its failure document where its runtime's `realpath` refuses the + * file, else the change reported with no tasks. + */ + async function unreadableTasks(argv: string[]): Promise { + const root = listFixture() + const tasks = join(root, 'openspec/changes/beta/tasks.md') + const restore = lock(tasks) + try { + const refused = realpathRefuses(tasks) + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + captureStatus(`6.3 ${argv[0]}`, cs) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + expect(up.exitCode).toBe(refused ? 1 : 0) + if (!refused) { + expect((cs.json as Row).status).toBeUndefined() + return + } + const want = firstStatus(up.json) + const got = firstStatus(cs.json) + expect(got.code).toBe(want.code) + // The binary's own failure, relayed: its code, syscall (its confinement + // check's `realpath`) and path. + expect(errnoShape(got.message)).toEqual(errnoShape(want.message)) + const { status: _u, ...upRest } = up.json as Row + const { status: _c, ...csRest } = cs.json as Row + expect(csRest).toEqual(upRest) + } finally { + restore() + } + } + + test("6.3 list: an unreadable tasks.md is the binary's list_error", () => + unreadableTasks(['list', '--json'])) + + test("6.3 status: an unreadable tasks.md is the binary's change_error", () => + unreadableTasks(['status', '--change', 'beta', '--json'])) + + test('6.4 an unreadable blocking-changes.md fails only its row', async () => { + const root = listFixture() + const blockers = join(root, 'openspec/changes/beta/blocking-changes.md') + writeFiles(root, { 'openspec/changes/beta/blocking-changes.md': BLOCKERS }) + const restore = lock(blockers) + try { + const cs = await oursJson(['list', '--json'], root) + expect(cs.exitCode).toBe(1) + const rows = rowsOf(cs.json) + expect(rows.map((r) => r.change).toSorted()).toEqual(['alpha', 'beta', 'gamma', 'mobile']) + expect(typeof rows.find((r) => r.change === 'beta')!.error).toBe('string') + expect(rows.filter((r) => r.error !== undefined)).toHaveLength(1) + } finally { + restore() + } + }) + }) +}) + +// --- 7. validate resolves items and scopes as the binary does --------------------------------- + +describe('7. validate item resolution', () => { + function itemRoot(): string { + const root = listFixture() + ambiguousGamma(root) + return root + } + + test('7.1 an ambiguous name is refused in text and --json', async () => { + const root = itemRoot() + const upText = await upstream(['validate', 'gamma'], root) + const csText = await ours(['validate', 'gamma'], root) + expect(csText.exitCode).toBe(1) + expect(upText.exitCode).toBe(1) + const first = upText.stderr.split('\n')[0]! + expect(csText.stderr).toBe(`cospec: ${first}\nPass --type change|spec.\n`) + const up = await upstreamJson(['validate', 'gamma', '--json'], root) + const cs = await oursJson(['validate', 'gamma', '--json'], root) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(up.json) + }) + + test("7.2 an unknown name gets the binary's nearest matches", async () => { + const root = itemRoot() + const empty = cospecRoot() + for (const [name, dir] of [ + ['gamm', root], + ['zzzz', root], + ['zzzz', empty], + ] as const) { + const upText = await upstream(['validate', name], dir) + const csText = await ours(['validate', name], dir) + expect(csText.exitCode).toBe(1) + expect(csText.stderr).toBe(`cospec: ${upText.stderr}`) + const up = await upstreamJson(['validate', name, '--json'], dir) + const cs = await oursJson(['validate', name, '--json'], dir) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(up.json) + expect(firstStatus(cs.json).code).toBe('unknown_item') + } + }) + + test('7.3 --type forces the kind, case-insensitively', async () => { + const root = itemRoot() + const kinds = async (argv: string[]) => { + const up = await upstreamJson([...argv, '--json'], root) + const cs = await oursJson([...argv, '--json'], root) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + return { + up: rowsOf(up.json, 'items').map((i) => `${String(i.type)}:${String(i.id)}`), + cs: rowsOf(cs.json, 'items').map((i) => `${String(i.kind)}:${String(i.id)}`), + upDoc: up.json, + csDoc: cs.json, + } + } + const spec = await kinds(['validate', 'gamma', '--type', 'spec']) + expect(spec.cs).toEqual(['spec:gamma']) + expect(spec.cs).toEqual(spec.up) + const change = await kinds(['validate', 'gamma', '--type', 'CHANGE']) + expect(change.cs).toEqual(['change:gamma']) + const bogus = await kinds(['validate', 'gamma', '--type', 'bogus']) + expect(bogus.csDoc).toEqual(bogus.upDoc) + expect(firstStatus(bogus.csDoc).code).toBe('ambiguous_item') + const invalid = await kinds(['validate', '../x', '--type', 'change']) + expect(invalid.csDoc).toEqual(invalid.upDoc) + expect(firstStatus(invalid.csDoc).code).toBe('invalid_item') + const missing = await kinds(['validate', 'nope', '--type', 'spec']) + const item = rowsOf(missing.csDoc, 'items')[0]! + expect(item).toMatchObject({ id: 'nope', kind: 'spec', valid: false }) + expect((item.issues as Row[]).map((i) => i.rule)).toEqual(['meta/item-missing']) + }) + + test('7.4 a bulk flag beside a name runs the bulk scope', async () => { + const root = itemRoot() + for (const flag of ['--all', '--changes', '--specs']) { + const argv = ['validate', 'alpha', flag, '--json'] + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + const set = (doc: unknown, kindKey: string) => + rowsOf(doc, 'items') + .map((i) => `${String(i[kindKey])}:${String(i.id)}`) + .toSorted() + expect({ flag, items: set(cs.json, 'kind') }).toEqual({ flag, items: set(up.json, 'type') }) + } + }) + + test('7.5 the four --report refusals, before any root', async () => { + const dir = mkTempRepo() + const cases = [ + ['--report', 'bogus', '--all'], + ['alpha', '--report', 'full'], + ['--archived', '--all', '--report', 'full'], + ['--report', 'findings'], + ] + for (const args of cases) { + const upText = await upstream(['validate', ...args], dir) + const csText = await ours(['validate', ...args], dir) + expect({ args, exit: csText.exitCode, stderr: csText.stderr }).toEqual({ + args, + exit: 1, + stderr: upText.stderr, + }) + const up = await upstreamJson(['validate', ...args, '--json'], dir) + const cs = await oursJson(['validate', ...args, '--json'], dir) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(up.json) + expect(firstStatus(cs.json).code).toBe('invalid_validation_report_request') + } + }) + + test("7.6 --report findings keeps full's exit code and lists only failing items", async () => { + const root = cospecRoot() + writeChange(root, 'broken', { 'proposal.md': PROPOSAL }, 'nope') + writeChange( + root, + 'clean', + { + 'proposal.md': PROPOSAL, + 'blocking-changes.md': BLOCKERS, + 'tasks.md': '## 1. W\n\n- [x] 1.1 Done\n', + }, + 'chore', + ) + for (const json of [[], ['--json']]) { + const full = await ours(['validate', '--all', '--report', 'full', ...json], root) + const findings = await ours(['validate', '--all', '--report', 'findings', ...json], root) + expect(full.exitCode).toBe(1) + expect(findings.exitCode).toBe(1) + if (json.length > 0) { + const doc = parseOne('findings', findings.stdout) as Row + expect(rowsOf(doc, 'itemFindings').map((i) => i.id)).toEqual(['broken']) + } else { + expect(findings.stdout).not.toContain('clean') + } + } + }) + + unlessRoot('mode 000', () => { + test('7.8 an unreadable artifact is one meta/unreadable-artifact ERROR', async () => { + const root = cospecRoot() + writeChange(root, 'other', { 'proposal.md': PROPOSAL }, 'chore') + for (const file of ['proposal.md', 'tasks.md', 'specs/widgets/spec.md']) { + const id = `locked-${file.split('/').pop()!.replace('.md', '')}` + const dir = writeChange(root, id, { + 'proposal.md': PROPOSAL, + 'blocking-changes.md': BLOCKERS, + 'specs/widgets/spec.md': DELTA, + 'tasks.md': TASKS, + }) + const restore = lock(join(dir, file)) + try { + for (const argv of [ + ['validate', id, '--json'], + ['validate', '--all', '--json'], + ]) { + const cs = await oursJson(argv, root) + expect(cs.exitCode).toBe(1) + const item = rowsOf(cs.json, 'items').find((i) => i.id === id)! + const issues = item.issues as Row[] + expect(issues).toHaveLength(1) + expect(issues[0]).toMatchObject({ level: 'ERROR', rule: 'meta/unreadable-artifact' }) + expect(issues[0]!.message).toContain(file) + expect(issues[0]!.message).toContain('EACCES') + if (argv.includes('--all')) + expect(rowsOf(cs.json, 'items').map((i) => i.id)).toContain('other') + } + } finally { + restore() + } + } + }) + }) + + test("7.9 the binary's no-deltas tip is relayed spelled cospec", async () => { + const root = cospecRoot() + specDrivenChange(root, 'no-deltas') + const cs = await oursJson(['validate', 'no-deltas', '--json'], root) + const messages = rowsOf(cs.json, 'items').flatMap((i) => + (i.issues as Row[]).map((x) => String(x.message)), + ) + expect(messages.some((m) => m.includes('cospec show --json --deltas-only'))).toBe( + true, + ) + for (const m of messages) expect(m).not.toMatch(BARE_OPENSPEC) + }) +}) + +describe('8.3 apply under --json', () => { + test('8.3 cospec apply nope --json is one change_error document naming nope', async () => { + const root = listFixture() + const cs = await ours(['apply', 'nope', '--json'], root) + expect(cs.exitCode).toBe(1) + expect(cs.stderr).toBe('') + const doc = parseOne('cospec apply nope --json', cs.stdout) as Row + expect(Object.keys(doc)).toEqual(['status']) + const status = firstStatus(doc) + expect(status).toMatchObject({ severity: 'error', code: 'change_error' }) + expect(status.message).toContain("'nope'") + }) +}) + +// --- 8. every --json failure is one document -------------------------------------------- + +const RESOLVER_ROWS: { argv: string[]; code: string }[] = [ + { argv: ['list', '--json'], code: 'list_error' }, + { argv: ['list', '--specs', '--json'], code: 'list_error' }, + { argv: ['status', '--change', 'a', '--json'], code: 'change_error' }, + { argv: ['status', '--all', '--json'], code: 'change_error' }, + { argv: ['validate', '--all', '--json'], code: 'validate_error' }, +] + +/** Row 8.1 for one command: an unreadable store registry, the command's code and payload. */ +async function unreadableRegistry(row: { argv: string[]; code: string }): Promise { + const sb = await makeSandbox(['s1']) + const registry = join(sb.env['XDG_DATA_HOME']!, 'openspec', 'stores', 'registry.yaml') + const restore = lock(registry) + try { + const argv = [...row.argv, '--store', 's1'] + const up = await upstreamJson(argv, sb.dir) + const cs = await oursJson(argv, sb.dir) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + const want = firstStatus(up.json) + const got = firstStatus(cs.json) + expect({ argv, code: got.code }).toEqual({ argv, code: want.code }) + expect(got.code).toBe(row.code) + expect(errnoShape(got.message)).toEqual(errnoShape(want.message)) + const { status: _u, ...upRest } = up.json as Row + const { status: _c, ...csRest } = cs.json as Row + expect({ argv, payload: csRest }).toEqual({ argv, payload: upRest }) + } finally { + restore() + } +} + +/** Row 8.4 for one command: an unknown store, the binary's diagnostic in its payload. */ +async function unknownStore(row: { argv: string[] }): Promise { + const sb = await makeSandbox(['s1']) + const argv = [...row.argv, '--store', 'nope'] + const up = await upstream(argv, sb.dir) + const cs = await oursJson(argv, sb.dir) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + // The message is cospec's own documented wording (`concepts/stores.md`, + // root-resolution-parity); the code, target, fix and payload are the binary's. + const want = JSON.parse(respellRemedies(up.stdout)) as Row + const got = cs.json as Row + const { message: wantMessage, ...wantDiagnostic } = firstStatus(want) + const { message: gotMessage, ...gotDiagnostic } = firstStatus(got) + expect({ argv, diagnostic: gotDiagnostic }).toEqual({ argv, diagnostic: wantDiagnostic }) + expect(wantMessage).toContain("'nope'") + expect(gotMessage).toContain("'nope'") + expect(gotMessage).toContain('Registered stores: s1') + const { status: _w, ...wantPayload } = want + const { status: _g, ...gotPayload } = got + expect({ argv, payload: gotPayload }).toEqual({ argv, payload: wantPayload }) + expect(gotDiagnostic.code).toBe('unknown_store') +} + +const [LIST_ROW, SPECS_ROW, STATUS_ROW, SWEEP_ROW, VALIDATE_ROW] = RESOLVER_ROWS as [ + (typeof RESOLVER_ROWS)[number], + (typeof RESOLVER_ROWS)[number], + (typeof RESOLVER_ROWS)[number], + (typeof RESOLVER_ROWS)[number], + (typeof RESOLVER_ROWS)[number], +] + +// `list`'s rows come from the binary's `list --json`, which refuses a directory +// with no OpenSpec root: cospec answers with that refusal, as the binary does. +describe('list outside an OpenSpec root', () => { + test("list answers the binary's no-root refusal in text and --json", async () => { + const dir = mkTempRepo({ git: true }) + const env = emptyMachineStateEnv() + const upText = await upstream(['list'], dir) + const csText = await ours(['list'], dir, dir, env) + expect({ exit: csText.exitCode, out: csText.stdout }).toEqual({ + exit: upText.exitCode, + out: '', + }) + expect(upText.exitCode).toBe(1) + expect(csText.stderr).toBe( + respellRemedies(upText.stderr.replace(/^(?:✖ )?Error: /, 'cospec list: ')), + ) + const up = await upstreamJson(['list', '--json'], dir) + const cs = await oursJson(['list', '--json'], dir, dir, env) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + expect(firstStatus(cs.json).code).toBe('no_openspec_root') + }) +}) + +describe('8. resolver failures under --json', () => { + unlessRoot('8.1 an unreadable store registry carries the command code and payload', () => { + test('8.1 list --json', () => unreadableRegistry(LIST_ROW)) + test('8.1 list --specs --json', () => unreadableRegistry(SPECS_ROW)) + test('8.1 status --change a --json', () => unreadableRegistry(STATUS_ROW)) + test('8.1 status --all --json', () => unreadableRegistry(SWEEP_ROW)) + test('8.1 validate --all --json', () => unreadableRegistry(VALIDATE_ROW)) + }) + + describe("8.4 an unknown store carries the binary's diagnostic inside its payload", () => { + test('8.4 list --json', () => unknownStore(LIST_ROW)) + test('8.4 list --specs --json', () => unknownStore(SPECS_ROW)) + test('8.4 status --change a --json', () => unknownStore(STATUS_ROW)) + test('8.4 status --all --json', () => unknownStore(SWEEP_ROW)) + test('8.4 validate --all --json', () => unknownStore(VALIDATE_ROW)) + }) +}) + +// --- 9. completion serves schemas and archived changes ------------------------------------ + +describe('9. __complete sources', () => { + test('9.1 schemas and archived-changes complete as the binary lists them', async () => { + const root = cospecRoot() + projectFork(root) + mkdirSync(join(root, 'openspec/changes/archive/2026-01-01-one'), { recursive: true }) + mkdirSync(join(root, 'openspec/changes/archive/2026-01-02-two'), { recursive: true }) + const ids = (stdout: string) => + stdout + .split('\n') + .filter(Boolean) + .map((l) => l.split('\t')[0]) + for (const [source, binarySource] of [ + ['schemas', 'schemas'], + ['archived-changes', 'archived-changes'], + ['SCHEMAS', 'schemas'], + ] as const) { + const up = await upstream(['__complete', binarySource], root) + const cs = await ours(['__complete', source], root) + expect({ source, exit: cs.exitCode, stderr: cs.stderr }).toEqual({ + source, + exit: 0, + stderr: '', + }) + expect(ids(cs.stdout)).toEqual(ids(up.stdout)) + for (const line of cs.stdout.split('\n').filter(Boolean)) + expect(line).toMatch(/^[^\t]+\t[^\t]+$/) + } + const outside = mkTempRepo() + for (const source of ['schemas', 'archived-changes']) { + const cs = await ours(['__complete', source], outside, outside, emptyMachineStateEnv()) + expect({ source, exit: cs.exitCode, out: cs.stdout, err: cs.stderr }).toEqual({ + source, + exit: 1, + out: '', + err: '', + }) + } + }) +}) + +// --- 10. schema classification reads the binary's user directory ----------------------------- + +describe('10. user schema directory', () => { + test('10.2 a user-dir schema is legacy; one under ~/.config is unknown', async () => { + const root = cospecRoot() + const env = oracleEnv(root) + const userDir = join(env['XDG_DATA_HOME']!, 'openspec', 'schemas', 'house-style') + cpSync(join(openspecPackageDir(), 'schemas/spec-driven'), userDir, { recursive: true }) + const configDir = join(env['HOME']!, '.config', 'openspec', 'schemas', 'config-style') + cpSync(join(openspecPackageDir(), 'schemas/spec-driven'), configDir, { recursive: true }) + writeChange(root, 'user-one', { 'proposal.md': PROPOSAL }, 'house-style') + writeChange(root, 'config-one', { 'proposal.md': PROPOSAL }, 'config-style') + + const up = await upstreamJson(['validate', 'user-one', '--json'], root) + const cs = await oursJson(['validate', 'user-one', '--json'], root) + const rules = (doc: unknown) => + rowsOf(doc, 'items').flatMap((i) => (i.issues as Row[]).map((x) => x.rule)) + expect(rules(cs.json)).not.toContain('meta/schema-unknown') + const upMessages = rowsOf(up.json, 'items').flatMap((i) => + (i.issues as Row[]).map((x) => respellRemedies(String(x.message))), + ) + const relayed = rowsOf(cs.json, 'items').flatMap((i) => + (i.issues as Row[]) + .filter((x) => x.rule === 'openspec/validate') + .map((x) => respellRemedies(String(x.message))), + ) + // Compared through the remedy allowlist on both sides: this row is about + // where the schema resolves; the relay spelling is row 7.9's. + expect(relayed).toEqual(upMessages) + + const config = await oursJson(['validate', 'config-one', '--json'], root) + expect(rules(config.json)).toContain('meta/schema-unknown') + const upConfig = await upstream(['status', '--change', 'config-one', '--json'], root) + expect(upConfig.exitCode).toBe(1) + }) +}) + +// --- 15. round-2 review rows ----------------------------------------------------------- + +/** A project schema `rfc` whose artifacts are `doc.md` and `notes.md` (notes needs doc). */ +function rfcSchema(root: string): void { + writeFiles(root, { + '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: []', + ' - id: notes', + ' generates: notes.md', + ' description: Review notes', + ' template: notes.md', + ' instruction: Write the notes.', + ' requires:', + ' - doc', + 'apply:', + ' requires: [doc]', + ' tracks: null', + '', + ].join('\n'), + 'openspec/schemas/rfc/templates/doc.md': '# Doc\n', + 'openspec/schemas/rfc/templates/notes.md': '# Notes\n', + }) +} + +/** The document without its `warnings` key. */ +function withoutWarnings(doc: unknown): unknown { + const { warnings: _w, ...rest } = doc as Row + return rest +} + +/** + * `item` keeps the issues it carries with `locked` readable (`own`). Where the + * binary refuses it over the unreadable `locked` (Bun's `realpath` on macOS: + * its item discovery resolves every living spec), that refusal is its one + * added ERROR, naming the file; elsewhere nothing is added. + */ +function expectOwnAnswer(label: unknown, item: Row, own: readonly Row[], locked: string): void { + const issues = item.issues as Row[] + for (const issue of own) + expect({ label, issues }).toEqual({ label, issues: expect.arrayContaining([issue]) }) + const added = issues.filter((i) => !own.some((o) => JSON.stringify(o) === JSON.stringify(i))) + if (!realpathRefuses(locked)) expect({ label, added }).toEqual({ label, added: [] }) + for (const issue of added) { + expect(issue).toMatchObject({ level: 'ERROR', rule: 'openspec/validate' }) + expect(String(issue.message)).toContain(basename(dirname(locked))) + expect(String(issue.message)).toContain('EACCES') + } +} + +describe('15. round-2 review rows', () => { + test('15.1 --type spec on a spec discovery skips validates the file, as the binary does', async () => { + const root = cospecRoot() + writeFiles(root, { + 'openspec/specs/.hidden/spec.md': '# hidden\n', + 'openspec/specs/real/spec.md': LIVING('real'), + }) + const outside = mkTempRepo() + writeFiles(outside, { 'cap/spec.md': '# linked\n' }) + symlinkSync(join(outside, 'cap'), join(root, 'openspec/specs/linked')) + for (const id of ['.hidden', 'linked']) { + const up = await upstreamJson(['validate', id, '--type', 'spec', '--json'], root) + const cs = await oursJson(['validate', id, '--type', 'spec', '--json'], root) + expect({ id, exit: cs.exitCode }).toEqual({ id, exit: up.exitCode }) + expect(up.exitCode).toBe(1) + const upItems = rowsOf(up.json, 'items') + const csItems = rowsOf(cs.json, 'items') + expect(csItems.map((i) => [i.id, i.valid])).toEqual(upItems.map((i) => [i.id, i.valid])) + const messages = (items: Row[]) => + items.flatMap((i) => (i.issues as Row[]).map((x) => String(x.message))) + for (const m of messages(upItems)) expect(messages(csItems)).toContain(m) + const text = await ours(['validate', id, '--type', 'spec'], root) + expect({ id, exit: text.exitCode }).toEqual({ id, exit: up.exitCode }) + } + }) + + test('15.2 a hand-made change whose schema output a brace glob matches is a change', async () => { + const root = cospecRoot('braced') + writeFiles(root, { + 'openspec/schemas/braced/schema.yaml': [ + 'name: braced', + 'version: 1', + 'description: Outputs under rfc/', + 'artifacts:', + ' - id: proposal', + " generates: 'rfc/{proposal,design}*.md'", + ' description: The proposal', + ' template: t.md', + ' instruction: Write it.', + ' requires: []', + '', + ].join('\n'), + 'openspec/schemas/braced/templates/t.md': '# t\n', + 'openspec/changes/rfc-change/rfc/proposal.md': PROPOSAL, + }) + const up = await upstreamJson(['list', '--json'], root) + const cs = await oursJson(['list', '--json'], root) + const upRow = rowsOf(up.json).find((r) => r.name === 'rfc-change')! + const row = rowsOf(cs.json).find((r) => r.change === 'rfc-change')! + expect(upRow.nested).toBeUndefined() + expect(row.state).not.toBe('not-a-change') + const validated = await oursJson(['validate', 'rfc-change', '--json'], root) + const rules = rowsOf(validated.json, 'items').flatMap((i) => + (i.issues as Row[]).map((x) => x.rule), + ) + expect(rules).not.toContain('meta/nested-change') + }) + + test("15.4 a custom schema's artifacts decide its status, singly and in the sweep", async () => { + const root = cospecRoot() + rfcSchema(root) + writeChange(root, 'r-empty', {}, 'rfc') + writeChange(root, 'r-doc', { 'doc.md': '# RFC\n' }, 'rfc') + // `apply.requires: [doc]`: once `doc` is written the optional `notes` + // never holds r-doc back from its gate (D4), while the binary's own + // `nextSteps` still names `notes`. + const expected: Record = { + 'r-empty': { next: 'cospec instructions doc --change r-empty', upstreamNext: 'doc' }, + 'r-doc': { next: 'cospec apply r-doc', upstreamNext: 'notes' }, + } + for (const [id, { next, upstreamNext }] of Object.entries(expected)) { + const upText = await upstream(['status', '--change', id], root) + const csText = await ours(['status', '--change', id], root) + captureStatus(`15.4 ${id} text`, csText) + expect({ id, exit: csText.exitCode }).toEqual({ id, exit: upText.exitCode }) + const u = statusText(upText.stdout) + expect(nextArtifact(u.next)).toBe(upstreamNext) + expect(statusText(csText.stdout).body).toEqual(u.body) + expect(statusText(csText.stdout).next).toBe(`Next: ${next}`) + const up = await upstreamJson(['status', '--change', id, '--json'], root) + const cs = await oursJson(['status', '--change', id, '--json'], root) + captureStatus(`15.4 ${id} json`, cs) + expect({ id, exit: cs.exitCode }).toEqual({ id, exit: up.exitCode }) + expect((cs.json as Row).next).toBe(next) + expect((cs.json as Row).nextSteps).toEqual( + ((up.json as Row).nextSteps as string[]).map(respellWholeRemedy), + ) + expect(JSON.stringify((cs.json as Row).nextSteps)).toContain( + `cospec instructions ${upstreamNext}`, + ) + expectOracle(up.json, cs.json, STATUS_SPEC) + } + const upAll = await upstreamJson(['status', '--all', '--json'], root) + const csAll = await oursJson(['status', '--all', '--json'], root) + captureStatus('15.4 sweep json', csAll) + expect(csAll.exitCode).toBe(upAll.exitCode) + expectOracle(upAll.json, csAll.json, STATUS_ALL_SPEC) + const entry = (id: string) => rowsOf(csAll.json).find((e) => e.change === id)! + for (const [id, { next }] of Object.entries(expected)) expect(entry(id).next).toBe(next) + // Nothing in the sweep names `proposal`, which the rfc schema has none of. + for (const id of Object.keys(expected)) + expect({ id, steps: [entry(id).next, ...(entry(id).nextSteps as string[])] }).toEqual({ + id, + steps: expect.not.arrayContaining([expect.stringContaining('proposal')]), + }) + const upSweep = await upstream(['status', '--all'], root) + const sweep = await ours(['status', '--all'], root) + captureStatus('15.4 sweep text', sweep) + expect(sweep.exitCode).toBe(upSweep.exitCode) + for (const id of ['r-empty', 'r-doc']) { + const upText = await upstream(['status', '--change', id], root) + for (const line of statusText(upText.stdout).body.filter((l) => l.length > 0)) + expect(sweep.stdout).toContain(line) + } + expect(sweep.stdout).not.toContain('cospec instructions proposal') + }) + + unlessRoot('mode 000', () => { + function lockedArchive(): { root: string; restore: () => void } { + const root = cospecRoot() + // A change both cospec's rules and the binary pass, so `validate --all` + // exits as the binary does and `apply` reaches a clear gate. + buildValidFeat(root, 'ready') + writeFiles(root, { 'openspec/changes/archive/2026-01-01-old/proposal.md': PROPOSAL }) + return { root, restore: lock(join(root, 'openspec/changes/archive')) } + } + + test("15.5 validate --archived relays the binary's failure document", async () => { + const { root, restore } = lockedArchive() + try { + const up = await upstreamJson(['validate', '--archived', '--json'], root) + const cs = await oursJson(['validate', '--archived', '--json'], root) + expect(up.exitCode).toBe(1) + expect(cs.exitCode).toBe(up.exitCode) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const upText = await upstream(['validate', '--archived'], root) + const text = await ours(['validate', '--archived'], root) + expect(text.exitCode).toBe(upText.exitCode) + expect(text.stderr).toBe(`cospec: ${respellRemedies(firstStatus(up.json).message)}\n`) + expect(text.stderr).not.toContain('1.9.0') + // Row 7.9's relay half: no relayed line names a bare `openspec` command. + for (const relayed of [cs.stdout, text.stderr]) + expect(relayed).not.toMatch(/(^|[\s`'"])openspec\s/m) + } finally { + restore() + } + }) + + test('15.6 an unreadable archive leaves validate and apply answering with a warning', async () => { + const { root, restore } = lockedArchive() + const locked: { argv: string[]; run: JsonAnswer; text: SpawnResult }[] = [] + const argvs = [ + ['validate', 'ready', '--json'], + ['validate', '--all', '--json'], + ['apply', 'ready', '--json'], + ] + try { + for (const argv of argvs) { + const run = await oursJson(argv, root) + const text = await ours( + argv.filter((a) => a !== '--json'), + root, + ) + locked.push({ argv, run, text }) + } + const up = await upstreamJson(['validate', '--all', '--json'], root) + expect(locked[1]!.run.exitCode).toBe(up.exitCode) + } finally { + restore() + } + // Each answer is a passing one: validate passes, apply reaches a clear gate. + expect(locked.map(({ argv, run }) => [argv.join(' '), run.exitCode])).toEqual( + argvs.map((argv) => [argv.join(' '), 0]), + ) + expect((locked[2]!.run.json as Row).gate).toMatchObject({ state: 'clear' }) + for (const { argv, run, text } of locked) { + const warnings = ((run.json as Row).warnings ?? []) as Row[] + expect({ argv, codes: warnings.map((w) => w.code) }).toEqual({ + argv, + codes: ['archive_unreadable'], + }) + expect(String(warnings[0]!.message)).toContain('openspec/changes/archive') + expect(text.stderr).toContain('Warning: could not read') + expect(text.stderr).toContain('openspec/changes/archive') + } + // Each text form exits as its --json form does. + for (const { argv, run, text } of locked) + expect({ argv, text: text.exitCode }).toEqual({ argv, text: run.exitCode }) + // With the archive readable again the answers are the same, bar the warning. + for (const { argv, run, text } of locked) { + const textArgv = argv.filter((a) => a !== '--json') + const textAgain = await ours(textArgv, root) + expect({ textArgv, exit: text.exitCode, stdout: text.stdout }).toEqual({ + textArgv, + exit: textAgain.exitCode, + stdout: textAgain.stdout, + }) + expect(textAgain.stderr).not.toContain('Warning: could not read') + const again = await oursJson(argv, root) + expect({ argv, exit: run.exitCode }).toEqual({ argv, exit: again.exitCode }) + const scrub = (doc: unknown) => + JSON.parse( + JSON.stringify(withoutWarnings(doc)).replace(/"durationMs": ?\d+/g, '"durationMs":0'), + ) + expect(scrub(run.json)).toEqual(scrub(again.json)) + } + }) + + test("15.8 list --specs relays the binary's failure document and fix", async () => { + const root = cospecRoot() + writeFiles(root, { 'openspec/specs/locked/spec.md': LIVING('locked') }) + const restore = lock(join(root, 'openspec/specs/locked')) + try { + const up = await upstreamJson(['list', '--specs', '--json'], root) + const cs = await oursJson(['list', '--specs', '--json'], root) + expect(up.exitCode).toBe(1) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const text = await ours(['list', '--specs'], root) + expect(text.exitCode).toBe(1) + const d = firstStatus(up.json) + expect(text.stderr).toBe( + `cospec: ${respellRemedies(d.message)}\n${d.fix === undefined ? '' : `Fix: ${respellRemedies(d.fix)}\n`}`, + ) + } finally { + restore() + } + }) + + test('15.9 an unreadable living spec is one meta/unreadable-artifact ERROR', async () => { + const root = cospecRoot() + writeFiles(root, { + 'openspec/specs/foo/spec.md': LIVING('foo'), + 'openspec/specs/bar/spec.md': LIVING('bar'), + }) + const alone = await oursJson(['validate', 'bar', '--json'], root) + const barAlone = rowsOf(alone.json, 'items').find((i) => i.id === 'bar')! + const restore = lock(join(root, 'openspec/specs/foo/spec.md')) + try { + for (const argv of [ + ['validate', 'foo', '--json'], + ['validate', 'foo', '--type', 'spec', '--json'], + ['validate', '--specs', '--json'], + ['validate', '--all', '--json'], + ]) { + const up = await upstream(argv, root) + const cs = await oursJson(argv, root) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + expect(cs.exitCode).toBe(1) + const items = rowsOf(cs.json, 'items') + const foo = items.find((i) => i.id === 'foo')! + const issues = foo.issues as Row[] + expect(issues).toHaveLength(1) + expect(issues[0]).toMatchObject({ level: 'ERROR', rule: 'meta/unreadable-artifact' }) + expect(String(issues[0]!.message)).toContain('specs/foo/spec.md') + expect(String(issues[0]!.message)).toContain('EACCES') + if (argv.includes('foo')) continue + const bar = items.find((i) => i.id === 'bar')! + const locked = join(root, 'openspec/specs/foo/spec.md') + expectOwnAnswer(argv, bar, barAlone.issues as Row[], locked) + if (!realpathRefuses(locked)) expect(bar.valid).toBe(barAlone.valid) + } + const text = await ours(['validate', 'foo'], root) + expect(text.exitCode).toBe(1) + expect(text.stdout).toContain('meta/unreadable-artifact') + } finally { + restore() + } + }) + + /** The list fixture with `beta`'s `tasks.md` at mode 000. */ + function lockedTasks(): { root: string; tasks: string; restore: () => void } { + const root = listFixture() + const tasks = join(root, 'openspec/changes/beta/tasks.md') + return { root, tasks, restore: lock(tasks) } + } + + /** cospec's `tasks_unreadable` warnings in `doc`, each naming `tasks` and its errno code. */ + function expectTasksWarning(doc: unknown, tasks: string): void { + const warnings = (((doc as Row).warnings ?? []) as Row[]).filter( + (w) => w.code === 'tasks_unreadable', + ) + expect(warnings).toHaveLength(1) + expect(String(warnings[0]!.message)).toContain(tasks) + expect(String(warnings[0]!.message)).toContain('EACCES') + } + + test('15.11 an unreadable tasks.md: list and status --change answer as the binary does', async () => { + const { root, tasks, restore } = lockedTasks() + try { + const refused = realpathRefuses(tasks) + for (const argv of [ + ['list', '--json'], + ['status', '--change', 'beta', '--json'], + ]) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + captureStatus(`15.11 ${argv[0]}`, cs) + expect({ argv, exit: up.exitCode }).toEqual({ argv, exit: refused ? 1 : 0 }) + 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) + expect({ textArgv, exit: text.exitCode }).toEqual({ textArgv, exit: upText.exitCode }) + if (refused) { + // The binary's refusal, relayed whole: its document, its message. + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const d = firstStatus(up.json) + // The path the binary resolved: the change directory's own realpath. + const resolved = join(realpathSync(dirname(tasks)), 'tasks.md') + expect(errnoShape(d.message)).toMatchObject({ code: 'EACCES', path: resolved }) + expect(text.stderr).toBe(`cospec ${argv[0]}: ${respellRemedies(d.message)}\n`) + continue + } + // The binary counts the file as no tasks; so does cospec, and says why. + expectOracle(up.json, cs.json, argv[0] === 'list' ? LIST_SPEC : STATUS_SPEC) + expectTasksWarning(cs.json, tasks) + expect(text.stderr).toContain(`Warning: could not read ${tasks} (EACCES)`) + if (argv[0] === 'list') { + const beta = rowsOf(cs.json).find((r) => r.change === 'beta')! + expect(beta.error).toBeUndefined() + expect(beta.tasks).toEqual({ total: 0, complete: 0 }) + expect(text.stdout).toMatch(/^ {2}beta +fix +clear +0\/0 tasks$/m) + } else { + expect((cs.json as Row).tasks).toEqual({ total: 0, complete: 0 }) + expect(text.stdout).toContain(' tasks: 0/0\n') + } + } + } finally { + restore() + } + }) + + test('15.12 an unreadable tasks.md: status --all answers its change as the binary does', async () => { + const { root, tasks, restore } = lockedTasks() + try { + const refused = realpathRefuses(tasks) + const up = await upstreamJson(['status', '--all', '--json'], root) + const cs = await oursJson(['status', '--all', '--json'], root) + captureStatus('15.12 json', cs) + const upText = await upstream(['status', '--all'], root) + const text = await ours(['status', '--all'], root) + captureStatus('15.12 text', text) + expect(cs.exitCode).toBe(up.exitCode) + expect(text.exitCode).toBe(upText.exitCode) + const upBeta = rowsOf(up.json).find((e) => e.changeName === 'beta')! + const beta = rowsOf(cs.json).find((e) => e.change === 'beta')! + expect(Array.isArray(upBeta.status)).toBe(refused) + if (refused) { + // The binary could not report beta: its failure is beta's entry, and the sweep fails. + const messages = (upBeta.status as Diagnostic[]).map((d) => d.message) + expect(up.exitCode).toBe(1) + expect(beta.error).toBe(messages.join('\n')) + expect(beta.status).toEqual(upBeta.status) + expect(text.stdout).toContain(`beta: ERROR — ${messages.join('\n')}\n`) + return + } + // beta is reported; the sweep's exit code is the namespace folder's. + expectOracle(up.json, cs.json, STATUS_ALL_SPEC) + expect(beta.error).toBeUndefined() + expect(beta.tasks).toEqual({ total: 0, complete: 0 }) + expectTasksWarning(cs.json, tasks) + expect(text.stderr).toContain(`Warning: could not read ${tasks} (EACCES)`) + expect(text.stdout).toContain(' tasks: 0/0\n') + } finally { + restore() + } + }) + }) + + test("15.7 validate --json outside a root is the binary's one no_openspec_root document", async () => { + const dir = mkTempRepo({ git: true }) + const env = emptyMachineStateEnv() + const scoped: unknown[] = [] + for (const scope of ['--all', '--changes', '--specs']) { + const up = await upstreamJson(['validate', scope, '--json'], dir) + const cs = await oursJson(['validate', scope, '--json'], dir, dir, env) + expect({ scope, exit: cs.exitCode }).toEqual({ scope, exit: up.exitCode }) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + expect(firstStatus(cs.json)).toEqual({ + severity: 'error', + code: 'no_openspec_root', + message: 'No OpenSpec root found from the current directory.', + target: 'openspec.root', + fix: 'Run cospec init to create a root here.', + }) + scoped.push(cs.json) + } + // Bare `validate --json` answers the same document the scoped forms do. + const bare = await oursJson(['validate', '--json'], dir, dir, env) + expect(bare.exitCode).toBe(1) + for (const doc of scoped) expect(bare.json).toEqual(doc) + }) +}) + +// --- 16. round-3 review rows ----------------------------------------------------------- + +/** `capability`'s MODIFIED delta, restating the living spec's one requirement. */ +const MODIFIED = (capability: string): string => `## MODIFIED Requirements + +### Requirement: ${capability} works + +The system SHALL make ${capability} work, restated. + +#### Scenario: It works + +- **WHEN** a caller uses ${capability} +- **THEN** it works +` + +/** A living spec with no `## Purpose` section: the binary's ERROR, no cospec rule. */ +const NO_PURPOSE = (name: string): string => `# ${name} Specification + +## Requirements + +### Requirement: ${name} works + +The system SHALL make ${name} work. + +#### Scenario: It works + +- **WHEN** a caller uses ${name} +- **THEN** it works +` + +/** A living spec carrying only the binary's WARNINGs: a brief purpose, no SHALL/MUST. */ +const WARNING_ONLY = (name: string): string => `# ${name} Specification + +## Purpose + +Short. + +## Requirements + +### Requirement: ${name} works + +The system makes ${name} work. + +#### Scenario: It works + +- **WHEN** a caller uses ${name} +- **THEN** it works +` + +/** Every issue message of every item in a validate document, by item id. */ +function messagesById(doc: unknown): Map { + return new Map( + rowsOf(doc, 'items').map((i) => [ + String(i.id), + (i.issues as Row[]).map((x) => String(x.message)), + ]), + ) +} + +/** A validate document with every `durationMs` zeroed. */ +function untimed(doc: unknown): unknown { + return JSON.parse(JSON.stringify(doc).replace(/"durationMs": ?\d+/g, '"durationMs":0')) +} + +describe('16. round-3 review rows', () => { + test("16.1 a change sharing a living spec's name is validated as one, in both lanes", async () => { + const root = cospecRoot() + writeFiles(root, { 'openspec/specs/foo/spec.md': LIVING('foo') }) + // `foo` on the binary's own schema with no deltas: the binary's ERROR. + specDrivenChange(root, 'foo') + // `bar` a `feat` change with a delta, so cospec delegates for it too. + writeChange(root, 'bar', { 'proposal.md': PROPOSAL, 'specs/widgets/spec.md': DELTA }) + const barAlone = await oursJson(['validate', 'bar', '--type', 'change', '--json'], root) + writeFiles(root, { 'openspec/specs/bar/spec.md': LIVING('bar') }) + for (const argv of [ + ['validate', 'foo', '--type', 'change', '--json'], + ['validate', '--changes', '--json'], + ]) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + expect({ argv, exit: up.exitCode }).toEqual({ argv, exit: 1 }) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + const theirs = messagesById(up.json).get('foo') ?? [] + expect(theirs.length).toBeGreaterThan(0) + expect({ argv, foo: messagesById(cs.json).get('foo') }).toEqual({ + argv, + foo: expect.arrayContaining(theirs.map(respellRemedies)), + }) + expect(rowsOf(cs.json, 'items').find((i) => i.id === 'foo')!.valid).toBe(false) + } + // The cospec lane: the living spec of the same name changes nothing. + const bar = await oursJson(['validate', 'bar', '--type', 'change', '--json'], root) + expect(bar.exitCode).toBe(barAlone.exitCode) + expect(untimed(bar.json)).toEqual(untimed(barAlone.json)) + expect(JSON.stringify(bar.json)).not.toContain('Ambiguous') + }) + + test('16.4 --strict fails a warning-only spec as the binary does, in valid and the totals', async () => { + const root = cospecRoot() + writeFiles(root, { 'openspec/specs/baz/spec.md': WARNING_ONLY('baz') }) + for (const argv of [ + ['validate', '--specs', '--strict', '--json'], + ['validate', 'baz', '--strict', '--json'], + ]) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + const verdicts = (doc: unknown) => rowsOf(doc, 'items').map((i) => [i.id, i.valid]) + expect({ argv, valid: verdicts(cs.json) }).toEqual({ argv, valid: verdicts(up.json) }) + expect(verdicts(up.json)).toEqual([['baz', false]]) + const summary = (doc: unknown) => (doc as { summary: Row }).summary + expect({ argv, totals: summary(cs.json).totals }).toEqual({ + argv, + totals: summary(up.json).totals, + }) + expect({ argv, byType: summary(cs.json).byType }).toEqual({ + argv, + byType: summary(up.json).byType, + }) + } + }) + + test('16.5 validate outside any root is an unknown item, as the binary answers', async () => { + const dir = mkTempRepo({ git: true }) + const env = emptyMachineStateEnv() + const up = await upstreamJson(['validate', 'foo', '--json'], dir) + const cs = await oursJson(['validate', 'foo', '--json'], dir, dir, env) + expect(up.exitCode).toBe(1) + expect(cs.exitCode).toBe(up.exitCode) + expect(cs.json).toEqual(up.json) + expect(firstStatus(cs.json).code).toBe('unknown_item') + const upText = await upstream(['validate', 'foo'], dir) + const text = await ours(['validate', 'foo'], dir, dir, env) + expect(text.exitCode).toBe(upText.exitCode) + expect(text.stderr).toBe(`cospec: ${upText.stderr.split('\n')[0]}\n`) + // The bulk scopes keep the binary's no-root refusal (row 15.7). + const bulk = await oursJson(['validate', 'foo', '--all', '--json'], dir, dir, env) + expect(firstStatus(bulk.json).code).toBe('no_openspec_root') + }) + + test('16.6 an empty cospec-typed change keeps artifacts: [] in --change and --all', async () => { + const root = cospecRoot() + writeChange(root, 'e1', {}, 'fix') + const up = await upstreamJson(['status', '--change', 'e1', '--json'], root) + const cs = await oursJson(['status', '--change', 'e1', '--json'], root) + captureStatus('16.6 json', cs) + expect(cs.exitCode).toBe(up.exitCode) + expect(((up.json as Row).artifacts as Row[]).length).toBeGreaterThan(0) + expect((cs.json as Row).artifacts).toEqual([]) + expect((cs.json as Row).state).toBe('in-progress') + expectOracle(up.json, cs.json, { ...STATUS_SPEC, kept: ['artifacts'] }) + const all = await oursJson(['status', '--all', '--json'], root) + captureStatus('16.6 sweep', all) + const entry = rowsOf(all.json).find((e) => e.change === 'e1')! + expect(entry.artifacts).toEqual([]) + expect(entry.changeName).toBe('e1') + }) + + test('16.7 a regular file under changes/ is no change, as the binary refuses it', async () => { + const root = cospecRoot() + writeFiles(root, { 'openspec/changes/todo': 'not a change\n' }) + const upText = await upstream(['status', '--change', 'todo'], root) + const text = await ours(['status', '--change', 'todo'], root) + captureStatus('16.7 text', text) + expect(upText.exitCode).toBe(1) + expect(text.exitCode).toBe(upText.exitCode) + expect(text.stdout).toBe('') + const up = await upstreamJson(['status', '--change', 'todo', '--json'], root) + const cs = await oursJson(['status', '--change', 'todo', '--json'], root) + captureStatus('16.7 json', cs) + expect(cs.exitCode).toBe(up.exitCode) + expect(Object.keys(cs.json as Row)).toEqual(['status']) + expect(firstStatus(cs.json).code).toBe('change_error') + const apply = await oursJson(['apply', 'todo', '--json'], root) + expect(apply.exitCode).toBe(1) + expect(firstStatus(apply.json)).toMatchObject({ + code: 'change_error', + message: "unknown change 'todo'", + }) + }) + + test('16.8 a non-kebab change directory is looked up as the binary looks it up', async () => { + const root = cospecRoot() + writeChange(root, 'Add_Auth', { 'proposal.md': PROPOSAL }) + const upText = await upstream(['status', '--change', 'Add_Auth'], root) + const text = await ours(['status', '--change', 'Add_Auth'], root) + captureStatus('16.8 text', text) + expect(upText.exitCode).toBe(0) + expect(text.exitCode).toBe(upText.exitCode) + expect(text.stderr).not.toContain('Did you mean') + const up = await upstreamJson(['status', '--change', 'Add_Auth', '--json'], root) + const cs = await oursJson(['status', '--change', 'Add_Auth', '--json'], root) + captureStatus('16.8 json', cs) + expect(cs.exitCode).toBe(up.exitCode) + expect((cs.json as Row).change).toBe('Add_Auth') + expectOracle(up.json, cs.json, STATUS_SPEC) + // What the binary refuses as a lookup name stays refused, and is never suggested back. + writeFiles(root, { 'openspec/changes/.hidden/.openspec.yaml': 'schema: feat\n' }) + for (const id of ['archive', '.hidden']) { + const refused = await ours(['status', '--change', id], root) + expect({ id, exit: refused.exitCode }).toEqual({ id, exit: 1 }) + expect(refused.stderr).not.toContain(`Did you mean '${id}'?`) + } + }) + + unlessRoot('mode 000', () => { + test("16.2 validate alone is answered whatever a sibling spec's mode", async () => { + const root = cospecRoot() + writeFiles(root, { + 'openspec/specs/foo/spec.md': LIVING('foo'), + 'openspec/specs/bar/spec.md': NO_PURPOSE('bar'), + }) + const readable = await oursJson(['validate', 'bar', '--json'], root) + expect(readable.exitCode).toBe(1) + const restore = lock(join(root, 'openspec/specs/foo/spec.md')) + try { + const up = await upstream(['validate', 'bar', '--json'], root) + const cs = await oursJson(['validate', 'bar', '--json'], root) + expect(up.exitCode).toBe(1) + expect(cs.exitCode).toBe(up.exitCode) + const item = rowsOf(cs.json, 'items')[0]! + expect([item.id, item.valid]).toEqual(['bar', false]) + if (!realpathRefuses(join(root, 'openspec/specs/foo/spec.md'))) { + // The binary answers for bar alone: the same answer as with foo readable. + expect(untimed(cs.json)).toEqual(untimed(readable.json)) + return + } + // The binary refuses bar over foo (Bun's `realpath` on macOS): its + // refusal is bar's ERROR, never an empty pass. + const d = firstStatus(JSON.parse(up.stdout)) + expect(item.issues).toContainEqual( + expect.objectContaining({ + level: 'ERROR', + rule: 'openspec/validate', + message: respellRemedies(d.message), + }), + ) + } finally { + restore() + } + }) + + test('16.3 an unreadable living spec a delta targets fails that change, never the command', async () => { + const root = cospecRoot() + buildValidFeat(root, 'ready') + writeChange(root, 'c1', { + 'proposal.md': PROPOSAL, + 'specs/gadgets/spec.md': MODIFIED('gadgets'), + }) + writeFiles(root, { 'openspec/specs/gadgets/spec.md': LIVING('gadgets') }) + const alone = await oursJson(['validate', 'ready', '--json'], root) + const restore = lock(join(root, 'openspec/specs/gadgets/spec.md')) + try { + for (const argv of [ + ['validate', 'c1', '--json'], + ['validate', '--all', '--json'], + ['validate', '--changes', '--json'], + ['apply', 'c1', '--json'], + ]) { + const cs = await oursJson(argv, root) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: 1 }) + if (argv[0] === 'validate') { + const up = await upstream(argv, root) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + } + const c1 = rowsOf(cs.json, 'items').find((i) => i.id === 'c1')! + expect(c1.valid).toBe(false) + expect(c1.issues).toEqual([ + expect.objectContaining({ + level: 'ERROR', + rule: 'meta/unreadable-artifact', + path: 'specs/gadgets/spec.md', + }), + ]) + const message = String((c1.issues as Row[])[0]!.message) + expect(message).toContain('openspec/specs/gadgets/spec.md') + expect(message).toContain('EACCES') + // Every other change keeps its own answer. Where the binary refuses + // it over the unreadable spec (Bun's `realpath` on macOS), that + // refusal is its one added ERROR. + const ready = rowsOf(cs.json, 'items').find((i) => i.id === 'ready') + if (ready !== undefined) { + const own = rowsOf(alone.json, 'items')[0]!.issues as Row[] + expectOwnAnswer(argv, ready, own, join(root, 'openspec/specs/gadgets/spec.md')) + } + } + const text = await ours(['validate', 'c1'], root) + expect(text.exitCode).toBe(1) + expect(text.stdout).toContain('meta/unreadable-artifact') + } finally { + restore() + } + }) + + test('16.9 a change the binary refuses is refused by status, in both modes and the sweep', async () => { + const root = cospecRoot() + writeChange(root, 'demo', { 'proposal.md': PROPOSAL }) + writeChange(root, 'other', { 'proposal.md': PROPOSAL }) + const dir = join(root, 'openspec/changes/demo') + const proposal = join(dir, 'proposal.md') + for (const locked of [dir, proposal]) { + const restore = lock(locked) + try { + const up = await upstreamJson(['status', '--change', 'demo', '--json'], root) + const cs = await oursJson(['status', '--change', 'demo', '--json'], root) + captureStatus(`16.9 ${locked} json`, cs) + const upText = await upstream(['status', '--change', 'demo'], root) + const text = await ours(['status', '--change', 'demo'], root) + captureStatus(`16.9 ${locked} text`, text) + expect({ locked, exit: cs.exitCode }).toEqual({ locked, exit: up.exitCode }) + expect({ locked, exit: text.exitCode }).toEqual({ locked, exit: upText.exitCode }) + const upAll = await upstreamJson(['status', '--all', '--json'], root) + const all = await oursJson(['status', '--all', '--json'], root) + captureStatus(`16.9 ${locked} sweep`, all) + expect({ locked, exit: all.exitCode }).toEqual({ locked, exit: upAll.exitCode }) + const sweepText = await ours(['status', '--all'], root) + const upSweepText = await upstream(['status', '--all'], root) + expect({ locked, exit: sweepText.exitCode }).toEqual({ + locked, + exit: upSweepText.exitCode, + }) + const refused = up.exitCode === 1 + // The directory refuses on every OS; the file where the runtime's `realpath` does. + if (locked === dir || realpathRefuses(proposal)) expect(refused).toBe(true) + const demo = rowsOf(all.json).find((e) => e.change === 'demo')! + if (!refused) { + expect(demo.error).toBeUndefined() + continue + } + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const d = firstStatus(up.json) + expect(text.stderr).toBe(`cospec status: ${respellRemedies(d.message)}\n`) + expect(text.stdout).toBe('') + expect(demo.error).toBe(respellRemedies(d.message)) + expect(sweepText.stdout).toContain(`demo: ERROR — ${respellRemedies(d.message)}\n`) + expect(rowsOf(all.json).find((e) => e.change === 'other')!.error).toBeUndefined() + } finally { + restore() + } + } + }) + + test('16.10 an unreadable planning directory is one --json document per command', async () => { + const root = cospecRoot() + writeChange(root, 'demo', { 'proposal.md': PROPOSAL }) + writeFiles(root, { 'openspec/specs/auth/spec.md': LIVING('auth') }) + const cases: { locked: string; argvs: string[][] }[] = [ + { + locked: 'openspec/changes', + argvs: [ + ['validate', '--all', '--json'], + ['status', '--change', 'demo', '--json'], + ['status', '--all', '--json'], + ], + }, + { + locked: 'openspec/specs', + argvs: [ + ['validate', '--specs', '--json'], + ['validate', 'demo', '--json'], + ], + }, + { locked: 'openspec/specs/auth', argvs: [['validate', '--all', '--json']] }, + ] + for (const { locked, argvs } of cases) { + const restore = lock(join(root, locked)) + try { + for (const argv of argvs) { + const up = await upstreamJson(argv, root) + const cs = await oursJson(argv, root) + if (argv[0] === 'status') captureStatus(`16.10 ${argv.join(' ')}`, cs) + expect({ argv, exit: up.exitCode }).toEqual({ argv, exit: 1 }) + expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode }) + expect({ argv, doc: cs.json }).toEqual({ + argv, + doc: JSON.parse(respellRemedies(up.stdout)), + }) + expect(cs.stderr).toBe('') + } + if (locked === 'openspec/changes') { + const apply = await oursJson(['apply', 'demo', '--json'], root) + expect(apply.exitCode).toBe(1) + const d = firstStatus(apply.json) + expect(d.code).toBe('change_error') + expect(errnoShape(d.message)).toMatchObject({ + code: 'EACCES', + path: join(realpathSync(root), locked), + }) + const text = await ours(['validate', '--all'], root) + expect(text.exitCode).toBe(1) + expect(text.stderr).toContain('EACCES') + } + } finally { + restore() + } + } + }) + + test("16.11 apply relays the binary's refusal of the apply instructions", async () => { + const root = cospecRoot('chore') + writeChange( + root, + 'c1', + { 'proposal.md': PROPOSAL, 'blocking-changes.md': BLOCKERS, 'tasks.md': TASKS }, + 'chore', + ) + const clear = await oursJson(['apply', 'c1', '--json'], root) + expect((clear.json as Row).gate).toMatchObject({ state: 'clear' }) + const restore = lock(join(root, 'openspec/schemas/chore/schema.yaml')) + try { + const up = await upstreamJson(['instructions', 'apply', '--change', 'c1', '--json'], root) + expect(up.exitCode).toBe(1) + const cs = await oursJson(['apply', 'c1', '--json'], root) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const text = await ours(['apply', 'c1'], root) + expect(text.exitCode).toBe(1) + const d = firstStatus(up.json) + expect(text.stderr).toBe( + `cospec apply: ${respellRemedies(d.message)}\n${d.fix === undefined ? '' : `Fix: ${respellRemedies(d.fix)}\n`}`, + ) + } finally { + restore() + } + }) + + test('16.12 an unreadable directory no artifact lives in leaves the change as it is', async () => { + const root = cospecRoot() + writeChange(root, 'demo', { 'proposal.md': PROPOSAL, 'specs/widgets/spec.md': DELTA }) + for (const rel of ['.cache', 'specs/.h', 'scratch']) + mkdirSync(join(root, 'openspec/changes/demo', rel), { recursive: true }) + const readable = await oursJson(['validate', 'demo', '--json'], root) + const upReadable = await upstream(['validate', 'demo', '--json'], root) + for (const rel of ['.cache', 'specs/.h', 'scratch']) { + const restore = lock(join(root, 'openspec/changes/demo', rel)) + try { + // The binary never reads the directory: its answer is unchanged too. + const up = await upstream(['validate', 'demo', '--json'], root) + expect({ rel, upExit: up.exitCode }).toEqual({ rel, upExit: upReadable.exitCode }) + const cs = await oursJson(['validate', 'demo', '--json'], root) + expect({ rel, exit: cs.exitCode }).toEqual({ rel, exit: readable.exitCode }) + expect({ rel, doc: untimed(cs.json) }).toEqual({ rel, doc: untimed(readable.json) }) + } finally { + restore() + } + } + // A directory an artifact can live in still fails the change. + const restore = lock(join(root, 'openspec/changes/demo/specs/widgets')) + try { + const cs = await oursJson(['validate', 'demo', '--json'], root) + const rules = rowsOf(cs.json, 'items').flatMap((i) => + (i.issues as Row[]).map((x) => x.rule), + ) + expect(rules).toContain('meta/unreadable-artifact') + } finally { + restore() + } + }) + }) +}) + +// --- 17. round-4 review rows ------------------------------------------------------------ + +describe('17. round-4 review rows', () => { + /** + * Each way a project's `chore` schema stops loading, as `break` leaves it; + * the returned restore puts the installed copy back. + */ + const BROKEN_SCHEMAS: { how: string; rootOnly?: true; break: (dir: string) => () => void }[] = [ + { how: 'removed', break: (dir) => (rmSync(dir, { recursive: true }), () => {}) }, + { + how: 'unparsable', + break: (dir) => (writeFileSync(join(dir, 'schema.yaml'), 'name: [chore\n'), () => {}), + }, + { + how: 'invalid', + break: (dir) => ( + writeFileSync(join(dir, 'schema.yaml'), 'name: chore\nversion: 1\nartifacts: []\n'), + () => {} + ), + }, + { how: 'locked', rootOnly: true, break: (dir) => lock(dir) }, + ] + + test('17.1 a cospec-typed change whose schema the binary cannot load is refused in text too', async () => { + const root = cospecRoot() + writeChange(root, 'ch1', { 'proposal.md': PROPOSAL }, 'chore') + writeChange(root, 'other', { 'proposal.md': PROPOSAL }) + const dir = join(root, 'openspec/schemas/chore') + for (const { how, rootOnly, break: breakSchema } of BROKEN_SCHEMAS) { + if (rootOnly === true && RUNNING_AS_ROOT) continue + const restore = breakSchema(dir) + try { + const up = await upstreamJson(['status', '--change', 'ch1', '--json'], root) + const cs = await oursJson(['status', '--change', 'ch1', '--json'], root) + captureStatus(`17.1 ${how} json`, cs) + const upText = await upstream(['status', '--change', 'ch1'], root) + const text = await ours(['status', '--change', 'ch1'], root) + captureStatus(`17.1 ${how} text`, text) + // The binary cannot load the schema, so it refuses the change in both modes. + expect({ how, exit: up.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: upText.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: cs.exitCode }).toEqual({ how, exit: 1 }) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const messages = (up.json as { status: Diagnostic[] }).status.map((d) => + respellRemedies(d.message), + ) + expect(messages.length).toBeGreaterThan(0) + if (how === 'removed') expect(messages.join('\n')).toContain("Unknown schema 'chore'") + expect({ how, exit: text.exitCode, stdout: text.stdout }).toEqual({ + how, + exit: 1, + stdout: '', + }) + expect({ how, stderr: text.stderr }).toEqual({ + how, + stderr: messages.map((m) => `cospec status: ${m}\n`).join(''), + }) + + const upAll = await upstreamJson(['status', '--all', '--json'], root) + const all = await oursJson(['status', '--all', '--json'], root) + captureStatus(`17.1 ${how} sweep json`, all) + const upSweepText = await upstream(['status', '--all'], root) + const sweepText = await ours(['status', '--all'], root) + captureStatus(`17.1 ${how} sweep text`, sweepText) + expect({ how, exit: upAll.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: upSweepText.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: all.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: sweepText.exitCode }).toEqual({ how, exit: 1 }) + const ch1 = rowsOf(all.json).find((e) => e.change === 'ch1')! + expect({ how, error: ch1.error }).toEqual({ how, error: messages.join('\n') }) + expect(rowsOf(all.json).find((e) => e.change === 'other')!.error).toBeUndefined() + expect(sweepText.stdout).toContain(`ch1: ERROR — ${messages.join('\n')}\n`) + expect(sweepText.stdout).toContain('other (feat)') + } finally { + restore() + rmSync(dir, { recursive: true, force: true }) + cpSync(join(REPO_ROOT, 'openspec/schemas/chore'), dir, { recursive: true }) + } + } + // The schema installed again: the change is cospec's own answer, spawn-free in text. + const text = await ours(['status', '--change', 'ch1'], root) + expect(text.exitCode).toBe(0) + expect(text.stdout).toContain('gate:') + }) + + /** Each `.openspec.yaml` the binary's `ChangeMetadataSchema` refuses, its schema still loading. */ + const REFUSED_METADATA: { how: string; yaml: string }[] = [ + { how: 'created', yaml: 'schema: chore\ncreated: notadate\nschemaVersion: 2\n' }, + { how: 'skip_specs', yaml: 'schema: chore\nskip_specs: "yes"\nschemaVersion: 2\n' }, + { how: 'goal', yaml: 'schema: chore\ngoal: ""\nschemaVersion: 2\n' }, + { how: 'affected_areas', yaml: 'schema: chore\naffected_areas: [1]\nschemaVersion: 2\n' }, + { how: 'initiative', yaml: 'schema: chore\ninitiative: {store: s1}\nschemaVersion: 2\n' }, + { + how: 'initiative key', + yaml: 'schema: chore\ninitiative: {store: s1, id: i1, extra: x}\nschemaVersion: 2\n', + }, + { + how: 'retire_capabilities', + yaml: 'schema: chore\nretire_capabilities: "no"\nschemaVersion: 2\n', + }, + ] + + // Seven metadata shapes, eight spawns each: longer than the suite's default timeout. + test('17.3 a cospec-typed change whose metadata the binary refuses is refused in text too', async () => { + const root = cospecRoot() + const ch1 = writeChange(root, 'ch1', { 'proposal.md': PROPOSAL }, 'chore') + writeChange(root, 'other', { 'proposal.md': PROPOSAL }) + const meta = join(ch1, '.openspec.yaml') + const valid = readFileSync(meta, 'utf8') + for (const { how, yaml } of REFUSED_METADATA) { + writeFileSync(meta, yaml) + try { + const up = await upstreamJson(['status', '--change', 'ch1', '--json'], root) + const cs = await oursJson(['status', '--change', 'ch1', '--json'], root) + captureStatus(`17.3 ${how} json`, cs) + const upText = await upstream(['status', '--change', 'ch1'], root) + const text = await ours(['status', '--change', 'ch1'], root) + captureStatus(`17.3 ${how} text`, text) + // The binary's readChangeMetadata refuses the file, so it refuses the change in both modes. + expect({ how, exit: up.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: upText.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: cs.exitCode }).toEqual({ how, exit: 1 }) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const messages = (up.json as { status: Diagnostic[] }).status.map((d) => + respellRemedies(d.message), + ) + expect({ how, joined: messages.join('\n') }).toEqual({ + how, + joined: expect.stringContaining('Invalid metadata'), + }) + expect({ how, exit: text.exitCode, stdout: text.stdout }).toEqual({ + how, + exit: 1, + stdout: '', + }) + expect({ how, stderr: text.stderr }).toEqual({ + how, + stderr: messages.map((m) => `cospec status: ${m}\n`).join(''), + }) + + const upAll = await upstreamJson(['status', '--all', '--json'], root) + const all = await oursJson(['status', '--all', '--json'], root) + captureStatus(`17.3 ${how} sweep json`, all) + const upSweepText = await upstream(['status', '--all'], root) + const sweepText = await ours(['status', '--all'], root) + captureStatus(`17.3 ${how} sweep text`, sweepText) + expect({ how, exit: upAll.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: upSweepText.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: all.exitCode }).toEqual({ how, exit: 1 }) + expect({ how, exit: sweepText.exitCode }).toEqual({ how, exit: 1 }) + const entry = rowsOf(all.json).find((e) => e.change === 'ch1')! + expect({ how, error: entry.error }).toEqual({ how, error: messages.join('\n') }) + expect(rowsOf(all.json).find((e) => e.change === 'other')!.error).toBeUndefined() + expect(sweepText.stdout).toContain(`ch1: ERROR — ${messages.join('\n')}\n`) + expect(sweepText.stdout).toContain('other (feat)') + } finally { + writeFileSync(meta, valid) + } + } + // Valid metadata again: the change is cospec's own answer, exit 0 in text. + const text = await ours(['status', '--change', 'ch1'], root) + expect(text.exitCode).toBe(0) + expect(text.stdout).toContain('gate:') + }, 120_000) + + /** + * `changeMetadataRefused` asks `listSchemas(base)` whether the change's + * `schema:` is one it lists, so a project `openspec/schemas` the binary + * cannot read as a directory (replaced by a file) fails that call with an + * errno, not a verdict. Before the fix this escaped `binaryDecides` — called + * directly in the `--all` sweep's upstream-fetch decision and the + * `--change` lookup's own decision, both outside any per-change try/catch — + * and crashed the whole command with a bare `cospec: ENOTDIR …` line instead + * of the per-change `cospec status: …` (lookup) / `ch1: ERROR — …` (sweep) + * refusal the binary itself answers with. + */ + test('17.5 a project schemas directory the binary cannot read is refused in text too', async () => { + const root = cospecRoot() + writeChange(root, 'ch1', { 'proposal.md': PROPOSAL }, 'chore') + const schemas = join(root, 'openspec/schemas') + const saved = `${schemas}.saved` + renameSync(schemas, saved) + writeFileSync(schemas, 'not a directory\n') + try { + const up = await upstreamJson(['status', '--change', 'ch1', '--json'], root) + const cs = await oursJson(['status', '--change', 'ch1', '--json'], root) + captureStatus('17.5 json', cs) + const upText = await upstream(['status', '--change', 'ch1'], root) + const text = await ours(['status', '--change', 'ch1'], root) + captureStatus('17.5 text', text) + // The binary's listSchemas cannot read the project tier, so it refuses + // the change in both modes — never a top-level crash. + expect(up.exitCode).toBe(1) + expect(upText.exitCode).toBe(1) + expect(cs.exitCode).toBe(1) + expect(cs.json).toEqual(JSON.parse(respellRemedies(up.stdout))) + const messages = (up.json as { status: Diagnostic[] }).status.map((d) => + respellRemedies(d.message), + ) + expect(messages.length).toBeGreaterThan(0) + expect({ exit: text.exitCode, stdout: text.stdout }).toEqual({ exit: 1, stdout: '' }) + expect(text.stderr).toEqual(messages.map((m) => `cospec status: ${m}\n`).join('')) + + const upAll = await upstreamJson(['status', '--all', '--json'], root) + const all = await oursJson(['status', '--all', '--json'], root) + captureStatus('17.5 sweep json', all) + const upSweepText = await upstream(['status', '--all'], root) + const sweepText = await ours(['status', '--all'], root) + captureStatus('17.5 sweep text', sweepText) + expect(upAll.exitCode).toBe(1) + expect(upSweepText.exitCode).toBe(1) + expect(all.exitCode).toBe(1) + expect(sweepText.exitCode).toBe(1) + const ch1 = rowsOf(all.json).find((e) => e.change === 'ch1')! + expect(ch1.error).toEqual(messages.join('\n')) + expect(sweepText.stdout).toContain(`ch1: ERROR — ${messages.join('\n')}\n`) + } finally { + rmSync(schemas, { force: true }) + renameSync(saved, schemas) + } + }) +}) + +// --- 5.6 no status output names a bare openspec command ------------------------------------ + +describe('5.6 status outputs', () => { + test('no captured status output names a bare openspec command', () => { + expect(STATUS_OUTPUTS.length).toBeGreaterThan(10) + const bare = STATUS_OUTPUTS.filter((o) => BARE_OPENSPEC.test(o.text)).map((o) => o.label) + expect(bare).toEqual([]) + }) +}) diff --git a/apps/cli/test/contract/glob.test.ts b/apps/cli/test/contract/glob.test.ts new file mode 100644 index 00000000..aba1fbb5 --- /dev/null +++ b/apps/cli/test/contract/glob.test.ts @@ -0,0 +1,192 @@ +// `core/glob.ts`, cospec's port of the pinned binary's `artifactOutputExists` +// over the fast-glob cospec pins (fast-glob 3, micromatch 4, picomatch 2, +// braces 3), held to those modules as the pinned package resolves them: the +// same fast-glob version, fast-glob's brace expansion, the regex fast-glob +// matches each pattern with (under its own default settings' micromatch +// options), and the binary's `artifactOutputExists` answer over one change +// directory. The port's surface is `expandBraces(pattern)`, `makeRe(pattern)` +// and `artifactOutputExists(changeDir, generates)`. + +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +import { parse as parseYaml } from 'yaml' + +import { COSPEC_TYPES } from '../../src/core/change.ts' +import { openspecPackageDir } from '../../src/core/openspec.ts' +import { cleanupAll, mkTempRepo, REPO_ROOT } from '../fixtures/support.ts' + +afterAll(cleanupAll) + +interface GlobPort { + expandBraces: (pattern: string) => string[] + makeRe: (pattern: string) => RegExp + artifactOutputExists: (changeDir: string, generates: string) => boolean +} + +// Loaded at run time, so a missing or incomplete port fails its rows rather +// than the file. +const PORT_MODULE = join(import.meta.dir, '../../src/core', 'glob.ts') +const port = async (): Promise => (await import(PORT_MODULE)) as GlobPort + +const requireFromOpenspec = createRequire(join(openspecPackageDir(), 'package.json')) +const fastGlobOut = dirname(requireFromOpenspec.resolve('fast-glob')) +const fgPattern = requireFromOpenspec(join(fastGlobOut, 'utils/pattern.js')) as { + expandBraceExpansion: (pattern: string) => string[] + makeRe: (pattern: string, options: object) => RegExp +} +const FgSettings = ( + requireFromOpenspec(join(fastGlobOut, 'settings.js')) as { default: new (o: object) => object } +).default +const FgProviderSync = ( + requireFromOpenspec(join(fastGlobOut, 'providers/sync.js')) as { + default: new (settings: object) => { _getMicromatchOptions: () => object } + } +).default +/** The micromatch options fast-glob matches with under its default settings. */ +const FG_MATCH_OPTIONS = new FgProviderSync(new FgSettings({}))._getMicromatchOptions() + +const upstream = (await import( + join(openspecPackageDir(), 'dist/core/artifact-graph/outputs.js') +)) as { artifactOutputExists: (changeDir: string, generates: string) => boolean } + +function generatesOf(schemaFile: string): string[] { + const doc = parseYaml(readFileSync(schemaFile, 'utf8')) as { artifacts: { generates: string }[] } + return doc.artifacts.map((a) => a.generates) +} + +/** Every `generates` the pinned dist's schemas and cospec's types declare, and one per glob feature. */ +const PATTERNS = [ + ...new Set([ + ...readdirSync(join(openspecPackageDir(), 'schemas')).flatMap((name) => + generatesOf(join(openspecPackageDir(), 'schemas', name, 'schema.yaml')), + ), + ...COSPEC_TYPES.flatMap((name) => + generatesOf(join(REPO_ROOT, 'openspec/schemas', name, 'schema.yaml')), + ), + 'rfc/{proposal,design}*.md', + 'rfc/{proposal,design}.md', + 'rfc/{a,b{c,d}}*.md', + '{rfc,adr}/**/*.md', + '{,rfc/}*.md', + 'notes/{1..3}-*.md', + 'notes/{01..10}-*.md', + 'notes/{a..c}*.md', + 'rfc/@(proposal|design)*.md', + 'rfc/!(README)*.md', + 'rfc/+([a-z])-notes.md', + 'rfc/?(draft-)proposal.md', + 'rfc/*(draft-)proposal.md', + '!rfc/*.md', + '**/*.md', + '*.md', + 'rfc/*/notes.md', + 'rfc/?.md', + 'rfc/[pd]*.md', + 'rfc/[!R]*.md', + 'rfc/[[:alpha:]]*.md', + 'rfc/\\*.md', + '.hidden/*.md', + ]), +] + +/** One change directory holding a file each pattern above can match, and some none can. */ +const FILES = [ + 'proposal.md', + 'design.md', + 'tasks.md', + 'README.md', + 'specs/widgets/spec.md', + 'rfc/proposal.md', + 'rfc/design-v2.md', + 'rfc/abc-notes.md', + 'rfc/draft-proposal.md', + 'rfc/x/notes.md', + 'rfc/p.md', + 'rfc/README.md', + 'rfc/*.md', + 'rfc/bd.md', + 'notes/2-first.md', + 'notes/07-later.md', + 'notes/b.md', + 'adr/0001/decision.md', + '.hidden/a.md', + 'other/file.txt', +] + +/** An `artifactOutputExists` answer: its boolean, or the message it throws with. */ +function outcome(run: () => boolean): { exists: boolean } | { throws: string } { + try { + return { exists: run() } + } catch (error) { + return { throws: (error as Error).message } + } +} + +function changeDir(files: readonly string[]): string { + const dir = join(mkTempRepo(), 'change') + mkdirSync(dir, { recursive: true }) + for (const rel of files) { + mkdirSync(dirname(join(dir, rel)), { recursive: true }) + writeFileSync(join(dir, rel), '# content\n') + } + return dir +} + +describe("core/glob.ts answers as the pinned binary's glob modules", () => { + test('cospec resolves the fast-glob the pinned binary resolves', () => { + const own = createRequire(PORT_MODULE) + const version = (req: NodeJS.Require) => + (req(req.resolve('fast-glob/package.json')) as { version: string }).version + expect(version(own)).toBe(version(requireFromOpenspec)) + }) + + test('every pattern expands its braces as fast-glob does', async () => { + const { expandBraces } = await port() + for (const pattern of PATTERNS) + expect({ pattern, expanded: expandBraces(pattern) }).toEqual({ + pattern, + expanded: fgPattern.expandBraceExpansion(pattern), + }) + }) + + test("every pattern and each expansion compiles to fast-glob's regex", async () => { + const { makeRe } = await port() + for (const pattern of new Set( + PATTERNS.flatMap((p) => [p, ...fgPattern.expandBraceExpansion(p)]), + )) { + const want = fgPattern.makeRe(pattern, FG_MATCH_OPTIONS) + const got = makeRe(pattern) + expect({ pattern, source: got.source, flags: got.flags }).toEqual({ + pattern, + source: want.source, + flags: want.flags, + }) + } + }) + + test("artifactOutputExists is the binary's over a populated change", async () => { + const { artifactOutputExists } = await port() + const dir = changeDir(FILES) + for (const pattern of PATTERNS) + expect({ pattern, ...outcome(() => artifactOutputExists(dir, pattern)) }).toEqual({ + pattern, + ...outcome(() => upstream.artifactOutputExists(dir, pattern)), + }) + }) + + test("artifactOutputExists is the binary's with each file alone", async () => { + const { artifactOutputExists } = await port() + for (const file of FILES) { + const dir = changeDir([file]) + for (const pattern of PATTERNS) + expect({ file, pattern, ...outcome(() => artifactOutputExists(dir, pattern)) }).toEqual({ + file, + pattern, + ...outcome(() => upstream.artifactOutputExists(dir, pattern)), + }) + } + }) +}) diff --git a/apps/cli/test/contract/nested-detector.test.ts b/apps/cli/test/contract/nested-detector.test.ts new file mode 100644 index 00000000..05bf0897 --- /dev/null +++ b/apps/cli/test/contract/nested-detector.test.ts @@ -0,0 +1,165 @@ +// The namespace-folder detector (design D2) against the pinned binary's own +// `findNestedChangesIn` (`dist/utils/nested-change.js`), loaded in-process +// from the pinned package. Every schema the pinned dist ships and every cospec +// type is a row, plus project schemas whose `generates` use braces, extglobs +// and a negation: for each artifact, a hand-made change holding only that +// artifact's output, and a folder wrapping one. A real change must never be +// reported as a namespace folder, and every answer must be the binary's. + +import { afterAll, describe, expect, test } from 'bun:test' +import { cpSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { parse as parseYaml } from 'yaml' + +import { COSPEC_TYPES, findNestedChangesIn } from '../../src/core/change.ts' +import { openspecPackageDir } from '../../src/core/openspec.ts' +import { cleanupAll, mkTempRepo, REPO_ROOT } from '../fixtures/support.ts' + +afterAll(cleanupAll) + +type Finding = { name: string; nested: string[] } | undefined + +const upstream = (await import(join(openspecPackageDir(), 'dist/utils/nested-change.js'))) as { + findNestedChangesIn: (changesDir: string, name: string) => Promise +} + +/** A concrete file each `generates` pattern matches, as the binary's glob reads it. */ +const EXAMPLE_PATH: Record = { + 'specs/**/*.md': 'specs/widgets/spec.md', + 'rfc/{proposal,design}*.md': 'rfc/proposal.md', + 'rfc/@(proposal|design)*.md': 'rfc/design-v2.md', + 'rfc/!(README)*.md': 'rfc/proposal.md', + 'rfc/+([a-z])-notes.md': 'rfc/abc-notes.md', + 'notes/{1..3}-*.md': 'notes/2-first.md', + 'rfc/?(draft-)proposal.md': 'rfc/draft-proposal.md', + '{rfc,adr}/**/*.md': 'adr/0001/decision.md', + '!rfc/*.md': 'rfc/negated.md', +} + +/** Project schemas whose outputs sit in a subdirectory, one glob feature each. */ +const CUSTOM_SCHEMAS: Record = { + 'rfc-braces': ['rfc/{proposal,design}*.md'], + 'rfc-extglob-at': ['rfc/@(proposal|design)*.md'], + 'rfc-extglob-negate': ['rfc/!(README)*.md'], + 'rfc-extglob-plus': ['rfc/+([a-z])-notes.md'], + 'rfc-extglob-qmark': ['rfc/?(draft-)proposal.md'], + 'notes-range': ['notes/{1..3}-*.md'], + 'rfc-brace-globstar': ['{rfc,adr}/**/*.md'], + 'rfc-negated': ['!rfc/*.md'], +} + +function exampleFor(generates: string): string { + return EXAMPLE_PATH[generates] ?? generates +} + +function schemaYaml(name: string, generates: string[]): string { + const artifacts = generates + .map( + (g, i) => + ` - id: a${i}\n generates: '${g}'\n description: artifact ${i}\n template: t.md\n instruction: Write it.\n requires: []\n`, + ) + .join('') + return `name: ${name}\nversion: 1\ndescription: ${name}\nartifacts:\n${artifacts}` +} + +interface SchemaRow { + name: string + /** Installs the schema into the root and returns its `generates` values. */ + install: (root: string) => string[] +} + +function generatesOf(schemaFile: string): string[] { + const doc = parseYaml(readFileSync(schemaFile, 'utf8')) as { artifacts: { generates: string }[] } + return doc.artifacts.map((a) => a.generates) +} + +const PACKAGE_SCHEMAS: SchemaRow[] = readdirSync(join(openspecPackageDir(), 'schemas')).map( + (name) => ({ + name, + install: () => generatesOf(join(openspecPackageDir(), 'schemas', name, 'schema.yaml')), + }), +) + +const COSPEC_SCHEMAS: SchemaRow[] = COSPEC_TYPES.map((name) => ({ + name, + install: (root) => { + cpSync(join(REPO_ROOT, 'openspec/schemas', name), join(root, 'openspec/schemas', name), { + recursive: true, + }) + return generatesOf(join(root, 'openspec/schemas', name, 'schema.yaml')) + }, +})) + +const GLOB_SCHEMAS: SchemaRow[] = Object.entries(CUSTOM_SCHEMAS).map(([name, generates]) => ({ + name, + install: (root) => { + const dir = join(root, 'openspec/schemas', name) + mkdirSync(join(dir, 'templates'), { recursive: true }) + writeFileSync(join(dir, 'schema.yaml'), schemaYaml(name, generates)) + writeFileSync(join(dir, 'templates/t.md'), '# t\n') + return generates + }, +})) + +function write(root: string, rel: string): void { + const path = join(root, rel) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '# content\n') +} + +/** + * A root whose `config.yaml` names `row`'s schema, holding for each artifact a + * hand-made change `real-` with only that output, and a folder `ns-` + * wrapping such a change. Returns every candidate name. + */ +function schemaRoot(row: SchemaRow): { root: string; names: string[] } { + const root = mkTempRepo() + mkdirSync(join(root, 'openspec/changes'), { recursive: true }) + writeFileSync(join(root, 'openspec/config.yaml'), `schema: ${row.name}\n`) + const generates = row.install(root) + const names: string[] = [] + generates.forEach((g, i) => { + write(root, `openspec/changes/real-${i}/${exampleFor(g)}`) + write(root, `openspec/changes/ns-${i}/child/${exampleFor(g)}`) + names.push(`real-${i}`, `ns-${i}`) + }) + return { root, names } +} + +async function compare(row: SchemaRow): Promise { + const { root, names } = schemaRoot(row) + const changesDir = join(root, 'openspec/changes') + for (const name of names) { + const want = await upstream.findNestedChangesIn(changesDir, name) + const got = findNestedChangesIn(changesDir, name) + expect({ schema: row.name, name, finding: got ?? null }).toEqual({ + schema: row.name, + name, + finding: want ?? null, + }) + } +} + +describe("the namespace-folder detector answers as the binary's findNestedChangesIn", () => { + for (const row of PACKAGE_SCHEMAS) + test(`the pinned dist's ${row.name} schema`, () => compare(row)) + + for (const row of COSPEC_SCHEMAS) test(`cospec's ${row.name} schema`, () => compare(row)) + + for (const row of GLOB_SCHEMAS) + test(`a project schema generating ${CUSTOM_SCHEMAS[row.name]!.join(', ')}`, () => compare(row)) + + test('no hand-made change holding only its schema output is reported as a folder', () => { + for (const row of [...PACKAGE_SCHEMAS, ...COSPEC_SCHEMAS, ...GLOB_SCHEMAS]) { + const { root, names } = schemaRoot(row) + const changesDir = join(root, 'openspec/changes') + for (const name of names.filter((n) => n.startsWith('real-'))) + expect({ + schema: row.name, + name, + finding: findNestedChangesIn(changesDir, name) ?? null, + }).toEqual({ schema: row.name, name, finding: null }) + } + }) +}) diff --git a/apps/cli/test/contract/parity-close-out.test.ts b/apps/cli/test/contract/parity-close-out.test.ts index c3a97294..93e7225c 100644 --- a/apps/cli/test/contract/parity-close-out.test.ts +++ b/apps/cli/test/contract/parity-close-out.test.ts @@ -140,12 +140,14 @@ async function validateIssues(root: string, name: string): Promise { function buildNamespace(root: string): void { withFeatSchema(root) @@ -185,12 +187,13 @@ ${LIVING_REQ}`, const res = await cospec(['validate', 'ns-wrap', '--strict'], { cwd: root }) expect(res.exitCode).toBe(1) const issues = await validateIssues(root, 'ns-wrap') - // The wrapper has no `.openspec.yaml`, so cospec's meta rule fires in - // Step 2 and the run never reaches the delegated call. - expect(issues.map((i) => i.rule)).toContain('meta/openspec-yaml') - // Recorded, not asserted as desirable: the delegated namespace ERROR does - // not reach the reader. The deferred nested-change work closes this. - expect(issues.some((i) => i.message.includes('folder wrapping'))).toBe(false) + // cli-surface-parity's detector: one `meta/nested-change` ERROR carrying + // the binary's explanation, and no other rule — the missing + // `.openspec.yaml` the folder has is no longer the reported cause. + expect(issues.map((i) => i.rule)).toEqual(['meta/nested-change']) + expect(issues[0]?.message).toContain( + '"ns-wrap" is not a change: it is a folder wrapping openspec/changes/ns-wrap/real-change/', + ) }) test('cospec archive refuses and moves nothing', async () => { diff --git a/apps/cli/test/contract/parity-pending.yaml b/apps/cli/test/contract/parity-pending.yaml index 369840ab..513b2563 100644 --- a/apps/cli/test/contract/parity-pending.yaml +++ b/apps/cli/test/contract/parity-pending.yaml @@ -16,28 +16,6 @@ # `source: cli` marks a hidden surface the four registry sources cannot # produce; the test verifies it against the pinned binary instead. -# --- cli-surface-parity ----------------------------------------------------------- -- { kind: flag, path: [list], flag: --sort, owner: cli-surface-parity } -- { kind: flag, path: [status], flag: --schema, owner: cli-surface-parity } -- { kind: flag, path: [validate], flag: --type, owner: cli-surface-parity } -- { kind: flag, path: [validate], flag: --report, owner: cli-surface-parity } -- { kind: flag, path: [validate], flag: --concurrency, owner: cli-surface-parity } - -# Upstream's hidden `__complete ` (dist/cli/index.js) also serves these -# two types; the walk cannot produce them. -- kind: positional-value - path: [__complete] - index: 0 - value: schemas - source: cli - owner: cli-surface-parity -- kind: positional-value - path: [__complete] - index: 0 - value: archived-changes - source: cli - owner: cli-surface-parity - # --- archive-and-sync-parity ------------------------------------------------------ - { kind: flag, path: [archive], flag: --no-validate, owner: archive-and-sync-parity } diff --git a/apps/cli/test/contract/precedence-matrix.test.ts b/apps/cli/test/contract/precedence-matrix.test.ts index 3d4f9603..c0e25bee 100644 --- a/apps/cli/test/contract/precedence-matrix.test.ts +++ b/apps/cli/test/contract/precedence-matrix.test.ts @@ -596,8 +596,24 @@ const VALUE_POSITION_ROWS: readonly Row[] = [ { argv: ['init', '--tools', '--help'], command: 'init', check: nothingWritten }, { argv: ['init', '--profile', '--help'], command: 'init', check: nothingWritten }, { argv: ['init', '--language', '--help'], command: 'init', check: nothingWritten }, - { argv: ['validate', '--concurrency', '--help'], command: 'validate' }, - { argv: ['validate', '--type', '--json'], command: 'validate' }, + // `--help` is `--concurrency`'s value (ignored as no positive integer), so no + // item and no scope is named: the binary prints its non-interactive hint and + // exits 1, while cospec — which never prompts — validates everything. + { + argv: ['validate', '--concurrency', '--help'], + command: 'validate', + cospecOnly: { outcome: 'parsed', exit: 0 }, + }, + // `--json` is `--type`'s value, so no item and no scope is named: the + // binary prints its non-interactive hint and exits 1, while cospec — which + // never prompts — validates everything, its documented opinion. + { + argv: ['validate', '--type', '--json'], + command: 'validate', + cospecOnly: { outcome: 'parsed', exit: 0 }, + }, + { argv: ['status', '--schema', '--json'], command: 'status' }, + { argv: ['list', '--sort', '--help'], command: 'list' }, { argv: ['templates', '--schema', '--help'], command: 'templates' }, { argv: ['templates', '--schema', '--json'], command: 'templates' }, { argv: ['show', 'c1', '--type', '--help'], command: 'show' }, @@ -615,20 +631,6 @@ const VALUE_POSITION_ROWS: readonly Row[] = [ }, { argv: ['store', 'setup', 's1', '--path', '--help'], command: 'store', check: storeSetUp }, { argv: ['workset', 'create', 'w1', '--tool', '--help'], command: 'workset' }, - // cospec refuses a pending flag as not supported yet once it has its value, - // where the binary runs with `--help` as the value (owned by later changes). - { - argv: ['list', '--sort', '--help'], - command: 'list', - cospecOnly: { outcome: 'parsed', exit: 1 }, - cospecStderr: "cospec list: '--sort' is not supported yet\n", - }, - { - argv: ['status', '--schema', '--json'], - command: 'status', - cospecOnly: { outcome: 'parsed', exit: 1 }, - cospecStderr: "cospec status: '--schema' is not supported yet\n", - }, // Every other declared value-taking flag, table and forward rows alike. { argv: ['feedback', '--body', '--help'], command: 'feedback' }, { argv: ['templates', '--schema', '-h'], command: 'templates' }, @@ -656,11 +658,11 @@ const VALUE_POSITION_ROWS: readonly Row[] = [ }, { argv: ['workset', 'open', 'w1', '--tool', '--help'], command: 'workset' }, { argv: ['config', '--scope', '--help', 'list'], command: 'config' }, + // `--help` is `--report`'s value: both refuse it as an unknown report. { argv: ['validate', '--report', '--help'], command: 'validate', - cospecOnly: { outcome: 'parsed', exit: 1 }, - cospecStderr: "cospec validate: '--report' is not supported yet\n", + cospecStderr: "Error: Unknown validation report '--help'.\n", }, { argv: ['instructions', 'proposal', '--schema', '--help'], command: 'instructions' }, // cospec-only flags take their value the same way. diff --git a/apps/cli/test/contract/reachability.test.ts b/apps/cli/test/contract/reachability.test.ts index 10cd3b38..36284bf4 100644 --- a/apps/cli/test/contract/reachability.test.ts +++ b/apps/cli/test/contract/reachability.test.ts @@ -1056,11 +1056,11 @@ describe('reachability: negative cases (ledger 4.1, 4.3, 4.5)', () => { }) test('a pending entry whose owner disagrees with the table fails', () => { - const pending = PENDING.filter((pe) => !(pe.kind === 'flag' && pe.flag === '--sort')) - pending.push({ kind: 'flag', path: ['list'], flag: '--sort', owner: 'tool-matrix' }) + const pending = PENDING.filter((pe) => !(pe.kind === 'flag' && pe.flag === '--no-validate')) + pending.push({ kind: 'flag', path: ['archive'], flag: '--no-validate', owner: 'tool-matrix' }) const failures = checkReachability({ ...model, pending }) expect(failures).toContain( - "flag `list --sort` is pending on 'tool-matrix' in parity-pending.yaml but on 'cli-surface-parity' in the command table", + "flag `archive --no-validate` is pending on 'tool-matrix' in parity-pending.yaml but on 'archive-and-sync-parity' in the command table", ) }) diff --git a/apps/cli/test/contract/relayed-remedies.test.ts b/apps/cli/test/contract/relayed-remedies.test.ts index b4a15846..32ee60ce 100644 --- a/apps/cli/test/contract/relayed-remedies.test.ts +++ b/apps/cli/test/contract/relayed-remedies.test.ts @@ -284,13 +284,16 @@ describe('view relays its footer through cospec', () => { }, 30_000) }) -describe('status of a legacy-schema change relays the binary status through cospec', () => { +describe("status of a legacy-schema change renders the binary's status through cospec", () => { /** The absolute change root each tool prints, which differs between the two copies. */ function rootless_(text: string, root: string): string { return text.replaceAll(realpathSync(root), '').replaceAll(root, '') } - test('text: the binary status, its Next remedy naming cospec instructions', async () => { + // cli-surface-parity: rendered from the binary's `status --json` document + // (a port of its text printer), the `Next:` line from cospec's own next-step + // decision — nothing the binary printed as prose is relayed. + test('text: the binary status, its Next line naming cospec instructions', async () => { const coRoot = fixtureRoot() const upRoot = fixtureRoot() const co = await cospec(['status', '--change', 'sd1'], { @@ -302,30 +305,38 @@ describe('status of a legacy-schema change relays the binary status through cosp expect(co.exitCode, detail(co)).toBe(up.exitCode) expect(rootless_(co.stdout, coRoot), detail(co)).toBe( rootless_(up.stdout, upRoot).replace( - 'Next: openspec instructions specs', - 'Next: cospec instructions specs', + 'Next: openspec instructions specs --change "sd1" --json', + 'Next: cospec instructions specs --change sd1', ), ) - expect(co.stderr).toBe(up.stderr) + // The binary's only stderr line is its spinner, which a rendered document has not. + expect(up.stderr).toBe('- Loading change status...\n') + expect(co.stderr).toBe('') expect(co.stdout + co.stderr).not.toMatch(BARE_OPENSPEC) }, 30_000) - test('--json keeps its legacy document', async () => { + test("--json keeps its legacy keys beside the binary's", async () => { const root = fixtureRoot() const co = await cospec(['status', '--change', 'sd1', '--json'], { cwd: root, env: oracleEnv(root), }) expect(co.exitCode, detail(co)).toBe(0) - expect(JSON.parse(co.stdout)).toEqual({ change: 'sd1', type: 'spec-driven', legacy: true }) + expect(JSON.parse(co.stdout)).toMatchObject({ + change: 'sd1', + type: 'spec-driven', + legacy: true, + next: 'cospec instructions specs --change sd1', + changeName: 'sd1', + schemaName: 'spec-driven', + }) }, 30_000) - test('--all points a legacy change at cospec status', async () => { + test("--all renders a legacy change's real status", async () => { const root = fixtureRoot() const co = await cospec(['status', '--all'], { cwd: root, env: oracleEnv(root) }) - expect(co.stdout, detail(co)).toContain( - 'sd1 (spec-driven): legacy schema — use `cospec status --change sd1` for details', - ) + expect(co.stdout, detail(co)).toContain('Change: sd1\nSchema: spec-driven\n') + expect(co.stdout).toContain('Next: cospec instructions specs --change sd1\n') expect(co.stdout + co.stderr).not.toMatch(BARE_OPENSPEC) }, 30_000) }) diff --git a/apps/cli/test/contract/root-resolution.test.ts b/apps/cli/test/contract/root-resolution.test.ts index 4cf3caf7..7f4b20b0 100644 --- a/apps/cli/test/contract/root-resolution.test.ts +++ b/apps/cli/test/contract/root-resolution.test.ts @@ -58,6 +58,9 @@ import { oracle } from './support/upstream-oracle.ts' afterAll(cleanupAll) +/** `list`'s null-shape ahead of `status` in its `--json` failure document, as the binary prints it. */ +const LIST_PAYLOAD = { changes: [], root: null } + interface CospecRoot { path: string source: string @@ -1137,13 +1140,22 @@ describe('a resolver hard-error under --json is one status document (ledger 5.4) argv: string[] setup: (sb: Sandbox) => string code: string + /** The command's own null-shape ahead of `status`, as the binary prints it (cli-surface-parity). */ + payload: Record }[] = [ - { id: 'M15', argv: ['list', '--json'], setup: bare, code: 'no_root_with_registered_stores' }, + { + id: 'M15', + argv: ['list', '--json'], + setup: bare, + code: 'no_root_with_registered_stores', + payload: LIST_PAYLOAD, + }, { id: 'M6', argv: ['status', '--json'], setup: (sb) => repo(sb, 'm6', configOnly('store: [unclosed\n')), code: 'invalid_store_pointer', + payload: {}, }, { id: 'M22', @@ -1154,6 +1166,7 @@ describe('a resolver hard-error under --json is one status document (ledger 5.4) return bare(sb) }, code: 'store_identity_mismatch', + payload: LIST_PAYLOAD, }, ] @@ -1168,7 +1181,9 @@ describe('a resolver hard-error under --json is one status document (ledger 5.4) expect(res.exitCode).toBe(1) expect(res.stderr).not.toMatch(/^cospec:/m) const doc = JSON.parse(res.stdout) as { status: RootDiagnostic[] } - expect(Object.keys(doc)).toEqual(['status']) + expect(Object.keys(doc)).toEqual([...Object.keys(c.payload), 'status']) + const { status: _status, ...payload } = doc + expect(payload).toEqual(c.payload) expect(doc.status).toHaveLength(1) const d = doc.status[0]! expect(Object.keys(d)).toEqual(['severity', 'code', 'message', 'target', 'fix']) @@ -1213,7 +1228,7 @@ describe('an empty --store= fails with invalid_store_id (ledger 5.5)', () => { expect(res.exitCode).toBe(1) expect(res.stderr).toBe('') const doc = JSON.parse(res.stdout) as { status: OracleDiagnostic[] } - expect(doc).toEqual({ status: [o.diagnostic!] }) + expect(doc).toEqual({ ...LIST_PAYLOAD, status: [o.diagnostic!] }) }) test('human mode: the oracle text after cospec:', async () => { @@ -1652,7 +1667,7 @@ describe('defaultStore reaches selection as the raw value upstream reads (ledger const o = await rootOracle(sb, cwd, ['list', '--json']) const res = await cospec(['list', '--json'], { cwd, env: sb.env }) expect(res.exitCode).toBe(1) - expect(JSON.parse(res.stdout)).toEqual({ status: [o.diagnostic] }) + expect(JSON.parse(res.stdout)).toEqual({ ...LIST_PAYLOAD, status: [o.diagnostic] }) const up = await oracle(['list'], sb.dir, { cwd }) const text = await cospec(['list'], { cwd, env: sb.env }) expect(text.exitCode).toBe(1) @@ -1725,7 +1740,7 @@ describe('an unreadable store registry fails selection with its diagnostic (ledg const o = await rootOracle(sb, fx.cwd, ['list', '--json', ...storeFlag()]) const res = await cospec(['list', '--json', ...storeFlag()], { cwd: fx.cwd, env: sb.env }) expect(res.exitCode).toBe(1) - expect(JSON.parse(res.stdout)).toEqual({ status: [o.diagnostic] }) + expect(JSON.parse(res.stdout)).toEqual({ ...LIST_PAYLOAD, status: [o.diagnostic] }) }) test("cospec list prints the binary's failure after cospec:", async () => { @@ -2081,8 +2096,9 @@ describe('a global config that cannot be read or parsed reads as defaults (ledge source: 'global_default', store_id: 'alpha', }) + // cli-surface-parity: status's `root` is the binary's object, not its path. if (argv[0] === 'status') - expect((JSON.parse(res.stdout) as { root: string }).root).toBe(root.path) + expect((JSON.parse(res.stdout) as { root: OracleRoot }).root).toEqual(root) }) test("from a rootless directory, doctor --json operates on the binary's root", async () => { @@ -2102,8 +2118,8 @@ describe('a global config that cannot be read or parsed reads as defaults (ledge const up = await binary('valid', ['status', '--json'], cwd) const res = await ours('valid', ['status', '--json'], cwd) expect(res.stderr).toBe(up.stderr) - expect((JSON.parse(res.stdout) as { root: string }).root).toBe( - (JSON.parse(up.stdout) as { root: OracleRoot }).root.path, + expect((JSON.parse(res.stdout) as { root: OracleRoot }).root).toEqual( + (JSON.parse(up.stdout) as { root: OracleRoot }).root, ) }) diff --git a/apps/cli/test/contract/support/key-oracle.ts b/apps/cli/test/contract/support/key-oracle.ts new file mode 100644 index 00000000..f66af9d8 --- /dev/null +++ b/apps/cli/test/contract/support/key-oracle.ts @@ -0,0 +1,376 @@ +// The key oracle (design D5): walks the pinned binary's `--json` document and +// cospec's document for the same invocation together, and says where cospec +// drops an upstream key, reports an upstream value differently, or collides +// with one outside the named collision list. A second check proves cospec's own +// pre-existing keys still carry the values cospec computes natively. +// +// Paths are written with `.` between keys and `[]` for an array entry +// (`changes[].artifacts[].status`); a pattern segment `*` matches one key and +// `**` any run of segments, none included. Arrays of objects are matched entry +// by entry through an identity per path, never by index, so the two tools may +// order a sweep differently; any other array is compared whole. + +import { respellWholeRemedy } from '../../../src/core/remedies.ts' + +/** How a shared path is compared. */ +export type PathClass = + | 'exempt' + | 'timing' + | 'verdict' + | 'kept' + | 'collision' + | 'respelled' + | 'equal' + +/** One side's identity for an array entry; `undefined` for an entry with none. */ +export type Identity = (entry: Record) => string | undefined + +export interface ArrayIdentity { + /** The binary's entry identity (`name`, `changeName`, `type:id`). */ + readonly upstream: Identity + /** cospec's entry identity for the same entry (`change`, `kind:id`). */ + readonly cospec: Identity +} + +/** + * A key both tools emit whose values differ by design. `check` sees the two + * parent objects, so an entry can prove the mapping it names (cospec's `kind` + * carries the binary's `type`). + */ +export interface NamedCollision { + readonly path: string + readonly reason: string + readonly check?: ( + upstreamParent: Record, + cospecParent: Record, + ) => string | undefined +} + +/** + * The named collision list. Every other key both tools emit must carry the + * binary's value, and `compareDocuments` fails on any collision not named here. + */ +export const NAMED_COLLISIONS: readonly NamedCollision[] = [ + { + path: 'version', + reason: + 'cospec\'s envelopes keep their own format marker, `version: 1`; the binary\'s `version` is its report format (`"1.0"`), which cospec never takes', + }, + { + path: 'items[].type', + reason: + "cospec's documented `type` is a change's schema (absent on a spec); the binary's `type` is the item kind, which cospec reports as `kind`", + check: (up, cs) => { + if (cs.kind !== up.type) + return `kind is ${JSON.stringify(cs.kind)} where the binary's type is ${JSON.stringify(up.type)}` + if (cs.kind === 'spec' && cs.type !== undefined) + return `a spec item carries type ${JSON.stringify(cs.type)}` + if (cs.kind === 'change' && cs.type !== undefined && typeof cs.type !== 'string') + return `type is not a schema name: ${JSON.stringify(cs.type)}` + return undefined + }, + }, +] + +export interface OracleSpec { + /** Identity per array-of-objects path. */ + readonly identities?: Readonly> + /** Compared by presence and JSON type (`durationMs`, `lastModified`). */ + readonly timing?: readonly string[] + /** Each lane keeps its own findings: presence and JSON type only. */ + readonly verdict?: readonly string[] + /** Upstream values cospec relays with each remedy spelled through cospec. */ + readonly respelled?: readonly string[] + /** + * Keys whose value is cospec's own pre-existing one (the in-progress + * status entry's empty `artifacts`): presence and JSON type only here, the + * value proven against cospec's native document by the row itself. + */ + readonly kept?: readonly string[] + /** The named collisions this command's document may carry (from `NAMED_COLLISIONS`). */ + readonly collisions?: readonly string[] +} + +export interface OracleResult { + /** One line per defect, each naming the offending path. */ + readonly failures: string[] + /** + * Array paths the binary's document left empty in every instance, so a row + * can prove its fixture exercises at least one entry of each array. + */ + readonly emptyArrays: string[] +} + +type Segments = string[] + +function segments(pattern: string): Segments { + return pattern + .split('.') + .flatMap((part) => (part.endsWith('[]') ? [part.slice(0, -2), '[]'] : [part])) + .filter((s) => s.length > 0) +} + +function render(path: Segments): string { + return path.reduce( + (out, seg) => (seg === '[]' ? `${out}[]` : out === '' ? seg : `${out}.${seg}`), + '', + ) +} + +function matches(pattern: Segments, path: Segments): boolean { + if (pattern.length === 0) return path.length === 0 + const [head, ...rest] = pattern + if (head === '**') { + for (let i = 0; i <= path.length; i++) if (matches(rest, path.slice(i))) return true + return false + } + if (path.length === 0) return false + if (head !== '*' && head !== path[0]) return false + return matches(rest, path.slice(1)) +} + +const EXEMPT = [segments('version')] + +function jsonType(value: unknown): string { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + return typeof value +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function deepEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b) && jsonType(a) === jsonType(b) +} + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical) + if (isPlainObject(value)) + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((k) => [k, canonical(value[k])]), + ) + return value +} + +function same(a: unknown, b: unknown): boolean { + return deepEqual(canonical(a), canonical(b)) +} + +interface Compiled { + identities: [Segments, ArrayIdentity][] + timing: Segments[] + verdict: Segments[] + respelled: Segments[] + kept: Segments[] + collisions: [Segments, NamedCollision][] +} + +function compile(spec: OracleSpec): Compiled { + const named = new Map(NAMED_COLLISIONS.map((c) => [c.path, c])) + return { + identities: Object.entries(spec.identities ?? {}).map(([p, id]) => [segments(p), id]), + timing: (spec.timing ?? []).map(segments), + verdict: (spec.verdict ?? []).map(segments), + respelled: (spec.respelled ?? []).map(segments), + kept: (spec.kept ?? []).map(segments), + collisions: (spec.collisions ?? []).map((p) => { + const entry = named.get(p) + if (entry === undefined) throw new Error(`key oracle: '${p}' is not a named collision`) + return [segments(p), entry] + }), + } +} + +function classify(c: Compiled, path: Segments): PathClass { + if (EXEMPT.some((p) => matches(p, path))) return 'exempt' + if (c.collisions.some(([p]) => matches(p, path))) return 'collision' + if (c.timing.some((p) => matches(p, path))) return 'timing' + if (c.verdict.some((p) => matches(p, path))) return 'verdict' + if (c.kept.some((p) => matches(p, path))) return 'kept' + if (c.respelled.some((p) => matches(p, path))) return 'respelled' + return 'equal' +} + +function identityFor(c: Compiled, path: Segments): ArrayIdentity | undefined { + return c.identities.find(([p]) => matches(p, path))?.[1] +} + +/** + * Compare the binary's document with cospec's: every upstream key path must be + * present in cospec's, with the binary's value unless its class says + * otherwise. Extra cospec keys are never a failure here; `checkNativeKeys` + * proves those. + */ +export function compareDocuments( + upstream: unknown, + cospec: unknown, + spec: OracleSpec, +): OracleResult { + const c = compile(spec) + const failures: string[] = [] + const empty = new Set() + const filled = new Set() + + const walk = ( + up: unknown, + cs: unknown, + path: Segments, + parents: { up?: Record; cs?: Record }, + ): void => { + const label = render(path) || '' + const cls = classify(c, path) + if (cls === 'exempt') return + if (cls === 'collision') { + const entry = c.collisions.find(([p]) => matches(p, path))![1] + const problem = entry.check?.(parents.up ?? {}, parents.cs ?? {}) + if (problem !== undefined) failures.push(`${label}: named collision broken: ${problem}`) + return + } + if (cs === undefined) { + failures.push(`${label}: missing (the binary has ${JSON.stringify(up)})`) + return + } + if (cls === 'timing' || cls === 'verdict' || cls === 'kept') { + if (jsonType(up) !== jsonType(cs)) + failures.push( + `${label}: ${cls} value is a ${jsonType(cs)} where the binary's is a ${jsonType(up)}`, + ) + return + } + if (cls === 'respelled') { + const want = typeof up === 'string' ? respellWholeRemedy(up) : up + if (!same(want, cs)) + failures.push( + `${label}: ${JSON.stringify(cs)} is not the binary's value respelled (${JSON.stringify(want)})`, + ) + return + } + if (jsonType(up) !== jsonType(cs)) { + failures.push( + `${label}: collision: cospec has a ${jsonType(cs)} (${JSON.stringify(cs)}) where the binary has a ${jsonType(up)} (${JSON.stringify(up)})`, + ) + return + } + if (isPlainObject(up) && isPlainObject(cs)) { + for (const key of Object.keys(up)) walk(up[key], cs[key], [...path, key], { up, cs }) + return + } + if (Array.isArray(up) && Array.isArray(cs)) { + const elementPath = [...path, '[]'] + if (up.length === 0) empty.add(render(path)) + else filled.add(render(path)) + const identity = identityFor(c, elementPath) + const hasRespelled = c.respelled.some((p) => matches(p, elementPath)) + if (identity === undefined && !hasRespelled) { + if (!same(up, cs)) + failures.push( + `${label}: collision: ${JSON.stringify(cs)} where the binary has ${JSON.stringify(up)}`, + ) + return + } + if (identity === undefined) { + if (up.length !== cs.length) + failures.push(`${label}: ${cs.length} entries where the binary has ${up.length}`) + up.forEach((entry, i) => walk(entry, cs[i], elementPath, {})) + return + } + for (const entry of up) { + if (!isPlainObject(entry)) { + failures.push(`${label}: an entry is not an object: ${JSON.stringify(entry)}`) + continue + } + const key = identity.upstream(entry) + const twin = cs.find((e) => isPlainObject(e) && identity.cospec(e) === key) + if (twin === undefined) { + failures.push(`${label}: no cospec entry for the binary's ${JSON.stringify(key)}`) + continue + } + walk(entry, twin, elementPath, { up: entry, cs: twin as Record }) + } + return + } + if (!same(up, cs)) + failures.push( + `${label}: collision: cospec has ${JSON.stringify(cs)} where the binary has ${JSON.stringify(up)}`, + ) + } + + walk(upstream, cospec, [], {}) + return { failures, emptyArrays: [...empty].filter((p) => !filled.has(p)) } +} + +/** + * The snapshot check: every path in `snapshot` (cospec's key set before this + * change) must be present in `cospec` with the value `native` — the document + * cospec computes natively for the same fixture — carries. Array entries are + * matched by the cospec side of `identities`. + */ +export function checkNativeKeys( + cospec: unknown, + native: unknown, + snapshot: readonly string[], + identities: Readonly> = {}, +): string[] { + const wanted = snapshot.map(segments) + const ids = Object.entries(identities).map(([p, id]) => [segments(p), id] as const) + const failures: string[] = [] + const prefixOfWanted = (path: Segments): boolean => + wanted.some((w) => w.length > path.length && matches(w.slice(0, path.length), path)) + + const walk = (nat: unknown, cs: unknown, path: Segments): void => { + const label = render(path) || '' + if (wanted.some((w) => matches(w, path))) { + if (cs === undefined) failures.push(`${label}: cospec key dropped`) + else if (!same(nat, cs)) + failures.push( + `${label}: cospec value changed: ${JSON.stringify(cs)}, natively ${JSON.stringify(nat)}`, + ) + return + } + if (!prefixOfWanted(path)) return + if (cs === undefined) { + failures.push(`${label}: cospec key dropped`) + return + } + if (isPlainObject(nat) && isPlainObject(cs)) { + for (const key of Object.keys(nat)) walk(nat[key], cs[key], [...path, key]) + return + } + if (Array.isArray(nat) && Array.isArray(cs)) { + const elementPath = [...path, '[]'] + const identity = ids.find(([p]) => matches(p, elementPath))?.[1] + nat.forEach((entry, i) => { + const twin = + identity === undefined || !isPlainObject(entry) + ? cs[i] + : cs.find((e) => isPlainObject(e) && identity.cospec(e) === identity.cospec(entry)) + walk(entry, twin, elementPath) + }) + return + } + failures.push(`${label}: shape changed: ${JSON.stringify(cs)}, natively ${JSON.stringify(nat)}`) + } + + walk(native, cospec, []) + return failures +} + +/** Identity helpers for the rows. */ +export const byKey = + (key: string): Identity => + (entry) => { + const value = entry[key] + return typeof value === 'string' ? value : undefined + } + +export const byKindAndId = + (kindKey: string): Identity => + (entry) => + typeof entry.id === 'string' ? `${String(entry[kindKey])}:${entry.id}` : undefined + +export const byCodeAndName: Identity = (entry) => + `${String(entry.code)}:${typeof entry.name === 'string' ? entry.name : String(entry.message)}` diff --git a/apps/cli/test/contract/unknown-option-differential.test.ts b/apps/cli/test/contract/unknown-option-differential.test.ts index 6152243e..61f4e771 100644 --- a/apps/cli/test/contract/unknown-option-differential.test.ts +++ b/apps/cli/test/contract/unknown-option-differential.test.ts @@ -407,37 +407,6 @@ const PENDING_ROWS: readonly Row[] = [ expect: 'pending', pendingFlag: '--no-copilot-cloud', }, - { - argv: ['validate', '--type', 'change', 'x'], - command: 'validate', - expect: 'pending', - pendingFlag: '--type', - setup: addChangeNamedChange, - }, - { - argv: ['validate', '--report', 'findings', '--all'], - command: 'validate', - expect: 'pending', - pendingFlag: '--report', - }, - { - argv: ['validate', '--concurrency', '4', '--all'], - command: 'validate', - expect: 'pending', - pendingFlag: '--concurrency', - }, - { - argv: ['status', '--schema', 'custom'], - command: 'status', - expect: 'pending', - pendingFlag: '--schema', - }, - { - argv: ['list', '--sort', 'name'], - command: 'list', - expect: 'pending', - pendingFlag: '--sort', - }, { argv: ['archive', '--no-validate', 'x'], command: 'archive', @@ -457,19 +426,6 @@ const PENDING_ROWS: readonly Row[] = [ expect: 'pending', pendingFlag: 'uninstall', }, - // Upstream's hidden `__complete` also serves these two types. - { - argv: ['__complete', 'schemas'], - command: '__complete', - expect: 'pending', - pendingFlag: 'schemas', - }, - { - argv: ['__complete', 'archived-changes'], - command: '__complete', - expect: 'pending', - pendingFlag: 'archived-changes', - }, ] /** @@ -507,6 +463,28 @@ const UPSTREAM_SPELLING_ROWS: readonly Row[] = [ }, ] +/** + * The flags and sources change `cli-surface-parity` implements: each was a + * pending row refused as not supported yet, and now parses and runs as the + * binary does. + */ +const CLI_SURFACE_ROWS: readonly Row[] = [ + { argv: ['status', '--schema', 'custom'], command: 'status', expect: 'same', exit: 0 }, + { argv: ['list', '--sort', 'name'], command: 'list', expect: 'same', exit: 0 }, + { argv: ['validate', '--report', 'findings', '--all'], command: 'validate', expect: 'same' }, + { argv: ['validate', '--concurrency', '4', '--all'], command: 'validate', expect: 'same' }, + { argv: ['__complete', 'schemas'], command: '__complete', expect: 'same', exit: 0 }, + { argv: ['__complete', 'archived-changes'], command: '__complete', expect: 'same', exit: 0 }, + // `--type` takes `change` as its value, never the positional: `x` is the + // item, and a change literally named `change` is not validated. + { + argv: ['validate', '--type', 'change', 'x'], + command: 'validate', + expect: 'same', + setup: addChangeNamedChange, + }, +] + /** * Rows cospec does not answer as the binary does yet, keyed by argv, run as * `test.failing` until the commit implementing each surface removes its key. @@ -865,6 +843,10 @@ describe('unknown-option differential: upstream spellings', () => { }) }) +describe('unknown-option differential: cli-surface-parity flags', () => { + register(CLI_SURFACE_ROWS) +}) + describe('unknown-option differential: forward commands relay the binary', () => { register(FORWARD_ROWS) diff --git a/apps/cli/test/contract/upstream-spellings.test.ts b/apps/cli/test/contract/upstream-spellings.test.ts index d623a94a..5f3f7b70 100644 --- a/apps/cli/test/contract/upstream-spellings.test.ts +++ b/apps/cli/test/contract/upstream-spellings.test.ts @@ -782,7 +782,13 @@ describe('3.2 instructions without a change or an artifact lets the binary answe const c = await runCospec(['instructions', 'apply', '--change', 'nope', ...flag], root) const a = await runCospec(['apply', 'nope', ...flag], root) expect(a.exitCode).toBe(1) - expect(a.stderr).toContain("unknown change 'nope'") + // Under --json apply's refusal is its one change_error document (cli-surface-parity). + if (asJson) { + expect(a.stderr).toBe('') + const doc = JSON.parse(a.stdout) as { status: { code: string; message: string }[] } + expect(doc.status[0]?.code).toBe('change_error') + expect(doc.status[0]?.message).toContain("unknown change 'nope'") + } else expect(a.stderr).toContain("unknown change 'nope'") expect({ exit: c.exitCode, stdout: c.stdout, stderr: c.stderr }).toEqual({ exit: a.exitCode, stdout: a.stdout, @@ -911,7 +917,14 @@ describe('3.5 instructions apply --change is always the gate', () => { const c = await runCospec(['instructions', 'apply', '--change', '1foo', ...flag], root) const a = await runCospec(['apply', '1foo', ...flag], root) expect(a.exitCode).toBe(1) - expect(a.stderr).toContain("unknown change '1foo'") + // apply looks `1foo` up as the binary does (cli-surface-parity 16.8), and + // its gate refuses the name outside cospec's grammar: meta/name-kebab. + if (asJson) { + expect(a.stderr).toBe('') + const doc = JSON.parse(a.stdout) as { items: { id: string; issues: { rule: string }[] }[] } + expect(doc.items[0]?.id).toBe('1foo') + expect(doc.items[0]?.issues.map((i) => i.rule)).toContain('meta/name-kebab') + } else expect(a.stdout).toContain('meta/name-kebab') expect(streams(c, root)).toEqual(streams(a, root)) }, 30_000) } diff --git a/apps/cli/test/contract/validation-parity.test.ts b/apps/cli/test/contract/validation-parity.test.ts index e44f94cb..ef83b98e 100644 --- a/apps/cli/test/contract/validation-parity.test.ts +++ b/apps/cli/test/contract/validation-parity.test.ts @@ -20,6 +20,7 @@ import { dirname, join } from 'node:path' import { readRetireCapabilitiesMarker, type MarkerRead } from '../../src/core/change-metadata.ts' import { parseDeltaSpec } from '../../src/core/deltas.ts' import { rebuildSpec } from '../../src/core/rebuilt-spec.ts' +import { respellRemedies } from '../../src/core/remedies.ts' import { cleanupAll, cospec, @@ -675,7 +676,10 @@ describe('1. each lane keeps its own severities', () => { const relayed = all .filter((i) => i.rule === 'openspec/validate') .map((i) => `${i.level} ${i.message}`) - expect(relayed.toSorted()).toEqual(bin.map((i) => `${i.level} ${i.message}`).toSorted()) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + expect(relayed.toSorted()).toEqual( + bin.map((i) => `${i.level} ${respellRemedies(i.message)}`).toSorted(), + ) // cospec adds its classification note and nothing else: no cospec-typed rule // family runs on this lane. expect(all.filter((i) => i.rule !== 'openspec/validate').map((i) => i.rule)).toEqual([ @@ -1148,7 +1152,8 @@ describe('5.2 a delegated finding survives where its cospec twin is silent', () ] const { report } = await cospecValidate(root, 'empty-section', ['--fast']) expect(byRule(report, 'archive/no-ops')).toEqual([]) - for (const d of delegated) expect(messages(report)).toContain(d.message) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + for (const d of delegated) expect(messages(report)).toContain(respellRemedies(d.message)) }) test('entries 8 and 9: under --fast both cross-section ERRORs are kept', async () => { @@ -1623,6 +1628,21 @@ The system SHALL render a widget twice. - **THEN** a widget is rendered again ` +/** A requirement header holding `"`, duplicated: the binary quotes it inside its own quotes. */ +const QUOTED_REQUIREMENT = `### Requirement: Widget "quoted" name + +The system SHALL name a quoted widget. + +#### Scenario: Name it + +- **WHEN** a caller names a widget +- **THEN** the widget is named +` + +const LIVING_DUPLICATE_QUOTED = `${LIVING} +${QUOTED_REQUIREMENT} +${QUOTED_REQUIREMENT}` + const strayUnderPurpose = (stray: string): string => LIVING.replace('## Purpose\n\n', `## Purpose\n\n${stray}\n\n`) @@ -1667,6 +1687,31 @@ describe('12. a structurally invalid living spec is refused at pre-flight', () = }) } + // cli-surface-parity row 11.1: the per-line dedupe reads a header holding + // `"`, which the narrowed `[^"\n]*` pattern missed, reporting one defect twice. + test('12.5 a duplicated quoted requirement header is reported once, by cospec', async () => { + const root = mkTempRepo({ git: true }) + buildFeat( + root, + 'living-dup-quoted', + { 'widgets/spec.md': MODIFIED_CACHING }, + { living: LIVING_DUPLICATE_QUOTED }, + ) + const delegated = binaryOne( + await binaryIssues(root, 'living-dup-quoted'), + 'target spec is structurally invalid', + ) + expect(delegated.level).toBe('INFO') + expect(delegated.message).toContain('Widget "quoted" name') + const { report, exitCode } = await cospecValidate(root, 'living-dup-quoted') + const found = byRule(report, 'archive/target-invalid') + expect(found).toHaveLength(1) + expect(found[0]?.level).toBe('ERROR') + expect(messages(report)).not.toContain(respellRemedies(delegated.message)) + expect(relayedMessages(report).filter((m) => m.includes('structurally invalid'))).toEqual([]) + expect(exitCode).toBe(1) + }) + test('12.4 a fenced requirement header outside ## Requirements is not a defect', async () => { const build = (root: string): void => buildFeat( @@ -1716,7 +1761,10 @@ describe('13. the legacy lane relays each round-2 shape at the binary level', () const relayed = all .filter((i) => i.rule === 'openspec/validate') .map((i) => `${i.level} ${i.message}`) - expect(relayed.toSorted()).toEqual(bin.map((i) => `${i.level} ${i.message}`).toSorted()) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + expect(relayed.toSorted()).toEqual( + bin.map((i) => `${i.level} ${respellRemedies(i.message)}`).toSorted(), + ) expect(all.filter((i) => i.rule !== 'openspec/validate').map((i) => i.rule)).toEqual([ 'meta/legacy-schema', ]) @@ -2261,7 +2309,10 @@ describe('18. the legacy lane relays each round-3 shape at the binary level', () const relayed = all .filter((i) => i.rule === 'openspec/validate') .map((i) => `${i.level} ${i.message}`) - expect(relayed.toSorted()).toEqual(bin.map((i) => `${i.level} ${i.message}`).toSorted()) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + expect(relayed.toSorted()).toEqual( + bin.map((i) => `${i.level} ${respellRemedies(i.message)}`).toSorted(), + ) expect(all.filter((i) => i.rule !== 'openspec/validate').map((i) => i.rule)).toEqual([ 'meta/legacy-schema', ]) @@ -3094,7 +3145,10 @@ describe('23. the legacy lane relays each round-4 shape at the binary level', () const relayed = all .filter((i) => i.rule === 'openspec/validate') .map((i) => `${i.level} ${i.message}`) - expect(relayed.toSorted()).toEqual(bin.map((i) => `${i.level} ${i.message}`).toSorted()) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + expect(relayed.toSorted()).toEqual( + bin.map((i) => `${i.level} ${respellRemedies(i.message)}`).toSorted(), + ) expect(all.filter((i) => i.rule !== 'openspec/validate').map((i) => i.rule)).toEqual([ 'meta/legacy-schema', ]) @@ -3684,7 +3738,10 @@ describe('30. the legacy lane relays each round-5 shape at the binary level', () const relayed = all .filter((i) => i.rule === 'openspec/validate') .map((i) => `${i.level} ${i.message}`) - expect(relayed.toSorted()).toEqual(bin.map((i) => `${i.level} ${i.message}`).toSorted()) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + expect(relayed.toSorted()).toEqual( + bin.map((i) => `${i.level} ${respellRemedies(i.message)}`).toSorted(), + ) expect(all.filter((i) => i.rule !== 'openspec/validate').map((i) => i.rule)).toEqual([ 'meta/legacy-schema', ]) @@ -4132,7 +4189,10 @@ describe('35. the legacy lane relays each round-6 shape at the binary level', () const relayed = all .filter((i) => i.rule === 'openspec/validate') .map((i) => `${i.level} ${i.message}`) - expect(relayed.toSorted()).toEqual(bin.map((i) => `${i.level} ${i.message}`).toSorted()) + // Relayed with each allowlisted remedy spelled through cospec (cli-surface-parity). + expect(relayed.toSorted()).toEqual( + bin.map((i) => `${i.level} ${respellRemedies(i.message)}`).toSorted(), + ) expect(all.filter((i) => i.rule !== 'openspec/validate').map((i) => i.rule)).toEqual([ 'meta/legacy-schema', ]) @@ -4171,6 +4231,58 @@ function doubleReports(all: readonly ReportIssue[]): string[] { return doubles } +// --- 36. the deltas/scenario-depth masked-view exception is proven ------------------------ +// +// `rules/views.ts` lets `deltas/scenario-depth` read the comment-masked view +// (its one exception in the view-enumeration test, `views.test.ts`) because +// reading the verbatim view would refuse what the binary archives. This row is +// that proof, cited by name from both: a delta whose only mis-depth scenario +// sits inside an HTML comment is archived by the binary, and cospec raises no +// `deltas/scenario-depth` for it. + +/** + * A delta whose only `### Scenario:` (one `#` short) is inside an HTML comment + * ahead of its requirement, where the binary's archive skips it as a header of + * no requirement rather than splitting one. + */ +const COMMENTED_MISDEPTH_SCENARIO = `## ADDED Requirements + + + +### Requirement: Widget sizing + +The system SHALL size a widget. + +#### Scenario: Size a widget + +- **WHEN** a caller sizes a widget +- **THEN** the widget is sized +` + +describe('36. the scenario-depth masked-view exception', () => { + const build = (root: string): void => + buildFeat(root, 'commented-misdepth', { 'widgets/spec.md': COMMENTED_MISDEPTH_SCENARIO }) + + test('36.1 commented mis-depth scenario: the binary archives it and cospec raises no deltas/scenario-depth', async () => { + const archived = await binaryArchive(build, 'commented-misdepth') + expect(archived.exitCode).toBe(0) + expect(archived.moved).toBe(true) + const root = mkTempRepo({ git: true }) + build(root) + const { report, exitCode } = await cospecValidate(root, 'commented-misdepth') + expect(byRule(report, 'deltas/scenario-depth')).toEqual([]) + expect(problems(report)).toEqual([]) + expect(exitCode).toBe(0) + // The counterfactual: the verbatim view reads the commented line, so a + // scenario-depth read there would refuse what the binary just archived. + const path = 'specs/widgets/spec.md' + const verbatim = parseDeltaSpec(COMMENTED_MISDEPTH_SCENARIO, path, 'widgets') + expect(verbatim.scenarioDepthIssues.map((d) => d.header)).toEqual(['Scenario: Shallow']) + }) +}) + describe('19. sweep', () => { test('19.1 no report in this file carries one defect twice', () => { expect(REPORTS.length).toBeGreaterThan(100) diff --git a/apps/cli/test/integration/completion.test.ts b/apps/cli/test/integration/completion.test.ts index 97e8f13c..f6142d06 100644 --- a/apps/cli/test/integration/completion.test.ts +++ b/apps/cli/test/integration/completion.test.ts @@ -9,6 +9,8 @@ // `mise run check` must stay green on a minimal CI image. import { afterAll, describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { cleanupAll, cospec, mkTempRepo, writeFiles } from '../fixtures/support.ts' import { authorCi } from './support.ts' @@ -194,3 +196,77 @@ describe('cospec __complete (hidden dynamic completion source)', () => { ) }) }) + +// Verification 9.2: the generated scripts complete schema names where the +// binary's own scripts do — every `--schema` value a table row declares and the +// first positional of `schema which|validate|fork` — from `cospec __complete +// schemas`, and none of them ever calls the `openspec` binary. +describe('generated scripts complete schema names from cospec __complete schemas', () => { + const SCHEMA_SLOTS: { words: string[]; label: string }[] = [ + { words: ['status', '--schema', ''], label: 'status --schema' }, + { words: ['templates', '--schema', ''], label: 'templates --schema' }, + { words: ['instructions', 'proposal', '--schema', ''], label: 'instructions --schema' }, + { words: ['schema', 'which', ''], label: 'schema which' }, + { words: ['schema', 'validate', ''], label: 'schema validate' }, + { words: ['schema', 'fork', ''], label: 'schema fork' }, + ] + + async function script(shell: string): Promise { + const res = await cospec(['completion', shell], { cwd: mkTempRepo() }) + expect(res.exitCode).toBe(0) + return res.stdout + } + + test('no generated script names the openspec binary', async () => { + for (const shell of ['bash', 'zsh', 'fish']) + expect(await script(shell)).not.toMatch(/\bopenspec\b/) + }) + + const bash = Bun.which('bash') === null ? test.skip : test + for (const slot of SCHEMA_SLOTS) + bash(`bash: ${slot.label} calls cospec __complete schemas`, async () => { + const body = await script('bash') + const words = ['cospec', ...slot.words].map((w) => `'${w}'`).join(' ') + const program = [ + body, + // A stub `cospec` records each call and answers one schema name. + 'cospec() { printf "%s\\n" "$*" >> "$CALLS"; printf "house\\tschema\\n"; }', + `COMP_WORDS=(${words})`, + `COMP_CWORD=${slot.words.length}`, + '_cospec', + 'printf "%s\\n" "${COMPREPLY[@]}"', + ].join('\n') + const calls = join(mkTempRepo(), 'calls') + const run = Bun.spawnSync(['bash', '-c', program], { env: { ...process.env, CALLS: calls } }) + expect(run.exitCode, new TextDecoder().decode(run.stderr)).toBe(0) + expect(new TextDecoder().decode(run.stdout).trim()).toBe('house') + expect(readFileSync(calls, 'utf8')).toBe('__complete schemas\n') + }) + + test('zsh: each slot is an arm calling _cospec_dynamic schemas', async () => { + const body = await script('zsh') + for (const command of ['status', 'templates', 'instructions']) + expect(body).toMatch( + new RegExp( + `\\n {4}${command}\\)\\n(?: {6}.*\\n)*? {6}\\[\\[ \\$prev == '--schema' \\]\\] && \\{ _cospec_dynamic schemas; return \\}`, + ), + ) + for (const sub of ['which', 'validate', 'fork']) + expect(body).toContain( + ` 'schema ${sub}')\n (( subpos == 0 )) && { _cospec_dynamic schemas; return }\n`, + ) + }) + + test('fish: each slot completes from cospec __complete schemas', async () => { + const body = await script('fish') + const source = '"(cospec __complete schemas 2>/dev/null | cut -f1)"' + for (const command of ['status', 'templates', 'instructions']) + expect(body).toContain( + `complete -c cospec -n '__fish_seen_subcommand_from ${command}' -l schema -x -a ${source}`, + ) + for (const sub of ['which', 'validate', 'fork']) + expect(body).toContain( + `complete -c cospec -n '__fish_seen_subcommand_from schema; and __fish_seen_subcommand_from ${sub}' -a ${source}`, + ) + }) +}) diff --git a/apps/cli/test/integration/pack-standalone.test.ts b/apps/cli/test/integration/pack-standalone.test.ts index d1bf0155..d852fccb 100644 --- a/apps/cli/test/integration/pack-standalone.test.ts +++ b/apps/cli/test/integration/pack-standalone.test.ts @@ -160,15 +160,12 @@ describe('standalone pack smoke (bun-less)', () => { ).toBe(true) // 4. Run on Node only: version, then real subcommands (dispatch + embedded - // canon, no wrapped openspec call, no bun). + // canon, then the wrapped openspec call, no bun). const ver = run([bin, '--version'], consumer, path) expect(ver.code, ver.stderr).toBe(0) expect(ver.stdout.trim()).toBe(version) const target = mkTempRepo({ git: true }) - const list = run([bin, 'list'], target, path, emptyMachineStateEnv()) - expect(list.code, list.stderr).toBe(0) - expect(list.stdout).toContain('No active changes') // `init` is the README quickstart and exercises the embedded canon (the // compiled binary has no canon/ directory on disk — a regression here means @@ -191,6 +188,12 @@ describe('standalone pack smoke (bun-less)', () => { expect(created.code, created.stderr).toBe(0) expect(existsSync(join(target, 'openspec/changes/smoke-change/.openspec.yaml'))).toBe(true) + // `list` makes one wrapped `list --json` call for its rows and their order + // (cli-surface-parity), so it too runs the full bun-less wrapped-call path. + const list = run([bin, 'list'], target, path, emptyMachineStateEnv()) + expect(list.code, list.stderr).toBe(0) + expect(list.stdout).toContain('smoke-change') + // `config`, `completion`, and `feedback` must dispatch from the compiled // binary too (not just from `bun run src/index.ts`) — literal `import()` // bundling is the trap that silently drops a command module (module diff --git a/apps/cli/test/unit/cli.test.ts b/apps/cli/test/unit/cli.test.ts index 7b552b0b..eacbe4f7 100644 --- a/apps/cli/test/unit/cli.test.ts +++ b/apps/cli/test/unit/cli.test.ts @@ -250,9 +250,9 @@ describe('cli dispatcher: help renders from the command table', () => { expect(init.out).toContain('--no-animation') // An alias flag is an offered flag: upstream's `init --help` lists `--tools`. expect(init.out).toMatch(/^ {2}--tools +OpenSpec's spelling of --harness/m) - const list = await dispatch(['list', '--help']) - expect(list.out).not.toContain('--sort') - expect(list.out).toContain('--changes') + const archive = await dispatch(['archive', '--help']) + expect(archive.out).not.toContain('--no-validate') + expect(archive.out).toContain('--skip-specs') }) test('a row with subcommands lists them with their flags; a subcommand has its own help', async () => { @@ -287,9 +287,9 @@ describe('cli dispatcher: table rows parse before the module loads', () => { }) test('a pending flag is refused as not supported yet', async () => { - const r = await dispatch(['validate', '--type', 'change', 'x']) + const r = await dispatch(['archive', 'x', '--no-validate']) expect(r.code).toBe(1) - expect(r.err).toBe("cospec validate: '--type' is not supported yet\n") + expect(r.err).toBe("cospec archive: '--no-validate' is not supported yet\n") }) test('a value-taking flag with no value is refused', async () => { @@ -379,13 +379,16 @@ describe("cli dispatcher: a value-taking flag's space-form value is never interc for (const [argv, err] of [ // A help flag or a global there is the value: the pending flag is refused // with its value consumed, never help, never absorbed. - [['list', '--sort', '--help'], "cospec list: '--sort' is not supported yet\n"], - [['list', '--sort', '--json'], "cospec list: '--sort' is not supported yet\n"], - [['validate', '--type', '--store'], "cospec validate: '--type' is not supported yet\n"], + [['init', '--language', '--help'], "cospec init: '--language' is not supported yet\n"], + [['init', '--language', '--json'], "cospec init: '--language' is not supported yet\n"], + [['init', '--profile', '--store'], "cospec init: '--profile' is not supported yet\n"], // Upstream's program level takes `--no-color` out first, wherever it sits // before the first `--`: the flag takes the next token or has none. - [['list', '--sort', '--no-color'], "cospec list: option '--sort ' argument missing\n"], - [['list', '--sort', '--no-color', 'x'], "cospec list: '--sort' is not supported yet\n"], + [ + ['init', '--language', '--no-color'], + "cospec init: option '--language ' argument missing\n", + ], + [['init', '--language', '--no-color', 'x'], "cospec init: '--language' is not supported yet\n"], [['list', '--store', '--no-color'], "cospec list: option '--store ' argument missing\n"], // Past a `--` taken as a value the program level has stopped: both tokens // are the command's unknown options. diff --git a/apps/cli/test/unit/commands/apply.test.ts b/apps/cli/test/unit/commands/apply.test.ts index cd5d147d..eb5b8a0e 100644 --- a/apps/cli/test/unit/commands/apply.test.ts +++ b/apps/cli/test/unit/commands/apply.test.ts @@ -1,9 +1,10 @@ -// Apply-gate tests. Every path here resolves before step 5's `openspec +// Apply-gate tests. Every gate path here resolves before step 5's `openspec // instructions apply` call (blocked/soft-blocked/validation-error/unknown), so -// no wrapped binary is spawned. The clear-gate path is covered by lifecycle.test.ts. +// no wrapped binary is spawned; only the early-exit rows' two failed wrapped +// calls spawn it. The clear-gate path is covered by lifecycle.test.ts. import { afterAll, describe, expect, test } from 'bun:test' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -143,3 +144,75 @@ describe('apply gate', () => { expect(r.out).toContain('proposal/sections') }) }) + +// Verification 8.2 (design D10): every early exit under `--json` is exactly one +// `{status: [{severity, code, message, fix?}]}` document on stdout, nothing on +// stderr, exit 1 — the code the binary's `instructions apply` reports for the +// same lookups. +describe('apply early exits under --json', () => { + /** The one failure document `out` must be, its `status[0]`. */ + function oneDocument(r: { code: number; out: string; err: string }): Record { + expect(r.code).toBe(1) + expect(r.err).toBe('') + const doc = JSON.parse(r.out) as { status: Record[] } + expect(Object.keys(doc)).toEqual(['status']) + expect(doc.status).toHaveLength(1) + const [entry] = doc.status + expect(Object.keys(entry!).filter((k) => k !== 'fix')).toEqual(['severity', 'code', 'message']) + expect(entry).toMatchObject({ severity: 'error', code: 'change_error' }) + expect(r.out).toBe(`${JSON.stringify(doc, null, 2)}\n`) + return entry! + } + + const json = (cwd: string, args: string[]) => ctx(cwd, args, { json: true, command: 'apply' }) + + test('no openspec/ directory', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'cospec-uninit-')) + roots.push(cwd) + const r = await withEmptyMachineState(() => runCmd(applyRun, json(cwd, ['foo']))) + expect(String(oneDocument(r).message)).toContain('no openspec/ directory') + }) + + test('an unknown change, with its suggestion folded into the message', async () => { + const cwd = repo() + writeChange(cwd, 'add-widget', 'ci') + const r = await withEmptyMachineState(() => runCmd(applyRun, json(cwd, ['add-widgets']))) + expect(oneDocument(r).message).toBe("unknown change 'add-widgets'. Did you mean 'add-widget'?") + }) + + test('an unknown change with no suggestion', async () => { + const cwd = repo() + const r = await withEmptyMachineState(() => runCmd(applyRun, json(cwd, ['nope']))) + expect(oneDocument(r).message).toBe("unknown change 'nope'") + }) + + test('a failed legacy delegation', async () => { + const cwd = repo() + // A project schema that exists (so the change is legacy) but does not load. + const dir = join(cwd, 'openspec', 'schemas', 'broken') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'schema.yaml'), 'name: broken\n') + writeChange(cwd, 'legacy', 'broken', { 'proposal.md': LITE_PROPOSAL }) + const r = await withEmptyMachineState(() => runCmd(applyRun, json(cwd, ['legacy']))) + // The binary's own refusal is the answer (verification 16.11), not the wrapper's. + const message = String(oneDocument(r).message) + expect(message).toStartWith('Invalid schema at ') + expect(message).toContain(join('schemas', 'broken', 'schema.yaml')) + }) + + test('a failed step-5 call', async () => { + // Every gate step clears, and the wrapped `instructions apply` then fails: + // the root carries no `ci` schema for the binary to resolve. + const cwd = mkdtempSync(join(tmpdir(), 'cospec-noschema-')) + roots.push(cwd) + mkdirSync(join(cwd, 'openspec', 'changes', 'archive'), { recursive: true }) + writeChange(cwd, 'c', 'ci', { + 'proposal.md': LITE_PROPOSAL, + 'blocking-changes.md': EMPTY_BLOCKERS, + 'tasks.md': DONE_TASKS, + }) + const r = await withEmptyMachineState(() => runCmd(applyRun, json(cwd, ['c']))) + // The binary's own refusal is the answer (verification 16.11), not the wrapper's. + expect(String(oneDocument(r).message)).toStartWith("Unknown schema 'ci'.") + }) +}) diff --git a/apps/cli/test/unit/commands/commands.test.ts b/apps/cli/test/unit/commands/commands.test.ts index ee2ab01a..2de11624 100644 --- a/apps/cli/test/unit/commands/commands.test.ts +++ b/apps/cli/test/unit/commands/commands.test.ts @@ -18,7 +18,6 @@ import { cospecSchemaInstalled, run as newRun, slugify, - userSchemasDir, wrappedNewReason, } from '../../../src/commands/new.ts' import { @@ -28,6 +27,7 @@ import { run as statusRun, } from '../../../src/commands/status.ts' import { run as validateRun } from '../../../src/commands/validate.ts' +import { userSchemasDir } from '../../../src/core/change-metadata.ts' import { commandRow, parseCommandArgs } from '../../../src/core/command-table.ts' import { withEmptyMachineState } from '../../fixtures/support.ts' import { @@ -593,9 +593,10 @@ describe('status --all (OpenSpec 1.11 parity)', () => { }) const r = await runCmd(statusRun, ctx(cwd, ['--all'], { json: true, command: 'status' })) expect(r.code).toBe(0) - const parsed = JSON.parse(r.out) as { changes: { change: string }[]; root: string } + const parsed = JSON.parse(r.out) as { changes: { change: string }[]; root: unknown } expect(parsed.changes.map((c) => c.change)).toEqual(['alpha', 'zeta']) - expect(parsed.root).toBe(realpathSync(cwd)) + // BREAKING (cli-surface-parity): the binary's {path, source} object, not a path string. + expect(parsed.root).toEqual({ path: realpathSync(cwd), source: 'nearest' }) }) test('single-change JSON shape is unchanged by the --all addition', async () => { @@ -611,7 +612,11 @@ describe('status --all (OpenSpec 1.11 parity)', () => { ) const swept = await runCmd(statusRun, ctx(cwd, ['--all'], { json: true, command: 'status' })) const sweptParsed = JSON.parse(swept.out) as { changes: unknown[] } - expect(sweptParsed.changes).toEqual([JSON.parse(single.out)]) + // A sweep entry is the single document without its top-level `root`, which + // the sweep carries once, as the binary's `status --all --json` does. + const { root, ...entry } = JSON.parse(single.out) as Record + expect(root).toEqual({ path: realpathSync(cwd), source: 'nearest' }) + expect(sweptParsed.changes).toEqual([entry]) expect(computeStatus(cwd, { id: 'c', dir, schema: 'ci' }).change).toBe('c') }) diff --git a/apps/cli/test/unit/commands/status.test.ts b/apps/cli/test/unit/commands/status.test.ts new file mode 100644 index 00000000..6210d789 --- /dev/null +++ b/apps/cli/test/unit/commands/status.test.ts @@ -0,0 +1,143 @@ +// `cospec status` next-step decision (design D4, verification 3.3). + +import { afterAll, describe, expect, test } from 'bun:test' +import { rmSync } from 'node:fs' + +import { + computeStatus, + resolveNext, + respelledUpstream, + run as statusRun, + upstreamFailure, +} from '../../../src/commands/status.ts' +import { respellRemedies } from '../../../src/core/remedies.ts' +import { ctx, makeRepo, writeChange } from './helpers.ts' + +const roots: string[] = [] +afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }) +}) + +function repo(): string { + const dir = makeRepo() + roots.push(dir) + return dir +} + +const s = (id: string, state: 'done' | 'ready' | 'blocked' | 'skipped') => ({ id, state }) + +describe('resolveNext (verification 3.3)', () => { + const required = new Set(['proposal', 'blocking-changes', 'specs', 'tasks']) + + test('the first ready artifact the change requires', () => { + const states = [s('proposal', 'done'), s('design', 'ready'), s('blocking-changes', 'ready')] + expect(resolveNext(states, required, 'a')).toBe( + 'cospec instructions blocking-changes --change a', + ) + }) + + test('the first ready optional artifact when no required one is ready', () => { + const states = [ + s('proposal', 'done'), + s('blocking-changes', 'blocked'), + s('design', 'ready'), + s('specs', 'blocked'), + s('tasks', 'blocked'), + ] + expect(resolveNext(states, required, 'a')).toBe('cospec instructions design --change a') + }) + + test('cospec apply once every required artifact is done, an optional one unwritten', () => { + const states = [ + s('proposal', 'done'), + s('blocking-changes', 'done'), + s('specs', 'done'), + s('design', 'ready'), + s('tasks', 'done'), + ] + expect(resolveNext(states, required, 'a')).toBe('cospec apply a') + }) + + test('a skipped specs counts as done', () => { + const states = [ + s('proposal', 'done'), + s('blocking-changes', 'done'), + s('specs', 'skipped'), + s('tasks', 'done'), + ] + expect(resolveNext(states, required, 'a')).toBe('cospec apply a') + }) + + test('nothing when every artifact is blocked', () => { + const states = [s('blocking-changes', 'blocked'), s('tasks', 'blocked')] + expect(resolveNext(states, new Set(['blocking-changes', 'tasks']), 'a')).toBeUndefined() + }) + + test("a cospec change's JSON next and its human Next: line are the same command", async () => { + const cwd = repo() + const dir = writeChange(cwd, 'alpha', 'feat', { 'proposal.md': '# p\n' }) + const status = computeStatus(cwd, { id: 'alpha', dir, schema: 'feat' }) + expect(status.next).toBe('cospec instructions blocking-changes --change alpha') + const out: string[] = [] + const write = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + out.push(chunk) + return true + }) as typeof process.stdout.write + try { + expect(await statusRun(ctx(cwd, ['--change', 'alpha'], { command: 'status' }))).toBe(0) + } finally { + process.stdout.write = write + } + expect(out.join('').endsWith(`Next: ${status.next}\n`)).toBe(true) + }) + + test('a skip_specs feat change reads its absent specs as skipped', () => { + const cwd = repo() + const dir = writeChange(cwd, 'skip', 'feat', { + 'proposal.md': '# p\n', + 'blocking-changes.md': '# d\n', + 'verification.md': '# v\n', + 'tasks.md': '## 1. W\n\n- [ ] 1.1 t\n', + }) + const status = computeStatus(cwd, { + id: 'skip', + dir, + schema: 'feat', + skipSpecs: true, + schemaVersion: 2, + }) + expect(status.next).toBe('cospec apply skip') + }) +}) + +describe('every relayed binary diagnostic is spelled cospec (verification 16.13)', () => { + const NOT_FOUND = + "Change 'todo' not found. No changes exist. Create one with: openspec new change " + const BARE = /(? { + const messages = upstreamFailure(failure)!.map((d) => d.message) + expect(messages).toEqual([respellRemedies(NOT_FOUND)]) + expect(messages[0]).not.toMatch(BARE) + expect(messages[0]).not.toBe(NOT_FOUND) + }) + + test('the --json relay respells status[] message and fix, singly and in the sweep', () => { + const want = { + ...failure.status[0], + message: respellRemedies(NOT_FOUND), + fix: respellRemedies(NOT_FOUND), + } + expect(respelledUpstream(failure)).toEqual({ status: [want] }) + const sweep = { changes: [{ changeName: 'todo', ...failure }], root: null } + expect(respelledUpstream(sweep)).toEqual({ + changes: [{ changeName: 'todo', status: [want] }], + root: null, + }) + expect(JSON.stringify(respelledUpstream(sweep))).not.toMatch(BARE) + }) +}) diff --git a/apps/cli/test/unit/commands/validate.test.ts b/apps/cli/test/unit/commands/validate.test.ts index 08b96c57..3ad9b169 100644 --- a/apps/cli/test/unit/commands/validate.test.ts +++ b/apps/cli/test/unit/commands/validate.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { mergeDelegated } from '../../../src/commands/validate.ts' +import { + erroredChange, + concurrencyBound, + mapPool, + mergeDelegated, + TARGET_INVALID, + TARGET_INVALID_HEAD, + TARGET_INVALID_LINE, +} from '../../../src/commands/validate.ts' import type { Issue } from '../../../src/core/rules/issue.ts' // mergeDelegated's DUPLICATE_CLASSES table drops a delegated (openspec/validate) @@ -132,15 +140,14 @@ describe('mergeDelegated: archive/target-invalid vs the pinned dry-run message', expect(mergeDelegated(native, delegated)).toEqual([...native, ...delegated]) }) - // Regression: the delegated regex used to backtrack the quoted header span - // against a trailing `[^\n]*` once per "line N: …" repetition (CodeQL - // js/redos, GHAS alert on this PR). A living spec's requirement/delta - // headers are attacker-controlled markdown, so a header containing several - // `".`-like substrings, repeated over many defect lines, made matching - // exponential in the number of lines. Bounding the quoted span to `[^"\n]*` - // (real header text never contains a literal quote) makes the match - // deterministic; this must stay fast no matter how many lines or how much - // punctuation the header carries. + // Regression: one regex over the whole message used to backtrack the quoted + // header span against a trailing `[^\n]*` once per "line N: …" repetition + // (CodeQL js/redos). A living spec's requirement/delta headers are + // attacker-controlled markdown, so a header holding several `".`-like + // substrings, repeated over many defect lines, made matching exponential in + // the number of lines. TARGET_INVALID now matches the head once and each + // line on its own, so no group repeats around the quoted span; this must + // stay fast no matter how many lines or how much punctuation a header holds. test('a pathological delegated message with quote-heavy headers resolves quickly', () => { const quotesPerLine = 6 const lines = 200 @@ -163,3 +170,207 @@ describe('mergeDelegated: archive/target-invalid vs the pinned dry-run message', expect(result).toEqual(delegated) }) }) + +/** + * Whether a regex source nests an unbounded quantifier (`*`, `+`, `{n,}`) + * inside a group that is itself unboundedly quantified — star height two, the + * shape behind every exponential-backtracking alert (CodeQL js/redos). It + * reads the source as text and never compiles it. + */ +function repeatsAQuantifiedGroup(source: string): boolean { + // One frame per open group: whether its body holds an unbounded quantifier. + const frames: boolean[] = [false] + // The atom a following quantifier applies to: a group's body flag, or false. + let atom: boolean | undefined + let i = 0 + while (i < source.length) { + const c = source[i]! + if (c === '\\') { + atom = false + i += 2 + } else if (c === '[') { + i++ + while (i < source.length && source[i] !== ']') i += source[i] === '\\' ? 2 : 1 + atom = false + i++ + } else if (c === '(') { + frames.push(false) + atom = undefined + i++ + if (source[i] === '?') { + i++ + if (source[i] === '<' && source[i + 1] !== '=' && source[i + 1] !== '!') + i = source.indexOf('>', i) + 1 + else i += source[i] === '<' ? 2 : 1 + } + } else if (c === ')') { + const body = frames.pop()! + frames[frames.length - 1] ||= body + atom = body + i++ + } else if (c === '*' || c === '+' || c === '?' || c === '{') { + let unbounded = c === '*' || c === '+' + if (c === '{') { + const close = source.indexOf('}', i) + // A `{` that opens no `{n}`/`{n,}`/`{n,m}` is a literal. + if (close === -1 || !/^\{\d+(?:,\d*)?$/.test(source.slice(i, close))) { + atom = false + i++ + continue + } + unbounded = source.slice(i, close).endsWith(',') + i = close + 1 + } else i++ + if (source[i] === '?') i++ + if (unbounded && atom === true) return true + if (unbounded) frames[frames.length - 1] = true + atom = undefined + } else { + atom = c === '|' || c === '^' || c === '$' ? undefined : false + i++ + } + } + return false +} + +describe('the target-invalid dedupe is linear (verification 11.2, 15.13)', () => { + /** + * The pattern before 74d5ea4, as text only — it is never compiled. A quoted + * span `[^\n]*"` before a required literal, inside a repeated group, + * backtracks exponentially in the number of lines on a message that ends in + * a line it cannot match. + */ + const PRE_FIX_SOURCE = + '^Archive would refuse this delta: (.+?): target spec is structurally invalid and cannot ' + + 'be updated until fixed:(?:\\nline \\d+: (?:Main spec contains delta header "[^\\n]*"\\.|' + + 'Requirement header "[^\\n]*" (?:duplicates the requirement declared on line \\d+\\.|' + + 'appears outside the main ## Requirements section\\.))[^\\n]*)+\\n?$' + + test('the star-height guard flags the pre-fix pattern and the textbook shapes', () => { + expect(repeatsAQuantifiedGroup(PRE_FIX_SOURCE)).toBe(true) + for (const shape of ['(a+)+', '(?:a|b*)*', '(?x[^y]*)+z', '((ab)*c){2,}', '(a{1,})+']) + expect({ shape, flagged: repeatsAQuantifiedGroup(shape) }).toEqual({ shape, flagged: true }) + for (const shape of [ + '(a+)', + '(?:ab)+', + '(a+){2}', + '[(a+)+]', + '\\(a+\\)+', + '(a+)?b*', + '(a+){x}', + ]) + expect({ shape, flagged: repeatsAQuantifiedGroup(shape) }).toEqual({ shape, flagged: false }) + }) + + test("neither of the per-line matcher's patterns repeats a quantified group", () => { + expect(repeatsAQuantifiedGroup(TARGET_INVALID_HEAD.source)).toBe(false) + expect(repeatsAQuantifiedGroup(TARGET_INVALID_LINE.source)).toBe(false) + }) + + /** The bound a linear matcher meets on the input below; a backtracking one never finishes. */ + const BOUND_MS = 100 + + /** 200 quote-heavy defect lines, then a line of a kind cospec's rule does not read. */ + function adversarial(): string { + const header = 'x".'.repeat(6) + 'x' + let message = + 'Archive would refuse this delta: widgets: target spec is structurally invalid and ' + + 'cannot be updated until fixed:' + for (let i = 0; i < 200; i++) + message += `\nline 9: Main spec contains delta header "${header}".` + return `${message}\nline 210: Some structural issue nobody expects.` + } + + test('the per-line matcher refuses the adversarial message well under the bound', () => { + const start = performance.now() + const hit = TARGET_INVALID.exec(adversarial()) + expect(hit).toBeNull() + expect(performance.now() - start).toBeLessThan(BOUND_MS) + }) + + test('the per-line matcher reads a quoted header and keys on the capability', () => { + const message = + 'Archive would refuse this delta: widgets: target spec is structurally invalid and ' + + 'cannot be updated until fixed:\nline 9: Requirement header ' + + '"### Requirement: Widget "quoted" name" duplicates the requirement declared on line 3. ' + + 'Requirement names must be unique so spec updates cannot discard one block while ' + + 'updating another.\n' + expect(TARGET_INVALID.exec(message)?.[1]).toBe('widgets') + // The narrowed span this replaced (`[^"\n]*`) cannot cross the header's own + // `"`, so it missed this line and the defect was reported twice. + const line = message.split('\n')[1]! + expect(TARGET_INVALID_LINE.test(line)).toBe(true) + expect( + /^line \d+: (?:Main spec contains delta header "[^"\n]*"\.|Requirement header "[^"\n]*" (?:duplicates the requirement declared on line \d+\.|appears outside the main ## Requirements section\.))/.test( + line, + ), + ).toBe(false) + }) +}) + +describe('the bulk validation pool (verification 7.7)', () => { + /** Eight stubbed validations; the most ever in flight at once, and the results. */ + async function run(bound: number): Promise<{ peak: number; results: number[] }> { + let inFlight = 0 + let peak = 0 + const results = await mapPool([0, 1, 2, 3, 4, 5, 6, 7], bound, async (n) => { + inFlight++ + peak = Math.max(peak, inFlight) + // Later items settle first, so the order is the pool's, not completion's. + await Bun.sleep(8 - n) + inFlight-- + return n * 10 + }) + return { peak, results } + } + + const cases: [string, string | undefined, NodeJS.ProcessEnv, number][] = [ + ['--concurrency 2', '2', {}, 2], + ['--concurrency 0', '0', {}, 6], + ['--concurrency abc', 'abc', {}, 6], + ['unset, OPENSPEC_CONCURRENCY=3', undefined, { OPENSPEC_CONCURRENCY: '3' }, 3], + ['all unset', undefined, {}, 6], + ] + for (const [label, flag, env, bound] of cases) + test(`${label}: bounded at ${bound}, results in input order`, async () => { + expect(concurrencyBound(flag, env)).toBe(bound) + const { peak, results } = await run(concurrencyBound(flag, env)) + expect(peak).toBe(bound) + expect(results).toEqual([0, 10, 20, 30, 40, 50, 60, 70]) + }) + + test('a bad OPENSPEC_CONCURRENCY falls back to the default; the flag outranks the env', () => { + expect(concurrencyBound(undefined, { OPENSPEC_CONCURRENCY: 'abc' })).toBe(6) + expect(concurrencyBound('4', { OPENSPEC_CONCURRENCY: '3' })).toBe(4) + expect(concurrencyBound('abc', { OPENSPEC_CONCURRENCY: '3' })).toBe(3) + }) +}) + +describe('a change whose validation throws (verification 16.3)', () => { + test("an errno failure is that change's meta/unreadable-artifact ERROR, naming the file", () => { + const error = Object.assign(new Error("EACCES: permission denied, open '/r/openspec/x.md'"), { + code: 'EACCES', + syscall: 'open', + path: '/r/openspec/x.md', + }) + expect(erroredChange('/r', 'c1', error)).toEqual({ + id: 'c1', + kind: 'change', + valid: false, + issues: [ + { + level: 'ERROR', + rule: 'meta/unreadable-artifact', + path: '.', + message: 'could not read openspec/x.md (EACCES)', + hint: 'fix the file permissions (or replace the entry with a readable file) and re-run', + }, + ], + }) + }) + + test('anything that is not an errno failure propagates', () => { + const error = new Error('boom') + expect(() => erroredChange('/r', 'c1', error)).toThrow(error) + }) +}) diff --git a/apps/cli/test/unit/core/change.test.ts b/apps/cli/test/unit/core/change.test.ts index 118ca0ce..8ddac0ea 100644 --- a/apps/cli/test/unit/core/change.test.ts +++ b/apps/cli/test/unit/core/change.test.ts @@ -1,10 +1,16 @@ import { afterAll, describe, expect, test } from 'bun:test' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { changeMetadataRefused, userSchemasDir } from '../../../src/core/change-metadata.ts' import { + changeLookupNameProblem, + changesDir, COSPEC_TYPES, + describeNestedChange, + findNestedChanges, + findNestedChangesIn, isCospecType, listChanges, readArchiveIndex, @@ -146,16 +152,32 @@ describe('listChanges / resolveChange', () => { expect(resolveChange(cwd, 'bare')?.schema).toBe('') }) - test('rejects non-kebab ids and path traversal', () => { + test('refuses what the binary refuses as a lookup name, before touching disk', () => { const cwd = makeRepo() makeChange(cwd, 'real', 'schema: feat\n') // `../changes/real` would join back onto an existing change dir, so the // guard must reject the traversal id before resolveChange touches disk. - for (const bad of ['../changes/real', '../../etc', 'a/b', '..', 'Cap', '-lead', 'trail-', '']) { + for (const bad of ['../changes/real', '../../etc', 'a/b', 'a\\b', '..', '.', '', 'a\0b']) { + expect(changeLookupNameProblem(bad)).toBeDefined() expect(resolveChange(cwd, bad)).toBeUndefined() } expect(resolveChange(cwd, 'real')?.schema).toBe('feat') }) + + test('looks a change up by its directory name, as the binary does (verification 16.7, 16.8)', () => { + const cwd = makeRepo() + makeChange(cwd, 'Add_Auth', 'schema: feat\n') + makeChange(cwd, '.hidden', 'schema: feat\n') + makeChange(cwd, 'archive', 'schema: feat\n') + writeFileSync(join(cwd, 'openspec/changes/todo'), 'not a change\n') + // A directory name outside the kebab grammar is still a change. + expect(resolveChange(cwd, 'Add_Auth')?.schema).toBe('feat') + // A hidden or reserved name is refused even when its directory exists. + expect(resolveChange(cwd, '.hidden')).toBeUndefined() + expect(resolveChange(cwd, 'archive')).toBeUndefined() + // A regular file is no change. + expect(resolveChange(cwd, 'todo')).toBeUndefined() + }) }) describe('readArchiveIndex', () => { @@ -210,3 +232,278 @@ describe('resolveSchema', () => { expect(res.isCospecType).toBe(false) }) }) + +describe('the namespace-folder detector (verification 4.5)', () => { + /** A root whose `config.yaml` names a project schema generating into subdirectories. */ + function detectorRepo(): string { + const cwd = makeRepo() + const schema = join(cwd, 'openspec', 'schemas', 'subdir', 'schema.yaml') + mkdirSync(dirname(schema), { recursive: true }) + writeFileSync( + schema, + [ + 'name: subdir', + 'version: 1', + 'artifacts:', + ' - id: rfc', + ' generates: rfc/proposal.md', + ' description: the rfc', + ' template: rfc.md', + ' - id: notes', + ' generates: notes/**/*.md', + ' description: notes', + ' template: notes.md', + '', + ].join('\n'), + ) + writeFileSync(join(cwd, 'openspec', 'config.yaml'), 'schema: subdir\n') + return cwd + } + + function put(cwd: string, rel: string, body = 'x\n'): void { + const path = join(changesDir(cwd), rel) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, body) + } + + const nestedOf = (cwd: string, name: string) => findNestedChangesIn(changesDir(cwd), name)?.nested + + test('each root marker makes a nested directory a change', () => { + const cwd = detectorRepo() + for (const marker of ['.openspec.yaml', 'proposal.md', 'tasks.md', 'design.md']) + put(cwd, `m-${marker.replace('.', '')}/c/${marker}`) + for (const marker of ['.openspec.yaml', 'proposal.md', 'tasks.md', 'design.md']) + expect(nestedOf(cwd, `m-${marker.replace('.', '')}`)).toEqual([ + `m-${marker.replace('.', '')}/c`, + ]) + }) + + test('a delta file only under specs/ is a change; a dot-file there is not', () => { + const cwd = detectorRepo() + put(cwd, 'delta/c/specs/widgets/spec.md') + put(cwd, 'dotspec/c/specs/.keep') + expect(nestedOf(cwd, 'delta')).toEqual(['delta/c']) + expect(nestedOf(cwd, 'dotspec')).toBeUndefined() + }) + + test("a schema output only is a change, at the schema's own subdirectory path", () => { + const cwd = detectorRepo() + put(cwd, 'rfc/c/rfc/proposal.md') + put(cwd, 'notes/c/notes/a/b.md') + put(cwd, 'wrong/c/rfc/other.md') + expect(nestedOf(cwd, 'rfc')).toEqual(['rfc/c']) + expect(nestedOf(cwd, 'notes')).toEqual(['notes/c']) + expect(nestedOf(cwd, 'wrong')).toBeUndefined() + }) + + test('a file of its own keeps a directory a change; a dot-file of its own does not', () => { + const cwd = detectorRepo() + put(cwd, 'own/README.md') + put(cwd, 'own/c/.openspec.yaml') + put(cwd, 'owndot/.DS_Store') + put(cwd, 'owndot/c/.openspec.yaml') + expect(nestedOf(cwd, 'own')).toBeUndefined() + expect(nestedOf(cwd, 'owndot')).toEqual(['owndot/c']) + }) + + test('depths one to three are searched, four is not', () => { + const cwd = detectorRepo() + put(cwd, 'd1/c/.openspec.yaml') + put(cwd, 'd2/a/c/.openspec.yaml') + put(cwd, 'd3/a/b/c/.openspec.yaml') + put(cwd, 'd4/a/b/c/d/.openspec.yaml') + expect(nestedOf(cwd, 'd1')).toEqual(['d1/c']) + expect(nestedOf(cwd, 'd2')).toEqual(['d2/a/c']) + expect(nestedOf(cwd, 'd3')).toEqual(['d3/a/b/c']) + expect(nestedOf(cwd, 'd4')).toBeUndefined() + }) + + test('a dot-directory, archive and a change-looking child are never descended', () => { + const cwd = detectorRepo() + put(cwd, '.hidden/c/.openspec.yaml') + put(cwd, 'archive/2026-01-01-x/c/.openspec.yaml') + put(cwd, 'dotchild/.c/.openspec.yaml') + put(cwd, 'shallow/c/.openspec.yaml') + put(cwd, 'shallow/c/deeper/.openspec.yaml') + expect(nestedOf(cwd, '.hidden')).toBeUndefined() + expect(nestedOf(cwd, 'archive')).toBeUndefined() + expect(nestedOf(cwd, 'dotchild')).toBeUndefined() + expect(nestedOf(cwd, 'shallow')).toEqual(['shallow/c']) + }) + + test('nested ids are sorted, and the explanation is upstream’s sentence', () => { + const cwd = detectorRepo() + put(cwd, 'two/zeta/.openspec.yaml') + put(cwd, 'two/eta/proposal.md') + const finding = findNestedChangesIn(changesDir(cwd), 'two')! + expect(finding).toEqual({ name: 'two', nested: ['two/eta', 'two/zeta'] }) + expect(describeNestedChange(finding)).toBe( + '"two" is not a change: it is a folder wrapping openspec/changes/two/eta/, openspec/changes/two/zeta/. ' + + 'A change must be a directory directly under openspec/changes/, so those nested directories are ' + + 'invisible to OpenSpec while the folder around them is reported as a change. Nested paths are ' + + 'supported under openspec/specs/ only. Rename each nested change to a flat name (for example "two-eta").', + ) + expect(findNestedChanges(changesDir(cwd), ['two', 'missing']).map((f) => f.name)).toEqual([ + 'two', + ]) + }) + + test('a change with a root marker is never a namespace folder', () => { + const cwd = detectorRepo() + put(cwd, 'alpha/proposal.md') + put(cwd, 'alpha/sub/.openspec.yaml') + expect(nestedOf(cwd, 'alpha')).toBeUndefined() + }) + + const asRoot = process.getuid?.() === 0 + test.skipIf(asRoot)('an unreadable subdirectory reads as empty and nothing throws', () => { + const cwd = detectorRepo() + put(cwd, 'locked/c/.openspec.yaml') + put(cwd, 'locked/d/.openspec.yaml') + const locked = join(changesDir(cwd), 'locked', 'c') + chmodSync(locked, 0o000) + try { + expect(nestedOf(cwd, 'locked')).toEqual(['locked/d']) + chmodSync(join(changesDir(cwd), 'locked'), 0o000) + expect(nestedOf(cwd, 'locked')).toBeUndefined() + } finally { + chmodSync(join(changesDir(cwd), 'locked'), 0o755) + chmodSync(locked, 0o755) + } + }) +}) + +describe('listChanges drops dot-directories', () => { + test('as the binary enumerates active changes', () => { + const cwd = makeRepo() + makeChange(cwd, 'alpha', 'schema: feat\n') + makeChange(cwd, '.hidden', 'schema: feat\n') + expect(listChanges(cwd).map((c) => c.id)).toEqual(['alpha']) + }) +}) + +describe('the user schema tier (verification 10.1)', () => { + test("userSchemasDir is the binary's getGlobalDataDir plus schemas in every case", () => { + expect(userSchemasDir({ XDG_DATA_HOME: '/x' }, '/h', 'darwin')).toBe('/x/openspec/schemas') + expect(userSchemasDir({ XDG_DATA_HOME: '/x' }, '/h', 'linux')).toBe('/x/openspec/schemas') + expect(userSchemasDir({ XDG_DATA_HOME: '' }, '/h', 'linux')).toBe( + '/h/.local/share/openspec/schemas', + ) + expect(userSchemasDir({}, '/h', 'darwin')).toBe('/h/.local/share/openspec/schemas') + expect(userSchemasDir({}, '/h', 'linux')).toBe('/h/.local/share/openspec/schemas') + expect(userSchemasDir({ LOCALAPPDATA: '/l' }, '/h', 'win32')).toBe( + join('/l', 'openspec', 'schemas'), + ) + expect(userSchemasDir({ LOCALAPPDATA: '' }, '/h', 'win32')).toBe( + join('/h', 'AppData', 'Local', 'openspec', 'schemas'), + ) + expect(userSchemasDir({}, '/h', 'win32')).toBe( + join('/h', 'AppData', 'Local', 'openspec', 'schemas'), + ) + }) + + test('change.ts, new.ts and change-metadata.ts compute the directory in one place', () => { + const src = (rel: string) => readFileSync(join(import.meta.dir, '../../../src', rel), 'utf8') + const definitions = ['core/change.ts', 'commands/new.ts', 'core/change-metadata.ts'].filter( + (rel) => /function userSchemasDir\b/.test(src(rel)), + ) + expect(definitions).toEqual(['core/change-metadata.ts']) + for (const rel of ['core/change.ts', 'commands/new.ts']) + expect(src(rel)).toMatch( + /import \{[^}]*\buserSchemasDir\b[^}]*\} from '(?:\.\.\/core|\.)\/change-metadata\.ts'/, + ) + expect(src('core/change.ts')).not.toContain("'.config'") + }) + + test('resolveSchema classifies a schema under XDG_DATA_HOME as user, never one under ~/.config', () => { + const cwd = makeRepo() + const data = mkdtempSync(join(tmpdir(), 'cospec-data-')) + const home = mkdtempSync(join(tmpdir(), 'cospec-home-')) + roots.push(data, home) + const write = (dir: string, name: string) => { + mkdirSync(join(dir, name), { recursive: true }) + writeFileSync(join(dir, name, 'schema.yaml'), `name: ${name}\n`) + } + write(join(data, 'openspec', 'schemas'), 'house-style') + write(join(home, '.config', 'openspec', 'schemas'), 'config-style') + const saved = { XDG_DATA_HOME: process.env.XDG_DATA_HOME, HOME: process.env.HOME } + process.env.XDG_DATA_HOME = data + process.env.HOME = home + try { + expect(resolveSchema(cwd, 'house-style')).toMatchObject({ kind: 'legacy', source: 'user' }) + expect(resolveSchema(cwd, 'config-style').kind).toBe('unknown') + } finally { + for (const [key, value] of Object.entries(saved)) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }) +}) + +describe("changeMetadataRefused mirrors the binary's readChangeMetadata (verification 17.3)", () => { + const listed = () => ['chore', 'feat'] + const changeWith = (yaml: string | null): string => { + const cwd = makeRepo() + makeChange(cwd, 'c1', yaml) + return join(cwd, 'openspec', 'changes', 'c1') + } + + test('a valid file, or none, is not refused', () => { + expect(changeMetadataRefused(changeWith('schema: chore\ncreated: 2026-09-01\n'), listed)).toBe( + false, + ) + expect(changeMetadataRefused(changeWith(null), listed)).toBe(false) + }) + + test('each ChangeMetadataSchema failure is refused', () => { + for (const extra of [ + 'created: notadate', + 'skip_specs: "yes"', + 'retire_capabilities: 1', + 'goal: ""', + 'affected_areas: [""]', + 'initiative: {store: s1}', + 'initiative: {store: S1, id: i1}', + 'initiative: {store: s1, id: i1, extra: x}', + ]) + expect({ + extra, + refused: changeMetadataRefused(changeWith(`schema: chore\n${extra}\n`), listed), + }).toEqual({ extra, refused: true }) + }) + + test('a file that is not YAML, or names an unlisted schema, is refused', () => { + expect(changeMetadataRefused(changeWith('schema: [chore\n'), listed)).toBe(true) + expect(changeMetadataRefused(changeWith('schema: house-style\n'), listed)).toBe(true) + }) + + test.skipIf(process.getuid?.() === 0)('an unreadable file is refused', () => { + const dir = changeWith('schema: chore\n') + const file = join(dir, '.openspec.yaml') + chmodSync(file, 0o000) + try { + expect(changeMetadataRefused(dir, listed)).toBe(true) + } finally { + chmodSync(file, 0o644) + } + }) + + test("listed()'s own errno failure is refused; any other throw still escapes", () => { + const dir = changeWith('schema: chore\n') + const enotdir = Object.assign( + new Error("ENOTDIR: not a directory, scandir '/r/openspec/schemas'"), + { code: 'ENOTDIR' }, + ) + expect( + changeMetadataRefused(dir, () => { + throw enotdir + }), + ).toBe(true) + const boom = new Error('boom') + expect(() => + changeMetadataRefused(dir, () => { + throw boom + }), + ).toThrow(boom) + }) +}) diff --git a/apps/cli/test/unit/core/command-table.test.ts b/apps/cli/test/unit/core/command-table.test.ts index 07a38853..6d7b9a3b 100644 --- a/apps/cli/test/unit/core/command-table.test.ts +++ b/apps/cli/test/unit/core/command-table.test.ts @@ -73,23 +73,23 @@ describe('parseCommandArgs — the six ledger 1.5 cases', () => { }) test('a pending flag consumes its value and never leaks it into a positional', () => { - const r = refused('validate', ['--type', 'change', 'x']) - expect(r).toMatchObject({ kind: 'pending', surface: '--type', owner: 'cli-surface-parity' }) - expect(r.message).toBe("cospec validate: '--type' is not supported yet\n") + const r = refused('init', ['--profile', 'core', 'x', 'y']) + expect(r).toMatchObject({ kind: 'pending', surface: '--profile', owner: 'workflow-profiles' }) + expect(r.message).toBe("cospec init: '--profile' is not supported yet\n") - // `--bogus` is consumed as --sort's value, so the pending refusal wins. - expect(refused('list', ['--sort', '--bogus'])).toMatchObject({ + // `--bogus` is consumed as --language's value, so the pending refusal wins. + expect(refused('init', ['--language', '--bogus'])).toMatchObject({ kind: 'pending', - surface: '--sort', + surface: '--language', }) expect(refused('init', ['--language', 'fr', '.'])).toMatchObject({ kind: 'pending', surface: '--language', }) expect(refused('init', ['--language=fr'])).toMatchObject({ kind: 'pending' }) - expect(refused('status', ['--schema', 'custom'])).toMatchObject({ + expect(refused('init', ['--profile', 'custom'])).toMatchObject({ kind: 'pending', - surface: '--schema', + surface: '--profile', }) }) @@ -257,7 +257,7 @@ describe('parseCommandArgs — refusals', () => { expect(refused('list', ['--store-path', '/x', 'extra']).kind).toBe('too-many-arguments') expect(refused('list', ['--store-path=/x', 'extra']).kind).toBe('too-many-arguments') expect(refused('validate', ['--store-path', '/x', 'a', 'b']).kind).toBe('too-many-arguments') - expect(refused('list', ['--store-path', '/x', '--sort', 'name']).kind).toBe('pending') + expect(refused('archive', ['c', '--store-path', '/x', '--no-validate']).kind).toBe('pending') // Its value is consumed, so it never counts as a positional. expect(refused('validate', ['--store-path', '/x', 'a']).kind).toBe('store-path') }) @@ -282,11 +282,11 @@ describe('parseCommandArgs — refusals', () => { kind: 'missing-value', flag: '--change', }) - expect(refused('list', ['--sort', 'x', '--bogus'])).toMatchObject({ + expect(refused('init', ['--language', 'x', '--bogus'])).toMatchObject({ kind: 'pending', - surface: '--sort', + surface: '--language', }) - expect(refused('list', ['--bogus', '--sort', 'x'])).toMatchObject({ + expect(refused('init', ['--bogus', '--language', 'x'])).toMatchObject({ kind: 'unknown-option', option: '--bogus', }) @@ -294,7 +294,7 @@ describe('parseCommandArgs — refusals', () => { test('the recorded refusal outranks too many arguments', () => { expect(refused('list', ['a', '--bogus']).kind).toBe('unknown-option') - expect(refused('list', ['a', '--sort', 'x']).kind).toBe('pending') + expect(refused('init', ['a', 'b', '--language', 'x']).kind).toBe('pending') }) }) @@ -328,18 +328,11 @@ const EXPECTED_PENDING: [string, string, PendingOwner][] = [ ['init', '--profile', 'workflow-profiles'], ['init', '--copilot-cloud', 'github-copilot'], ['init', '--no-copilot-cloud', 'github-copilot'], - ['validate', '--type', 'cli-surface-parity'], - ['validate', '--report', 'cli-surface-parity'], - ['validate', '--concurrency', 'cli-surface-parity'], - ['status', '--schema', 'cli-surface-parity'], - ['list', '--sort', 'cli-surface-parity'], ['archive', '--no-validate', 'archive-and-sync-parity'], ['completion', 'install', 'completion-install'], ['completion', 'uninstall', 'completion-install'], ['completion', 'powershell', 'completion-install'], ['completion generate', 'powershell', 'completion-install'], - ['__complete', 'schemas', 'cli-surface-parity'], - ['__complete', 'archived-changes', 'cli-surface-parity'], ] function pendingSurfaces(row: CommandRow): [string, string, PendingOwner][] { @@ -378,18 +371,11 @@ describe('pending surfaces', () => { 'init --profile': ['--profile', 'core'], 'init --copilot-cloud': ['--copilot-cloud'], 'init --no-copilot-cloud': ['--no-copilot-cloud'], - 'validate --type': ['--type', 'change', 'x'], - 'validate --report': ['--report', 'full'], - 'validate --concurrency': ['--concurrency', '4'], - 'status --schema': ['--schema', 'custom'], - 'list --sort': ['--sort', 'name'], 'archive --no-validate': ['c', '--no-validate'], 'completion install': ['install', 'zsh', '--verbose'], 'completion uninstall': ['uninstall', '-y'], 'completion powershell': ['powershell'], 'completion generate powershell': ['generate', 'powershell'], - '__complete schemas': ['schemas'], - '__complete archived-changes': ['archived-changes'], } /** The pending refusal `command surface` gets, named on the row it was typed on. */ @@ -425,6 +411,7 @@ describe('pending surfaces', () => { '--specs', '--blocked', '--changes', + '--sort', ]) }) }) @@ -496,7 +483,8 @@ describe('table shape', () => { }) test('a table row refuses --store exactly when its module never reads it', () => { - const readsStore = /\bresolveRoot\(|\brunPassthrough\(|\bcallPassthrough\(|flags\.store\b/ + const readsStore = + /\bresolveRoot(?:OrDocument)?\(|\brunPassthrough\(|\bcallPassthrough\(|flags\.store\b/ for (const row of COMMAND_TABLE) { if (row.parse !== 'table') continue const file = row.name === '__complete' ? 'complete' : row.name diff --git a/apps/cli/test/unit/core/completions.test.ts b/apps/cli/test/unit/core/completions.test.ts index 78681f4b..640efdc6 100644 --- a/apps/cli/test/unit/core/completions.test.ts +++ b/apps/cli/test/unit/core/completions.test.ts @@ -124,12 +124,29 @@ describe('buildCompletionSpec — matches COMMAND_TABLE', () => { expect(spec.commands.find((c) => c.name === 'config')!.positional).toEqual([]) }) - test('dynamic flag values: status --change and instructions --change complete to changes', () => { + test('dynamic flag values: --change completes to changes, every --schema to schemas', () => { expect(spec.commands.find((c) => c.name === 'status')!.flagValues).toEqual({ '--change': 'changes', + '--schema': 'schemas', }) expect(spec.commands.find((c) => c.name === 'instructions')!.flagValues).toEqual({ '--change': 'changes', + '--schema': 'schemas', + }) + expect(spec.commands.find((c) => c.name === 'templates')!.flagValues).toEqual({ + '--schema': 'schemas', + }) + const declaring = spec.commands.filter((c) => c.flags.includes('--schema')).map((c) => c.name) + expect( + spec.commands.filter((c) => c.flagValues['--schema'] === 'schemas').map((c) => c.name), + ).toEqual(declaring) + }) + + test('schema which|validate|fork complete their first positional from schemas', () => { + expect(spec.commands.find((c) => c.name === 'schema')!.subcommandPositional).toEqual({ + which: ['schemas'], + validate: ['schemas'], + fork: ['schemas'], }) }) }) diff --git a/apps/cli/test/unit/core/errno.test.ts b/apps/cli/test/unit/core/errno.test.ts new file mode 100644 index 00000000..b3d76ed0 --- /dev/null +++ b/apps/cli/test/unit/core/errno.test.ts @@ -0,0 +1,71 @@ +// An errno failure a command lets escape (verification 16.10). + +import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test' + +import { answeringErrno, errnoMessage } from '../../../src/core/errno.ts' + +const eacces = (): Error => + Object.assign(new Error("EACCES: permission denied, scandir '/r/openspec/changes'"), { + code: 'EACCES', + syscall: 'scandir', + path: '/r/openspec/changes', + }) + +let written = '' +const spy = spyOn(process.stdout, 'write').mockImplementation((chunk) => { + written += String(chunk) + return true +}) +afterEach(() => { + written = '' +}) +afterAll(() => { + spy.mockRestore() +}) + +describe('answeringErrno', () => { + test("under --json an errno failure is the binary's one document, exit 1", async () => { + const code = await answeringErrno( + true, + { code: 'change_error', payload: { changes: [], root: null } }, + () => Promise.reject(eacces()), + ) + expect(code).toBe(1) + expect(JSON.parse(written)).toEqual({ + changes: [], + root: null, + status: [ + { + severity: 'error', + code: 'change_error', + message: "EACCES: permission denied, scandir '/r/openspec/changes'", + }, + ], + }) + }) + + test('in text mode, and for any other error, the failure propagates untouched', async () => { + const errno = eacces() + await expect( + answeringErrno(false, { code: 'validate_error' }, () => Promise.reject(errno)), + ).rejects.toBe(errno) + const other = Object.assign(new Error('no syscall'), { code: 'EACCES' }) + await expect( + answeringErrno(true, { code: 'validate_error' }, () => Promise.reject(other)), + ).rejects.toBe(other) + expect(written).toBe('') + }) + + test('a command that answers keeps its own exit code', async () => { + expect(await answeringErrno(true, { code: 'validate_error' }, () => Promise.resolve(3))).toBe(3) + }) +}) + +describe('errnoMessage', () => { + test('names an errno failure and nothing else', () => { + expect(errnoMessage(eacces())).toBe("EACCES: permission denied, scandir '/r/openspec/changes'") + expect(errnoMessage(new Error('plain'))).toBeUndefined() + expect(errnoMessage({ code: 'EACCES', syscall: 'open' })).toBeUndefined() + expect(errnoMessage(undefined)).toBeUndefined() + }) +}) diff --git a/apps/cli/test/unit/core/openspec.test.ts b/apps/cli/test/unit/core/openspec.test.ts index 5d28915e..e84d4111 100644 --- a/apps/cli/test/unit/core/openspec.test.ts +++ b/apps/cli/test/unit/core/openspec.test.ts @@ -11,6 +11,7 @@ import { localRoot, openspecApplyInstructions, openspecArtifactInstructions, + openspecBelow, openspecList, openspecPackageDir, openspecStatus, @@ -28,6 +29,22 @@ function result(partial: Partial): OpenspecResult { return { stdout: '', stderr: '', exitCode: 0, ...partial } } +describe('openspecBelow', () => { + test('a parseable version below the floor is below it', () => { + expect(openspecBelow('1.8.9', '1.9.0')).toBe(true) + expect(openspecBelow('1.0.0', '1.9.0')).toBe(true) + }) + test('the floor and anything above it are not below it', () => { + expect(openspecBelow('1.9.0', '1.9.0')).toBe(false) + expect(openspecBelow('1.13.1', '1.9.0')).toBe(false) + expect(openspecBelow('2.0.0-beta.1', '1.9.0')).toBe(false) + }) + test('an unparseable version is never judged too old', () => { + expect(openspecBelow('', '1.9.0')).toBe(false) + expect(openspecBelow('dev', '1.9.0')).toBe(false) + }) +}) + describe('satisfiesOpenspecRange', () => { test('accepts the floor, the pin, and everything up to the ceiling', () => { expect(satisfiesOpenspecRange('1.0.0')).toBe(true) // inclusive floor @@ -191,12 +208,20 @@ describe('wrapped calls against the real binary', () => { }, 30_000) test('openspecApplyInstructions returns state and contextFiles', async () => { - const apply = await openspecApplyInstructions(localRoot(cwd), 'try-it') + const answer = await openspecApplyInstructions(localRoot(cwd), 'try-it') + if (!('instructions' in answer)) throw new Error(JSON.stringify(answer)) + const apply = answer.instructions expect(apply.state).toBe('ready') expect(apply.progress).toEqual({ total: 2, complete: 1, remaining: 1 }) expect(apply.contextFiles.proposal?.[0]).toContain('proposal.md') }, 30_000) + test('openspecApplyInstructions answers a refusal with the failure document', async () => { + const answer = await openspecApplyInstructions(localRoot(cwd), 'no-such-change') + if (!('refused' in answer)) throw new Error(JSON.stringify(answer)) + expect(answer.refused.status[0]).toMatchObject({ severity: 'error', code: 'change_error' }) + }, 30_000) + test('openspecArtifactInstructions returns template and instruction', async () => { const artifact = await openspecArtifactInstructions(localRoot(cwd), 'proposal', 'try-it') expect(artifact.artifactId).toBe('proposal') diff --git a/apps/cli/test/unit/core/report.test.ts b/apps/cli/test/unit/core/report.test.ts index 05b38dd6..c64af713 100644 --- a/apps/cli/test/unit/core/report.test.ts +++ b/apps/cli/test/unit/core/report.test.ts @@ -8,6 +8,7 @@ import { renderJson, type ReportJson, summarize, + toFindings, } from '../../../src/core/report.ts' function issue(partial: Partial & Pick): Issue { @@ -150,3 +151,35 @@ describe('renderHuman', () => { expect(colored.includes('\x1b[')).toBe(true) }) }) + +describe('toFindings (--report findings)', () => { + const clean: ItemReport = { id: 'clean', kind: 'change', type: 'chore', valid: true, issues: [] } + const items = [...sample, clean] + + test("keeps only the items with issues under the binary's report object, inside version 1", () => { + const doc = toFindings( + { items, summary: { errors: 1 }, root: { path: '/r', source: 'nearest' } }, + 'all', + ) + expect(doc).toEqual({ + version: 1, + report: { + kind: 'validation-findings', + version: '1.0', + scope: 'all', + returnedItems: items.filter((i) => i.issues.length > 0).length, + totalItems: items.length, + }, + itemFindings: items.filter((i) => i.issues.length > 0), + summary: { errors: 1 }, + root: { path: '/r', source: 'nearest' }, + }) + }) + + test('a findings human report folds an issue-free change into the counts, as a valid spec is', () => { + const out = renderHuman(items, { noColor: true, findingsOnly: true }) + expect(out).not.toContain('clean') + expect(out).toContain('add-widget') + expect(renderHuman(items, { noColor: true })).toContain('clean') + }) +}) diff --git a/apps/cli/test/unit/core/upstream-keys.test.ts b/apps/cli/test/unit/core/upstream-keys.test.ts new file mode 100644 index 00000000..afcd9487 --- /dev/null +++ b/apps/cli/test/unit/core/upstream-keys.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test' + +import { mergeUpstream, rootOutput } from '../../../src/core/upstream-keys.ts' + +describe('mergeUpstream (design D3)', () => { + test('adds every upstream key cospec lacks and keeps every cospec key and value', () => { + const { value, collisions } = mergeUpstream( + { change: 'a', type: 'feat', gate: 'clear' }, + { changeName: 'a', schemaName: 'feat', isComplete: false }, + ) + expect(value).toEqual({ + change: 'a', + type: 'feat', + gate: 'clear', + changeName: 'a', + schemaName: 'feat', + isComplete: false, + }) + expect(collisions).toEqual([]) + }) + + test('a key both carry keeps cospec value and is listed as a collision when it differs', () => { + const { value, collisions } = mergeUpstream( + { version: 1, root: { path: '/r', source: 'nearest' }, same: 'x' }, + { version: '1.0', root: { path: '/r', source: 'nearest' }, same: 'x' }, + ) + expect(value).toEqual({ version: 1, root: { path: '/r', source: 'nearest' }, same: 'x' }) + expect(collisions).toEqual(['version']) + }) + + test('nested objects merge key by key, and a nested collision names its path', () => { + const { value, collisions } = mergeUpstream( + { summary: { errors: 1, totals: { items: 2 } } }, + { summary: { totals: { items: 3, passed: 1 }, byType: {} } }, + ) + expect(value).toEqual({ summary: { errors: 1, totals: { items: 2, passed: 1 }, byType: {} } }) + expect(collisions).toEqual(['summary.totals.items']) + }) + + test('arrays of objects merge by identity, whatever their order; unmatched upstream entries append', () => { + const { value, collisions } = mergeUpstream( + { + changes: [ + { change: 'b', artifacts: [{ id: 'proposal', done: true }] }, + { change: 'a', artifacts: [] }, + ], + }, + { + changes: [ + { changeName: 'a', schemaName: 'feat' }, + { + changeName: 'b', + artifacts: [ + { id: 'tasks', status: 'blocked' }, + { id: 'proposal', status: 'done' }, + ], + }, + { changeName: 'c', status: [{ code: 'change_error' }] }, + ], + }, + { + 'changes[]': { cospec: 'change', upstream: 'changeName' }, + 'changes[].artifacts[]': { cospec: 'id', upstream: 'id' }, + }, + ) + expect(value).toEqual({ + changes: [ + { + change: 'b', + artifacts: [ + { id: 'proposal', done: true, status: 'done' }, + { id: 'tasks', status: 'blocked' }, + ], + changeName: 'b', + }, + { change: 'a', artifacts: [], changeName: 'a', schemaName: 'feat' }, + { changeName: 'c', status: [{ code: 'change_error' }] }, + ], + }) + expect(collisions).toEqual([]) + }) + + test('an array with no identity is a whole value: cospec keeps its own', () => { + const { value, collisions } = mergeUpstream({ list: [1, 2] }, { list: [2, 1] }) + expect(value).toEqual({ list: [1, 2] }) + expect(collisions).toEqual(['list']) + }) + + test('the inputs are left unchanged', () => { + const cospec = { a: { b: 1 } } + const upstream = { a: { c: 2 } } + mergeUpstream(cospec, upstream) + expect(cospec).toEqual({ a: { b: 1 } }) + expect(upstream).toEqual({ a: { c: 2 } }) + }) +}) + +describe('rootOutput', () => { + test("is the binary's {path, source, store_id?} object", () => { + const local = { + base: '/r', + cwd: '/r', + storeArgs: [], + store: undefined, + source: 'nearest' as const, + } + expect(rootOutput(local)).toEqual({ path: '/r', source: 'nearest' }) + const store = { + ...local, + base: '/s', + store: 's1', + storeArgs: ['--store', 's1'], + source: 'store' as const, + } + expect(rootOutput(store)).toEqual({ path: '/s', source: 'store', store_id: 's1' }) + }) +}) diff --git a/apps/cli/test/unit/rules/meta.test.ts b/apps/cli/test/unit/rules/meta.test.ts index 143493c9..bcb29a4e 100644 --- a/apps/cli/test/unit/rules/meta.test.ts +++ b/apps/cli/test/unit/rules/meta.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from 'bun:test' import { + itemMissingIssue, metadataKeyIssues, metaRules, + nestedChangeIssue, + unreadableArtifactIssue, schemaOutdatedIssues, surfaceUnmetConsequences, } from '../../../src/core/rules/meta.ts' @@ -235,3 +238,43 @@ describe('metadataKeyIssues (skip_specs / retire_capabilities type check)', () = expect(rules(issues)).toEqual(['meta/skip-specs-type', 'meta/retire-capabilities-type']) }) }) + +describe('the namespace, unreadable and missing-item rules', () => { + test('meta/nested-change carries the explanation verbatim at the change root', () => { + const issue = nestedChangeIssue('"mobile" is not a change: it is a folder wrapping …') + expect(issue).toEqual({ + level: 'ERROR', + rule: 'meta/nested-change', + path: '.', + message: '"mobile" is not a change: it is a folder wrapping …', + hint: + 'Move each nested change directly under openspec/changes/, folding the namespace into its name; ' + + 'Only specs may be nested by domain; change directories are always flat', + }) + }) + + test('meta/unreadable-artifact names the file and its errno code', () => { + const issue = unreadableArtifactIssue('specs/widgets/spec.md', 'EACCES') + expect(issue).toMatchObject({ + level: 'ERROR', + rule: 'meta/unreadable-artifact', + path: 'specs/widgets/spec.md', + message: 'could not read specs/widgets/spec.md (EACCES)', + }) + }) + + test('meta/item-missing names the absent change directory or living spec', () => { + expect(itemMissingIssue('change', 'nope')).toEqual({ + level: 'ERROR', + rule: 'meta/item-missing', + path: '.', + message: 'no change directory at openspec/changes/nope/', + }) + expect(itemMissingIssue('spec', 'area/cap')).toEqual({ + level: 'ERROR', + rule: 'meta/item-missing', + path: 'specs/area/cap/spec.md', + message: 'no living spec at openspec/specs/area/cap/spec.md', + }) + }) +}) diff --git a/apps/cli/test/unit/rules/views.test.ts b/apps/cli/test/unit/rules/views.test.ts index 979d6cf3..be77da9f 100644 --- a/apps/cli/test/unit/rules/views.test.ts +++ b/apps/cli/test/unit/rules/views.test.ts @@ -168,6 +168,10 @@ describe('rule views', () => { expect(ADVISORY_RULES.filter((id) => !enumeratedRules().includes(id))).toEqual([]) }) + // Each advisory rule is an exception to the verbatim view, proven by a + // differential fixture: `deltas/scenario-depth`'s is + // `test/contract/validation-parity.test.ts` row 36.1 ("commented mis-depth + // scenario: the binary archives it and cospec raises no deltas/scenario-depth"). const advisory = new Set(ADVISORY_RULES) for (const [rule, fixture] of Object.entries(FIXTURES)) { if (advisory.has(rule)) diff --git a/apps/docs/concepts/apply-and-archive.md b/apps/docs/concepts/apply-and-archive.md index e6199ee0..8bb4a516 100644 --- a/apps/docs/concepts/apply-and-archive.md +++ b/apps/docs/concepts/apply-and-archive.md @@ -95,6 +95,12 @@ a change with no delta specs, a tasks file with zero checkboxes) is already a mainly on the legacy/v1-schema and forked-schema lanes, where cospec's own gate is narrower than OpenSpec's. +When `openspec/changes/archive/` can't be read, `apply` gates as if nothing were +archived — a blocker naming an archived change stays open, never the reverse — +and its document gains a top-level +`"warnings": [{ "code": "archive_unreadable", "message": "…" }]` (a `Warning:` +line on stderr in text). `archive` itself still refuses. + ::: tip `--allow-soft` only waives **soft** blockers. Hard blockers have no override — the change they name has to actually land first. ::: diff --git a/apps/docs/concepts/how-it-relates-to-openspec.md b/apps/docs/concepts/how-it-relates-to-openspec.md index e25750fe..1e461817 100644 --- a/apps/docs/concepts/how-it-relates-to-openspec.md +++ b/apps/docs/concepts/how-it-relates-to-openspec.md @@ -63,8 +63,14 @@ and the current pin: three (`deltas/unpaired-rename`, the widened `archive/added-exists`, `deltas/unread-file` — see [Validation rules](/reference/validation-rules)) so `cospec validate --strict` catches them before `cospec archive` ever - delegates; the fourth (namespace folders) is a delegated refusal cospec relays - verbatim rather than re-implementing its own detection of. + delegates. The fourth, a namespace folder (`changes/mobile/refresh-token/`), + cospec detects natively with a port of OpenSpec's own detector, matching a + schema's `generates` globs as OpenSpec does (braces, ranges, extglobs, + negation), so a change holding only its schema's outputs is never mistaken for + a folder: `status` refuses it (`--change`) or reports it as a failure entry + (`--all`), `list` marks its row `not a change`, and `validate` reports it as + one `meta/nested-change` ERROR — each carrying OpenSpec's explanation + verbatim. - **1.13.1's change validator reports defects cospec's own rules already catch:** empty delta sections and a change with no parsed delta, skipped `###` headers, header-only and missing SHALL/MUST, a requirement with no scenario, a @@ -169,7 +175,9 @@ accepts `--schema ` and answers from that schema's apply requirements, while `cospec instructions apply --change ` is the gate, which enforces the change's own. cospec refuses `--schema` there before the gate runs rather than print a verdict and a payload that disagree — see -[Commands](/reference/commands). +[Commands](/reference/commands). `cospec status --schema `, by contrast, +takes OpenSpec's own meaning: a schema override for every change it reports, not +a filter. ## Named exceptions diff --git a/apps/docs/reference/commands.md b/apps/docs/reference/commands.md index 4b89df13..4c6c96fd 100644 --- a/apps/docs/reference/commands.md +++ b/apps/docs/reference/commands.md @@ -102,43 +102,47 @@ the binary as the item name. ## Commands -| command | synopsis | key flags | see | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cospec init [path]` | Scaffold `openspec/`, the eleven typed schemas, and harness files. Idempotent. | `--yes`, `--force`, `--harness ` (upstream spells it `--tools`; `claude`, `codex`, `opencode`, `agents`, `all`, `none` — trimmed and case-insensitive, as upstream reads `--tools`; an empty list is refused with upstream's message and writes nothing), `--gate` / `--no-gate`, `--remove-opsx` | [Installation](/guide/installation) | -| `cospec update [path]` | Regenerate managed files (schemas, harness files) for the project at `path` (default `.`). | `--check` (drift gate, exits nonzero on drift — including a not-yet-migrated `.codex/skills` layout — changes nothing), `--force` (also discards hand-edited legacy skill copies) | [Installation](/guide/installation) | -| `cospec doctor` | Read-only health check: wrapped-OpenSpec version, schema/harness drift, a `legacy-layout` warning per file still under `.codex/skills`, dangling slash/skill refs, `config.yaml` validity, changes stuck on an old `schemaVersion`, and — on every root — OpenSpec's own doctor report folded in as `openspec-*` findings (root relationship, references, and for a store root its git/metadata facts), its remedies spelled `cospec`. The project config is `openspec/config.yaml`, else `config.yml`, as OpenSpec reads it. `--json` is `{version, findings, summary, root, store, references, status}`: the last four are OpenSpec's own keys as it reports them (each diagnostic's `fix`, and on a failed report its `message`, spelled `cospec`); with no OpenSpec root, its no-root diagnostic stays in `status` beside cospec's one `initialized` ERROR finding. Each line OpenSpec's doctor writes to stderr — its config warnings, such as `Invalid 'context' field in config (must be string)` — is an `openspec-stderr` WARNING finding (in `--json` too), printed once. cospec's own checks run on the operating root: the enclosing root from a subdirectory, and the store an explicit `--store `, a `store:` pointer or the global `defaultStore` selects — so `--store ` checks the store from a bare workspace or from inside another project, exiting as `openspec doctor --store ` does; with no root selected they don't run, and a selection that fails for any other reason is reported by OpenSpec's folded diagnostic alone. | — | [How it relates to OpenSpec](/concepts/how-it-relates-to-openspec), [Stores](/concepts/stores) | -| `cospec new ` | Create a typed change and print its artifact plan. Also accepts `cospec new ": "`, `--goal ` (stored in `.openspec.yaml` beside `schema:`/`created:`), and upstream's own create spelling, `cospec new change ` — without `--schema` (or with `--schema ''`) OpenSpec itself picks the schema from the root's `config.yaml` `schema:` default, else `spec-driven`, printing its own warning on stderr for every `config.yaml` field it can't use (the file unparseable or not a mapping, a `schema:` that isn't a non-empty string, a bad `context:`, `rules:`, `operations:`, `references:`, `store:` or `githubCopilot:`), and its own refusal when that default names a schema it can't find (a whitespace-only `schema:`, or a cospec type the repo has no schema for); `--description`/`--goal` work the same on both spellings. `--initiative ` / `--areas ` (upstream's now-removed options) print upstream's removed-option message on stderr, or its `initiative_option_removed` / `areas_option_removed` document under `--json`, and create nothing. A cospec type the repo has no schema for, named as `` or `--schema`, is refused before OpenSpec runs. Under `--json` every refusal of its own — no `openspec/` tree, unknown type, missing schema, a slug it cannot derive, an invalid slug, an existing or archived change, a failed OpenSpec call — is one `{change: null, status: [{severity, code: "change_error", message}]}` document on stdout, exit `1`; on success `new … --json` carries `change`, `root`, `type`, `dir` and (typed lane) `artifacts` — under `cospec new ` `change` is the slug string, while under upstream's `cospec new change ` it is upstream's own `{id, path, metadataPath, schema}` object, and `root` is the wrapped call's own on both. A failed OpenSpec call is answered with OpenSpec's own reason (a schema it cannot parse or a directory it cannot create, say — its paths and quoted excerpts verbatim, only OpenSpec's own remedy sentences respelled to `cospec`), as `cospec new: ` in text or as the document's message; a missing type or slug or an unknown option stays a text parse refusal, as OpenSpec's own parse errors do, answered ahead of every other refusal (a missing `openspec/` tree included). | `--description `, `--goal ` | [Types and artifacts](/concepts/types-and-artifacts) | -| `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. | `--strict` (promote warnings to errors), `--all`, `--changes`, `--specs`, `--archived`, `--fast`, `--no-interactive` | [Types and artifacts](/concepts/types-and-artifacts) | -| `cospec status --change ` | Per-artifact completion, the blocker gate state, and archive-readiness for one change; `--all` sweeps every active change instead of one. On a change whose schema isn't a cospec type, text mode prints OpenSpec's own status for it and `--json` a `{change, type, legacy: true}` document. | `--change `, `--all` | [Apply and archive](/concepts/apply-and-archive) | -| `cospec list` | List active changes with type, gate state, task progress, and archive-readiness columns. `--specs` instead lists living specs by requirement count. | `--blocked` (only changes with a non-clear gate), `--specs` | [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) | -| `cospec sync-blockers` | Check off blocking-changes entries whose target has shipped, across all active changes. | `--check` (report only, no writes), `--change ` | [Blocking changes](/concepts/blocking-changes) | -| `cospec store ` | First-class wrap of the store lifecycle: `setup`/`register`/`unregister`/`remove`/`list` (`ls`)/`doctor`. `setup`/`register` auto-run `cospec init --harness none` on success. No subcommand, an unknown one, an option where it belongs, or anything after `--` (`cospec store`, `store bogus`, `store --bogus`, `store -- --bogus`) gets OpenSpec's own refusal, exit `1` — under `--json` its one `unknown_store_subcommand` document. Every relayed diagnostic's `fix`, and on failure its `message`, names the `cospec` command, text and `--json`. | `--no-cospec-init` (`setup`/`register` only) | [Stores](/concepts/stores) | -| `cospec context` | Read-only cross-repo working-set brief across a repo and its `references:` stores. The reference block's commands — each `Fetch:` and `Fix:` line, and under `--json` `members[].fetch`, `members[].status[].fix` and `status[].fix` — name `cospec`, spelled from OpenSpec's own document only where the whole value is one of OpenSpec's reference remedies; store ids, paths and a declared clone remote are printed as OpenSpec prints them. | `--json`, `--code-workspace `, `--force` | [Stores](/concepts/stores) | -| `cospec workset create\|list\|remove\|open` | Personal, local working views. No subcommand, an unknown one, an option where it belongs, or anything after `--` gets OpenSpec's own refusal, exit `1` — under `--json` its one `unknown_workset_subcommand` document; `create` and an empty `list` print their next step as `cospec workset …`. `open` hands the terminal over to the workset's editor/agent session and never accepts `--store`; under `--json` it opens nothing and relays OpenSpec's `workset_open_json_unsupported` document, exit `1`. Before handing over it refuses what OpenSpec would — the argv first, then, with no terminal (no TTY on stdin, `CI`, `OPEN_SPEC_INTERACTIVE=0`) or for a workset OpenSpec would refuse (an unreadable worksets file, not saved, no member folder on this machine), it runs the call piped and relays its answer, remedies spelled `cospec`. | — | [Stores](/concepts/stores) | -| `cospec show ` | Show a single change or spec, text or JSON. | `--type`, `--deltas-only`, `--requirements-only`, `-r`/`--requirement`, `--no-scenarios`, `--diff` | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `show`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | -| `cospec view` | Summary dashboard for the operating root. Accepts neither `--json` nor `--store`. | — | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `view`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | -| `cospec schemas` | List every resolvable schema — the eleven cospec types plus any project-local (forked) schema — with its artifact chain. | — | [Configuration](/reference/configuration#tier-3-schema-forking) | -| `cospec schema which\|validate\|fork\|init` | Inspect which schema a change resolves to, validate a schema's own structure, or create a project-local schema (`fork [name]`, `init `). Refuses a destination name that collides with one of the eleven cospec types. | `--description `, `--artifacts ` (`init` only) | [Configuration](/reference/configuration#tier-3-schema-forking) | -| `cospec templates` | List resolved per-artifact template paths for a schema. | `--schema ` (default `spec-driven`) | [Configuration](/reference/configuration#tier-3-schema-forking) | -| `cospec config ` | Machine-global OpenSpec config (`~/.config/openspec/config.json`): `path`, `list`, `get `, `set `, `unset `, `reset`, `edit`, `profile [preset]`. `edit`, `profile` with no preset, and `reset --all` without `-y` hand the terminal over (inherited stdio, verbatim child exit code) once their argv has been checked (`reset --all` with no TTY on stdin runs piped instead); the rest are piped. With no subcommand it prints `cospec config --help` on stderr, exit `1`. | `--scope global` (only accepted value), `--json` (`list` only — the rest get a cospec-owned envelope), `-y`/`--yes` (`reset --all`) | [Configuration](/reference/configuration#machine-global-openspec-config) | -| `cospec completion [bash\|zsh\|fish]` | Print a shell completion script to stdout, generated from cospec's own command table. Shell auto-detected from `$SHELL` when omitted; a given shell name is case-insensitive, as upstream reads it. Also accepts upstream's `cospec completion generate [shell]`. No `install`/`uninstall` — copy-paste only. | — | [Installation](/guide/installation#shell-completion) | -| `cospec help [command]` | Print the program help, or one command's help (a hidden command's included), matching commander's implicit `help`. An unknown name prints the program help on stderr and exits `1`. | — | — | -| `cospec feedback "" [--body ]` | File a bug report at `aligned-team/cospec` via `gh issue create` (array argv, no shell); prints a prefilled manual-submission URL and exits 0 if `gh` is missing or unauthenticated. `--upstream` relays to `openspec feedback` instead, filing at OpenSpec's own tracker. | `--body `, `--upstream` | — | +| command | synopsis | key flags | see | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cospec init [path]` | Scaffold `openspec/`, the eleven typed schemas, and harness files. Idempotent. | `--yes`, `--force`, `--harness ` (upstream spells it `--tools`; `claude`, `codex`, `opencode`, `agents`, `all`, `none` — trimmed and case-insensitive, as upstream reads `--tools`; an empty list is refused with upstream's message and writes nothing), `--gate` / `--no-gate`, `--remove-opsx` | [Installation](/guide/installation) | +| `cospec update [path]` | Regenerate managed files (schemas, harness files) for the project at `path` (default `.`). | `--check` (drift gate, exits nonzero on drift — including a not-yet-migrated `.codex/skills` layout — changes nothing), `--force` (also discards hand-edited legacy skill copies) | [Installation](/guide/installation) | +| `cospec doctor` | Read-only health check: wrapped-OpenSpec version, schema/harness drift, a `legacy-layout` warning per file still under `.codex/skills`, dangling slash/skill refs, `config.yaml` validity, changes stuck on an old `schemaVersion`, and — on every root — OpenSpec's own doctor report folded in as `openspec-*` findings (root relationship, references, and for a store root its git/metadata facts), its remedies spelled `cospec`. The project config is `openspec/config.yaml`, else `config.yml`, as OpenSpec reads it. `--json` is `{version, findings, summary, root, store, references, status}`: the last four are OpenSpec's own keys as it reports them (each diagnostic's `fix`, and on a failed report its `message`, spelled `cospec`); with no OpenSpec root, its no-root diagnostic stays in `status` beside cospec's one `initialized` ERROR finding. Each line OpenSpec's doctor writes to stderr — its config warnings, such as `Invalid 'context' field in config (must be string)` — is an `openspec-stderr` WARNING finding (in `--json` too), printed once. cospec's own checks run on the operating root: the enclosing root from a subdirectory, and the store an explicit `--store `, a `store:` pointer or the global `defaultStore` selects — so `--store ` checks the store from a bare workspace or from inside another project, exiting as `openspec doctor --store ` does; with no root selected they don't run, and a selection that fails for any other reason is reported by OpenSpec's folded diagnostic alone. | — | [How it relates to OpenSpec](/concepts/how-it-relates-to-openspec), [Stores](/concepts/stores) | +| `cospec new ` | Create a typed change and print its artifact plan. Also accepts `cospec new ": "`, `--goal ` (stored in `.openspec.yaml` beside `schema:`/`created:`), and upstream's own create spelling, `cospec new change ` — without `--schema` (or with `--schema ''`) OpenSpec itself picks the schema from the root's `config.yaml` `schema:` default, else `spec-driven`, printing its own warning on stderr for every `config.yaml` field it can't use (the file unparseable or not a mapping, a `schema:` that isn't a non-empty string, a bad `context:`, `rules:`, `operations:`, `references:`, `store:` or `githubCopilot:`), and its own refusal when that default names a schema it can't find (a whitespace-only `schema:`, or a cospec type the repo has no schema for); `--description`/`--goal` work the same on both spellings. `--initiative ` / `--areas ` (upstream's now-removed options) print upstream's removed-option message on stderr, or its `initiative_option_removed` / `areas_option_removed` document under `--json`, and create nothing. A cospec type the repo has no schema for, named as `` or `--schema`, is refused before OpenSpec runs. Under `--json` every refusal of its own — no `openspec/` tree, unknown type, missing schema, a slug it cannot derive, an invalid slug, an existing or archived change, a failed OpenSpec call — is one `{change: null, status: [{severity, code: "change_error", message}]}` document on stdout, exit `1`; on success `new … --json` carries `change`, `root`, `type`, `dir` and (typed lane) `artifacts` — under `cospec new ` `change` is the slug string, while under upstream's `cospec new change ` it is upstream's own `{id, path, metadataPath, schema}` object, and `root` is the wrapped call's own on both. A failed OpenSpec call is answered with OpenSpec's own reason (a schema it cannot parse or a directory it cannot create, say — its paths and quoted excerpts verbatim, only OpenSpec's own remedy sentences respelled to `cospec`), as `cospec new: ` in text or as the document's message; a missing type or slug or an unknown option stays a text parse refusal, as OpenSpec's own parse errors do, answered ahead of every other refusal (a missing `openspec/` tree included). | `--description `, `--goal ` | [Types and artifacts](/concepts/types-and-artifacts) | +| `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 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) | +| `cospec sync-blockers` | Check off blocking-changes entries whose target has shipped, across all active changes. | `--check` (report only, no writes), `--change ` | [Blocking changes](/concepts/blocking-changes) | +| `cospec store ` | First-class wrap of the store lifecycle: `setup`/`register`/`unregister`/`remove`/`list` (`ls`)/`doctor`. `setup`/`register` auto-run `cospec init --harness none` on success. No subcommand, an unknown one, an option where it belongs, or anything after `--` (`cospec store`, `store bogus`, `store --bogus`, `store -- --bogus`) gets OpenSpec's own refusal, exit `1` — under `--json` its one `unknown_store_subcommand` document. Every relayed diagnostic's `fix`, and on failure its `message`, names the `cospec` command, text and `--json`. | `--no-cospec-init` (`setup`/`register` only) | [Stores](/concepts/stores) | +| `cospec context` | Read-only cross-repo working-set brief across a repo and its `references:` stores. The reference block's commands — each `Fetch:` and `Fix:` line, and under `--json` `members[].fetch`, `members[].status[].fix` and `status[].fix` — name `cospec`, spelled from OpenSpec's own document only where the whole value is one of OpenSpec's reference remedies; store ids, paths and a declared clone remote are printed as OpenSpec prints them. | `--json`, `--code-workspace `, `--force` | [Stores](/concepts/stores) | +| `cospec workset create\|list\|remove\|open` | Personal, local working views. No subcommand, an unknown one, an option where it belongs, or anything after `--` gets OpenSpec's own refusal, exit `1` — under `--json` its one `unknown_workset_subcommand` document; `create` and an empty `list` print their next step as `cospec workset …`. `open` hands the terminal over to the workset's editor/agent session and never accepts `--store`; under `--json` it opens nothing and relays OpenSpec's `workset_open_json_unsupported` document, exit `1`. Before handing over it refuses what OpenSpec would — the argv first, then, with no terminal (no TTY on stdin, `CI`, `OPEN_SPEC_INTERACTIVE=0`) or for a workset OpenSpec would refuse (an unreadable worksets file, not saved, no member folder on this machine), it runs the call piped and relays its answer, remedies spelled `cospec`. | — | [Stores](/concepts/stores) | +| `cospec show ` | Show a single change or spec, text or JSON. | `--type`, `--deltas-only`, `--requirements-only`, `-r`/`--requirement`, `--no-scenarios`, `--diff` | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `show`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | +| `cospec view` | Summary dashboard for the operating root. Accepts neither `--json` nor `--store`. | — | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `view`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | +| `cospec schemas` | List every resolvable schema — the eleven cospec types plus any project-local (forked) schema — with its artifact chain. | — | [Configuration](/reference/configuration#tier-3-schema-forking) | +| `cospec schema which\|validate\|fork\|init` | Inspect which schema a change resolves to, validate a schema's own structure, or create a project-local schema (`fork [name]`, `init `). Refuses a destination name that collides with one of the eleven cospec types. | `--description `, `--artifacts ` (`init` only) | [Configuration](/reference/configuration#tier-3-schema-forking) | +| `cospec templates` | List resolved per-artifact template paths for a schema. | `--schema ` (default `spec-driven`) | [Configuration](/reference/configuration#tier-3-schema-forking) | +| `cospec config ` | Machine-global OpenSpec config (`~/.config/openspec/config.json`): `path`, `list`, `get `, `set `, `unset `, `reset`, `edit`, `profile [preset]`. `edit`, `profile` with no preset, and `reset --all` without `-y` hand the terminal over (inherited stdio, verbatim child exit code) once their argv has been checked (`reset --all` with no TTY on stdin runs piped instead); the rest are piped. With no subcommand it prints `cospec config --help` on stderr, exit `1`. | `--scope global` (only accepted value), `--json` (`list` only — the rest get a cospec-owned envelope), `-y`/`--yes` (`reset --all`) | [Configuration](/reference/configuration#machine-global-openspec-config) | +| `cospec completion [bash\|zsh\|fish]` | Print a shell completion script to stdout, generated from cospec's own command table. Shell auto-detected from `$SHELL` when omitted; a given shell name is case-insensitive, as upstream reads it. Also accepts upstream's `cospec completion generate [shell]`. No `install`/`uninstall` — copy-paste only. | — | [Installation](/guide/installation#shell-completion) | +| `cospec help [command]` | Print the program help, or one command's help (a hidden command's included), matching commander's implicit `help`. An unknown name prints the program help on stderr and exits `1`. | — | — | +| `cospec feedback "" [--body ]` | File a bug report at `aligned-team/cospec` via `gh issue create` (array argv, no shell); prints a prefilled manual-submission URL and exits 0 if `gh` is missing or unauthenticated. `--upstream` relays to `openspec feedback` instead, filing at OpenSpec's own tracker. | `--body `, `--upstream` | — | `cospec check-commit` is a hidden commit-msg hook entrypoint (advisory only, never blocks a commit) and isn't part of the everyday command surface. -`cospec __complete ` is a hidden dynamic-completion source -the generated shell scripts call at Tab time — a failed lookup is silent (exit -1, nothing on either stream) so it can never corrupt a keystroke; only a parse -refusal (no source at all, an unknown option) reaches stderr, which the scripts -discard. `cospec experimental [--tool ] [--no-interactive]` is a hidden, -deprecated alias of `init` kept for upstream compatibility only — it never -appears in `cospec --help` or completions, prints a deprecation note (unless -`--json`), then runs `init` on `.` with `--tool` read as `--harness`. +`cospec __complete ` is a hidden +dynamic-completion source the generated shell scripts call at Tab time — a +failed lookup is silent (exit 1, nothing on either stream) so it can never +corrupt a keystroke; only a parse refusal (no source at all, an unknown option) +reaches stderr, which the scripts discard. The source name is matched in any +case; `schemas` lists `cospec schemas --json`'s names in their order (the +scripts complete every `--schema` value and `schema which|validate|fork`'s +schema from it), and `archived-changes` the resolved root's archive entries. +`cospec experimental [--tool ] [--no-interactive]` is a hidden, deprecated +alias of `init` kept for upstream compatibility only — it never appears in +`cospec --help` or completions, prints a deprecation note (unless `--json`), +then runs `init` on `.` with `--tool` read as `--harness`. ::: tip Exit codes `apply` and `archive` use the same four-code contract (`0`/`1`/`2`/`3`) across every gated command. The full table lives on @@ -151,16 +155,27 @@ command. ::: line, exit `1`, without `--json`; as `{ "changes": [], "root": null, "error": "..." }` on stdout, exit `1`, with `--json`. On success, `--all`'s `--json` shape is -`{ changes: ChangeEntry[], root: string }`, where each entry is the same shape a -single `cospec status --change --json` emits (or `{ change, error }` if -that one change's status computation threw); exit is `1` if any entry failed, -`0` otherwise. The single-change shape itself is unchanged. Under `--json` a -lookup that fails — an unknown change, or no `--change` with several active -changes — is one document on stdout in OpenSpec's shape, +`{ changes: ChangeEntry[], root: { path, source } }`, where each entry is the +shape a single `cospec status --change --json` emits, less its `root` (or +`{ change, error }` plus OpenSpec's `changeName`/`status` if that one change's +status could not be computed, a namespace folder included); exit is `1` if any +entry failed, `0` otherwise. Under `--json` a lookup that fails — an unknown +change, or no `--change` with several active changes — is one document on stdout +in OpenSpec's shape, `{ "status": [{ "severity": "error", "code": "change_error", "message": "..." }] }`, exit `1`, and no active changes is -`{ "changes": [], "root": "...", "message": "No active changes." }`, exit `0`. -::: +`{ "changes": [], "message": "No active changes.", "root": { "path": "...", "source": "..." } }`, +exit `0`. A root that can't be selected is one document too, with OpenSpec's +payload — `{ "changes": [], "root": null, "status": [...] }` for `list` and +`status --all`, `{ "specs": [], "root": null, "status": [...] }` for +`list --specs` — and, for a failure OpenSpec doesn't diagnose (an unreadable +store registry), its per-command code: `list_error` (`list`), `change_error` +(`status`, `apply`) or `validate_error` (`validate`). Every `cospec apply` early +exit under `--json` — no `openspec/` root, an unknown change, a failed OpenSpec +call, an unreadable `openspec/changes/` — is one `change_error` document, exit +`1`; when OpenSpec's `instructions apply` refuses the change after the gate +clears, its own failure document is the answer, spelled `cospec` +(`cospec apply: ` and its `Fix:` line in text). ::: ## Read-only and personal commands diff --git a/apps/docs/reference/validation-rules.md b/apps/docs/reference/validation-rules.md index b903cef7..4cff0f02 100644 --- a/apps/docs/reference/validation-rules.md +++ b/apps/docs/reference/validation-rules.md @@ -23,18 +23,41 @@ the `deltas/*` rules enforce, see OpenSpec's ## Command ``` -cospec validate [name] [--all|--changes|--specs] [--strict] [--json] [--fast] +cospec validate [name] [--all|--changes|--specs|--archived] [--type change|spec] + [--report full|findings] [--concurrency ] [--strict] [--json] [--fast] ``` -| Flag | Effect | -| ----------- | ------------------------------------------------------------------ | -| _(no args)_ | defaults to `--all` | -| `--strict` | promotes every WARNING to blocking — this is what hooks and CI use | -| `--fast` | skips the archive-precondition checks (used internally by `apply`) | -| `--json` | machine-readable output (shape below) | +| Flag | Effect | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| _(no args)_ | defaults to `--all` | +| `--strict` | promotes every WARNING to blocking — this is what hooks and CI use | +| `--fast` | skips the archive-precondition checks (used internally by `apply`) | +| `--json` | machine-readable output (shape below) | +| `--type ` | forces the kind of the named item, in any case; any other value is ignored, as OpenSpec ignores it | +| `--report ` | the full report (the default) or only the items with findings — needs a bulk scope and no name | +| `--concurrency ` | at most `n` change validations at once in a bulk scope; else `OPENSPEC_CONCURRENCY`, else 6 — a value that isn't a positive integer is ignored | Exit code is `1` if there are errors (or warnings under `--strict`), otherwise -`0`. +`0` — under `--report findings` too, which only narrows what is printed. + +A name is resolved as OpenSpec resolves it. `--all`, `--changes` or `--specs` +beside a name runs that bulk scope and ignores the name. Otherwise a name that +is both an active change and a living spec is refused +(`Ambiguous item '' matches both a change and a spec.`, fix +`Pass --type change|spec.`; one `ambiguous_item` document under `--json`), and +one that is neither prints OpenSpec's nearest matches +(`Unknown item ''. Did you mean: …?`, up to five ids, duplicates kept; +`unknown_item`), both exit `1` — with no `openspec/` directory too, where a name +alone matches nothing and is `unknown_item`. With `--type`, a path-shaped name +is refused with OpenSpec's `invalid_item` message, and a forced kind naming +nothing on disk is that item with one `meta/item-missing` ERROR. A forced +`--type spec` reaches a `spec.md` the bulk scopes skip — under a dot-directory, +or a capability behind a linked directory — and validates that file as OpenSpec +does, never an empty passing report. `--report` requests are checked before any +root is resolved: an unknown value, an item name, `--archived` with a bulk flag, +or no bulk scope is refused with OpenSpec's message and fix (`Error: …` / +`Fix: …` on stderr, or one `invalid_validation_report_request` document), exit +`1`. ::: tip Which families run for a change Every change always runs `meta`, `proposal`, `blockers`, and `tasks`. Whether `verification`, `design`, `deltas`, @@ -58,20 +81,23 @@ Every issue also carries a one-line hint. Structural checks on the change directory itself — `.openspec.yaml`, naming, and which artifact files are allowed to exist. -| ID | Level | Check | -| ------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `meta/openspec-yaml` | E | `.openspec.yaml` is present, parseable, and has a non-empty `schema:`; `created:` must be `YYYY-MM-DD` when present | -| `meta/schema-unknown` | E | the declared schema isn't one of the eleven cospec types and isn't resolvable any other way | -| `meta/legacy-schema` | I | schema resolves but isn't a cospec type — the change runs in legacy mode | -| `meta/name-kebab` | E | the change directory isn't kebab-case with no `YYYY-MM-DD-` prefix (that prefix collides with archive naming) | -| `meta/forbidden-artifact` | E | a file exists for an artifact the type doesn't declare — e.g. a `specs/` dir under `ci` | -| `meta/unexpected-file` | W | a file matches no declared artifact glob (excludes `README.md`, `.openspec.yaml`, `.refine/`) | -| `meta/empty-change` | I | `.openspec.yaml` exists but the change has zero artifacts yet — reported as "in progress," not as an error | -| `meta/surface-unmet` | W (E-strict) | a checked `## Surfaces` box's consequence is missing, for a type whose target isn't Forbidden — specifically, a type like `revert`/`build`/`ci` whose `verification.md` doesn't exist at all. A missing _row_ on a file that does exist is owned by `verification/*` instead, so this never double-reports. | -| `meta/schema-outdated` | I | the change is on `schemaVersion` 1 (absent counts as 1) — some artifacts are grandfathered out until `cospec migrate`; never blocks | -| `meta/skip-specs-type` | E | `.openspec.yaml`'s `skip_specs` key is present but isn't a boolean | -| `meta/retire-capabilities-type` | E | `.openspec.yaml`'s `retire_capabilities` key is present but isn't a boolean | -| `change/artifact-missing` | I / E-strict | an artifact required by the type's apply gate doesn't exist yet (verification is excluded — `verification/missing` owns that case) | +| ID | Level | Check | +| ------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `meta/openspec-yaml` | E | `.openspec.yaml` is present, parseable, and has a non-empty `schema:`; `created:` must be `YYYY-MM-DD` when present | +| `meta/schema-unknown` | E | the declared schema isn't one of the eleven cospec types and isn't resolvable any other way | +| `meta/legacy-schema` | I | schema resolves but isn't a cospec type — the change runs in legacy mode | +| `meta/name-kebab` | E | the change directory isn't kebab-case with no `YYYY-MM-DD-` prefix (that prefix collides with archive naming) | +| `meta/forbidden-artifact` | E | a file exists for an artifact the type doesn't declare — e.g. a `specs/` dir under `ci` | +| `meta/unexpected-file` | W | a file matches no declared artifact glob (excludes `README.md`, `.openspec.yaml`, `.refine/`) | +| `meta/empty-change` | I | `.openspec.yaml` exists but the change has zero artifacts yet — reported as "in progress," not as an error | +| `meta/surface-unmet` | W (E-strict) | a checked `## Surfaces` box's consequence is missing, for a type whose target isn't Forbidden — specifically, a type like `revert`/`build`/`ci` whose `verification.md` doesn't exist at all. A missing _row_ on a file that does exist is owned by `verification/*` instead, so this never double-reports. | +| `meta/schema-outdated` | I | the change is on `schemaVersion` 1 (absent counts as 1) — some artifacts are grandfathered out until `cospec migrate`; never blocks | +| `meta/skip-specs-type` | E | `.openspec.yaml`'s `skip_specs` key is present but isn't a boolean | +| `meta/retire-capabilities-type` | E | `.openspec.yaml`'s `retire_capabilities` key is present but isn't a boolean | +| `meta/nested-change` | E | the directory is a namespace folder wrapping nested changes (`changes/mobile/refresh-token/`), not a change — the message is OpenSpec's explanation verbatim; no other rule runs on it and nothing is delegated | +| `meta/unreadable-artifact` | E | a change file that exists could not be read (`could not read ()`) — a proposal, blockers, tasks, verification or design file, a delta or unread spec file, `.openspec.yaml`, a directory an artifact can live in (the change itself, `specs/` outside dot-directories — any other directory is passed by, as OpenSpec never reads it), or the living spec a delta targets (on the delta's path, `could not read openspec/specs//spec.md ()`); no other rule runs on the change and nothing is delegated. On a spec item it is the living `spec.md` itself (`could not read specs//spec.md ()`), that spec's only issue, with nothing delegated for it — every other spec in scope is reported as it is alone, bar OpenSpec's refusal of it over the unreadable file where OpenSpec refuses (Bun's `realpath` on macOS): its one added `openspec/validate` ERROR | +| `meta/item-missing` | E | `--type` forced a kind the name has nothing on disk for: no change directory at `openspec/changes//`, or no living spec at `openspec/specs//spec.md` | +| `change/artifact-missing` | I / E-strict | an artifact required by the type's apply gate doesn't exist yet (verification is excluded — `verification/missing` owns that case) | ## `proposal/` @@ -295,7 +321,12 @@ thing that would refuse the merge as an **INFO**-level `openspec/validate` issue — one per precondition, one per delta file. INFO never blocks: `normalizeLevel` accepts it, but only ERROR and WARNING (WARNING only under `--strict`) count toward `valid` or the exit code, so this arrives as extra context, never a new -way to fail. +way to fail. Each delegated call names its item's kind (`--type change`, or +`--type spec` for a named spec), so a change sharing a living spec's name is +validated as a change. When OpenSpec refuses an item instead of reporting it +(its `{status: [...]}` failure document — an errno it couldn't read past, say), +each of its diagnostics is that item's `openspec/validate` issue at its +severity, so the item fails rather than passing on an empty report. Most of what that dry-run reports is the same defect cospec's own `archive/*` family already caught as an ERROR — a MODIFIED/REMOVED/RENAMED target that's @@ -403,7 +434,7 @@ cospec validate — 2 changes, 5 specs ```json { - "version": "...", + "version": 1, "items": [ { "id": "add-widget", @@ -420,12 +451,72 @@ cospec validate — 2 changes, 5 specs "hint": null, "fixable": false } - ] + ], + "durationMs": 12 } ], - "summary": { "errors": 2, "warnings": 1, "byRule": { "...": 1 } } + "summary": { + "errors": 2, + "warnings": 1, + "byRule": { "...": 1 }, + "totals": { "items": 7, "passed": 6, "failed": 1 }, + "byType": { + "change": { "items": 2, "passed": 1, "failed": 1 }, + "spec": { "items": 5, "passed": 5, "failed": 0 } + } + }, + "root": { "path": "/path/to/repo", "source": "nearest" } } ``` +Beside cospec's own keys the document carries OpenSpec's report keys: +`items[].durationMs`, `summary.totals`, `summary.byType` (one entry per kind in +scope) and `root` (the selected root, `{path, source, store_id?}`). `version` +stays cospec's format marker `1`, never OpenSpec's `"1.0"`. One key maps rather +than matches: an item's `type` is cospec's documented value — a change's schema +(`feat`), absent on a spec — while OpenSpec's `type` (`change`/`spec`) is what +cospec calls `kind`. + +When `openspec/changes/archive/` can't be read, `validate` (and `apply`) answer +as if nothing were archived and the document gains +`"warnings": [{ "code": "archive_unreadable", "message": "could not read … (EACCES); …" }]` +(a `Warning:` line on stderr in text) — an empty index can only add issues or +keep blockers open, never clear one. A run that can't produce a report is one +OpenSpec-shaped failure document instead, +`{ "status": [{ "severity": "error", "code": "…", "message": "…", "fix"?: "…" }] }`, +exit `1`: `no_openspec_root` (fix `Run cospec init to create a root here.`) for +a bulk scope, `--archived` or a bare `validate --json` with no `openspec/` +directory (a name alone is resolved, and is `unknown_item`), `validate_error` +carrying the errno message when `openspec/changes/`, `openspec/specs/` or a +capability directory can't be read, and OpenSpec's own document, respelled, when +its `--archived` sweep fails. Under `--strict` an item with a warning, a change +or a spec, is `valid: false` and counted as failed in `summary.totals`, as in +OpenSpec's report. + +`--report findings --json` emits OpenSpec's findings projection inside the same +`version: 1` envelope — only the items with at least one issue: + +```json +{ + "version": 1, + "report": { + "kind": "validation-findings", + "version": "1.0", + "scope": "all", + "returnedItems": 1, + "totalItems": 7 + }, + "itemFindings": [ + { "id": "add-widget", "kind": "change", "type": "feat", "...": "..." } + ], + "summary": { "...": "as above" }, + "root": { "path": "/path/to/repo", "source": "nearest" } +} +``` + +`scope` is `all`, `changes`, `specs` or `archived`; `report.version` is +OpenSpec's own nested marker. Text-mode findings fold every issue-free item into +the header's counts. + Each issue carries `level`, `rule`, `path`, an optional `line`, `message`, an optional `hint`, and `fixable`. diff --git a/bun.lock b/bun.lock index 21249273..63418cb1 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ }, "devDependencies": { "bun-types": "1.3.14", + "fast-glob": "3.3.3", "yaml": "2.9.0", }, "optionalDependencies": { diff --git a/docs/architecture.md b/docs/architecture.md index 5534f453..d55c0595 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -278,6 +278,43 @@ the gate; a successful `archive` (the user's context and operation guidance only) is relayed as the binary prints it, its text form from the same argv again without `--json` (the helper's `rerun`, which never selects the root twice). +### `status`, `list` and `validate`: upstream keys by additive merge + +`cospec status`, `cospec list` and `cospec validate` compute their own documents +— the gate column, archive-readiness, cospec's rule findings — and also carry +every key the binary's own `--json` document for the same invocation does. The +keys come from **one delegated call per invocation**, never one per change: +`status --change --json` (or `--all --json`, `--schema` forwarded) and +`list --json` (`--sort name` forwarded), each with its expected exit codes +`{0, 1}` and a post-condition that the answer is one document naming the change, +the sweep or the rows, or carrying `status`. The binary's document is merged +into cospec's by `mergeUpstream` (`core/upstream-keys.ts`): every key cospec +lacks is added, plain objects merge key by key, arrays of objects merge entry by +entry by an identity (`change`/`changeName`, `change`/`name`, an artifact's +`id`), and a key both documents carry keeps cospec's value — a differing one is +returned as a collision, never written. `root` is set from the resolver +(`rootOutput`, the binary's `{path, source, store_id?}`) before the merge; +`nextSteps` is the binary's value with each sentence spelled through the remedy +allowlist (`respellWholeRemedy`). `list`'s rows, their order and their +membership are the binary's; each keeps cospec's columns, computed by name. +`validate` computes the binary's report keys itself (`root`, `durationMs`, +`summary.totals`/`byType`, `toJson` in `core/report.ts`). Every envelope keeps +`version: 1`. Text-mode `status` on a cospec-typed change stays spawn-free +unless the binary decides whether the change can be reported at all (an entry +cospec cannot read, a `.openspec.yaml` the binary's `readChangeMetadata` +refuses, or a schema the binary cannot load), when it asks the binary and relays +its refusal; a schema cospec doesn't type is rendered from the delegated +document with a port of the binary's status printer. + +The gate is the **key oracle**, `test/contract/support/key-oracle.ts`: each +`cli-surface.test.ts` row runs a command and the pinned binary on the same +fixture and fails on an upstream key path cospec's document lacks, on an +upstream value cospec reports differently, and on any collision outside its +named list — `version` (cospec's format marker) and validate's `items[].type` +(the change's schema; the binary's `change|spec` is cospec's `kind`). Timing +values compare by type, validation verdicts by presence and type; a second check +pins cospec's pre-existing keys to the values it computes natively. + ## The disciplined-passthrough runner Not every wrapped command adds a cospec gate. Read-only reads (`show`, `view`, @@ -525,29 +562,32 @@ placeholder there, and the literal text would leak to the model — so the two rendered bodies for the same workflow have different `contentHash`es on OpenCode by design, not by drift. -### 4. Nested-change namespace folders (deferred, bounded by contract) +### 4. Nested-change namespace folders Upstream refuses a `openspec/changes/` entry that is itself a namespace folder -wrapping further changes, at both gates that matter: `openspec validate` reports -`is not a change: it is a folder wrapping …`, and `openspec archive` -hard-refuses with `archive_change_is_namespace_folder` before validation ever -runs. cospec's `change.ts` has no shape check of its own and deliberately -doesn't grow one here, so the safety property is inherited: `cospec validate` -and `cospec archive` both refuse such an entry at exit 1, nothing merges and -nothing moves. - -The refusal is cospec's own, though, not the relayed one. A namespace folder has -no `.openspec.yaml`, so Step 2's fast validation raises `meta/openspec-yaml` and -the run exits before it ever delegates — the reader is told the file is missing, -with a hint (`cospec new `) that points the wrong way, and never -learns the folder is wrapping changes. Reporting quality is the whole of the -gap: `cospec status` and `cospec list` would likewise show a fabricated artifact -plan against a phantom empty schema. It is a UX defect, not a gate disagreement -or a data-loss path, so it stays out of scope here, bounded instead by contract -tests that pin both halves — the upstream text the binary really emits, and its -documented absence from cospec's report, so the deferred work has a marker to -flip. A proper fix is its own `feat`: a namespace-folder detector wired into all -four command surfaces. +wrapping further changes: `openspec validate` reports +`is not a change: it is a folder wrapping …`, `status` refuses it, `list` marks +it, and `openspec archive` hard-refuses with +`archive_change_is_namespace_folder` before validation ever runs. cospec detects +the folder natively with a port of the binary's detector — +`findNestedChangesIn`, `findNestedChanges` and `describeNestedChange` in +`core/change.ts`, the same three signals (a change-root marker, a file under +`specs/`, an output of the schema the directory resolves to), a depth bound of +three and the verbatim explanation. The schema-output signal matches each +artifact's `generates` through `core/glob.ts`, a line-for-line port of the +binary's `artifactOutputExists` over `fast-glob` — braces, numeric ranges, +extglobs and negation as the binary reads them. `fast-glob` is an exact-pinned +devDependency at the version the pinned openspec resolves, bundled into the +standalone binary by `bun build --compile` (the embedded openspec bundle has no +copy of its own to load), and `test/contract/glob.test.ts` holds the port's +regex sources, brace expansions and answers to the pinned binary's own modules — +it fails if an OpenSpec pin bump moves `fast-glob`. `status --change` refuses it +(`change_error` under `--json`), `status --all` carries it as a failure entry, +`list` marks its row `not a change` with state `not-a-change` and the binary's +warning, and `validateChange` answers it with one `meta/nested-change` ERROR — +so `validate`, `apply` and `archive` refuse it with the binary's explanation +before anything is delegated. The archive's own dedicated refusal shape is +`archive-and-sync-parity`'s. ## The static-matrix invariant @@ -604,7 +644,10 @@ apps/cli/src/ ├── core/ │ ├── openspec.ts spawn wrapper, version assert, passthroughOpenspec runner │ ├── passthrough-command.ts global-flag threading for passthrough commands -│ ├── change.ts change discovery, .openspec.yaml, archive index +│ ├── change.ts change discovery, .openspec.yaml, archive index, +│ │ the namespace-folder detector +│ ├── glob.ts the binary's artifactOutputExists over fast-glob +│ ├── upstream-keys.ts the additive merge of the binary's --json keys │ ├── report.ts the Issue model + text/JSON renderers (frozen interface) │ ├── managed-files.ts generatedBy/contentHash protocol + manifest │ ├── blockers.ts blocking-changes.md parser, sync, lint diff --git a/docs/harness-integration.md b/docs/harness-integration.md index 0fbd6111..53c5e424 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -30,7 +30,9 @@ internals behind it — content the site intentionally keeps at a higher level. write the artifact, until every `apply.requires` artifact is done. Each artifact in the status JSON carries `ready` (its `requires` are all done, so it can be authored next) alongside `done` and `required`, so the loop can pick - what to write without re-deriving the dependency graph. + what to write without re-deriving the dependency graph. A change with no + artifacts yet carries `artifacts: []`, and its `next` names the first one to + write. - **new** — scaffold-only entry point: pick the type, run `cospec new `, print the typed artifact plan and the first artifact's instructions, then **stop** without authoring anything. Hands off diff --git a/docs/validation.md b/docs/validation.md index c002d58a..d8e9a425 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -202,7 +202,15 @@ archive reads. The two `ReadView`s of that scan: change, so reading it verbatim would refuse what the binary archives, while a commented one that does split a requirement is `archive/split-requirement`'s, on the verbatim view. `parseAdvisoryDelta` returns this view - (`AdvisoryDelta`), and `LivingSpec.advisory` carries it. + (`AdvisoryDelta`), and `LivingSpec.advisory` carries it. The proving fixture + is `test/contract/validation-parity.test.ts` row 36.1: a delta whose only + mis-depth scenario sits inside an HTML comment, which the binary archives and + on which cospec raises no `deltas/scenario-depth` (the verbatim parse would). + +The standing rule: a gate rule reads the verbatim view unless a differential +fixture proves the verbatim view makes cospec refuse something the binary +accepts AND the structural outcome is gated elsewhere on the verbatim view; such +exceptions live only in the enumeration test with their fixture. The two parses are distinct types, branded by the view they were read under, so no gate can be handed the masked one: `Delta` and `AdvisoryDelta`, and diff --git a/openspec/changes/archive/2026-09-28-validation-parity/tasks.md b/openspec/changes/archive/2026-09-28-validation-parity/tasks.md index d6ef4130..66ca9d2c 100644 --- a/openspec/changes/archive/2026-09-28-validation-parity/tasks.md +++ b/openspec/changes/archive/2026-09-28-validation-parity/tasks.md @@ -94,9 +94,13 @@ trailer, never `--no-verify`). archive-parity suites green), 6.2, 7.1 (`owner: validation-parity` count 0 before and after, reachability green) and 9.1 (`mise run check` exit 0). Commit `docs(validate): record validation-parity evidence` -- [ ] 7.2 Run `mise run cospec -- validate validation-parity --strict` and +- [x] 7.2 Run `mise run cospec -- validate validation-parity --strict` and `mise run cospec -- archive validation-parity` as the PR branch's final - commit, after rebasing onto `main` + commit, after rebasing onto `main` — ticked by `cli-surface-parity`: the + archive ran with `--force-incomplete`, which waived the tasks gate for + this one self-referential row (it describes the archive run itself), and + both hard gates (`archive/verification-incomplete`, + `archive/scenario-preservation`) ran ## 8. Round 2 — the archive's view (review findings F1–F6) diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/.openspec.yaml b/openspec/changes/archive/2026-10-05-cli-surface-parity/.openspec.yaml new file mode 100644 index 00000000..c624de83 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/.openspec.yaml @@ -0,0 +1,3 @@ +schema: feat +created: 2026-09-28 +schemaVersion: 2 diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/blocking-changes.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/blocking-changes.md new file mode 100644 index 00000000..a66fc7cd --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/blocking-changes.md @@ -0,0 +1,42 @@ +# Dependencies + +## Blocked by + +- [x] `unknown-option-contract` — the command table the five new flags and two + `__complete` values are declared in, `ctx.parsed` on every table row, the + reachability test and the pending entries this change removes _(archived + 2026-09-28)_ +- [x] `root-resolution-parity` — `resolveRoot`'s `source`, the + `RootSelectionError`/`RawSelectionError` classes and the + `{...payload, status}` document the per-command failure codes build on, + and the upstream oracle helpers _(archived 2026-09-28)_ +- [x] `upstream-spellings` — the structural respell of relayed documents and the + `status/next-*` remedy entries `nextSteps` is spelled through _(archived + 2026-09-28)_ +- [x] `validation-parity` — the `validate.ts` it last edited (merged first, as + planned), `DUPLICATE_CLASSES` and the verbatim/advisory view split whose + `deltas/scenario-depth` exception this change cites _(archived + 2026-09-28)_ +- [x] `standalone-json-once` — one JSON document per wrapped `--json` call, so + the delegated `status`/`list` documents parse as one _(archived + 2026-09-28)_ +- [x] `pin-node-oracle` — the oracle running the pinned binary under Bun with + the product's spawn env, and errno rows compared by code and path + _(archived 2026-09-28)_ + +## Soft-blocked by + +None. + +## Notes + +The doctor and passthrough-JSON change is merging to main while this change is +planned. It isn't archived in this checkout, so it isn't listed above. The first +task rebases onto main once it lands. It edits `root.ts`, `store.ts`, +`workset.ts`, `config.ts`, `schemas.ts`, `context.ts` and `doctor.ts`, and none +of those is in this change's file windows. The rebase re-checks that before any +implementation starts. + +`archive-and-sync-parity` is next in line and consumes this change's detector +for its namespace-folder refusal. This change doesn't touch +`commands/archive.ts`. diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/design.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/design.md new file mode 100644 index 00000000..bd801f89 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/design.md @@ -0,0 +1,617 @@ +# Design + +## Context + +The proposal lists the gaps and the specs state the behavior. This section +carries only the current state the approach depends on. Every upstream fact +below was read from the pinned package's `dist/` and then probed by running the +binary under Bun, with HOME and every XDG directory redirected into a throwaway +sandbox. + +- `list`, `status` and `validate` are table rows (`core/command-table.ts`), so + their argv arrives parsed on `ctx.parsed`. The five new flags and the two + `__complete` values are declared there as pending, owned by this change, and + `parity-pending.yaml` holds the seven matching entries. The reachability test + requires a flag's entry to leave the yaml in the commit that stops the table + marking it pending. +- `status.ts` computes a cospec-typed change's matrix natively and spawns + nothing. Its only wrapped call is the text relay for a schema cospec doesn't + type. That relay is already respelled; its `--json` answer is a + `{change, type, legacy}` stub. `status --all --json` emits + `{changes, root: }`. That envelope was added to mirror the + binary's keys, and the binary's `root` is `{path, source, store_id?}`. +- `list.ts` computes every row natively from `listChanges` and `archiveMap`, + sorted by name. The binary's `list --json` rows carry + `{name, completedTasks, totalTasks, lastModified, status, nested?}`, sorted by + the newest file mtime under the change, plus top-level `warnings` (nested + folders only) and `root`. +- `validate.ts` resolves a name change-first, then spec. It lets the name beat + the bulk flags, runs every change validation at once through `Promise.all`, + and reads artifacts with an unguarded `readFileSync`. Its `delegate()` parses + the binary's issues and relays their messages untouched. +- The resolver (`core/root.ts`) throws `RootSelectionError`, and `cli.ts` turns + it into `{...jsonFailurePayload, status}` under `--json`. A raw failure is a + `RawSelectionError` with code `store_error`. `jsonFailurePayload` is a static + export per module. +- `core/remedies.ts` already carries the `status/next-*` commands and sentences, + the `validate/*` hints, and `validation/no-deltas-tip`. `respellWholeRemedy` + rewrites a field only when its whole value is one allowlisted remedy. +- `readArchiveIndex` (`core/change.ts`) throws on an unreadable archive. `list`, + `status` and `apply` reach it through `archiveMap`, and `archive` uses it for + its collision checks. + +Probed facts that change the plan's wording: + +- `status --schema` is a **schema override** + (`Schema override (auto-detected from config.yaml)`), not a filter. + `status --all --schema fix` renders every change as `fix`. An unknown name is + refused before enumeration under `--all` and before the report under + `--change`, but not with neither flag. +- The binary's unknown-item suggestion is its `nearestMatches`: the five nearest + ids by Levenshtein distance, no cap, duplicates kept + (`Did you mean: gamma, gamma, beta, alpha, mobile?`). It isn't `closest()`. +- `--type bogus` silently means no override. `--concurrency 0`, `abc`, and + `OPENSPEC_CONCURRENCY=abc` silently mean the default. `--sort bogus` means + `recent`. +- `validate --all` runs the bulk scope and ignores the name. +- An unreadable `changes/archive/` doesn't affect the binary's `list` or + `status`. An unreadable `tasks.md` is answered by the binary's runtime, not + its code. The binary confines every artifact output through + `realpathSync.native` (`FileSystemUtils.canonicalizePotentialPath`) before + reading it, uncaught. Bun on macOS opens the file to resolve it, so at mode + 000 the binary's `list` answers + `{changes: [], root: null, status: [{code: "list_error"}]}`, exit 1, and its + `status --change` answers `change_error`, each naming `realpath`. Bun on Linux + (CI run 36547287646, as a non-root user) and Node anywhere resolve it without + opening it: the binary lists and reports the change, exit 0, its + `countTaskFile` counting the unreadable file as 0 tasks, and `status` marks + the `tasks` artifact done. Task 11.12 matches both through the binary's own + answer (D4, D6), never a prediction of it. +- `list` with no OpenSpec root is refused (`no_openspec_root`, exit 1), so a + `list` whose rows come from the binary answers that refusal where cospec used + to print `No active changes.`; `status` with no root answers the + no-active-changes document on an implicit root, as before. +- `__complete schemas` describes every schema as `schema`; cospec keeps each + schema's `description` from `schemas --json` (D9), and the ids and order + match. +- `status --change ` is refused with `change_error`, exit 1. + `status --all` carries the folder as `{changeName, status: [change_error]}` + and exits 1. +- A change directory with no `.openspec.yaml` resolves to the root's + `config.yaml` `schema:` (`feat` in a cospec root). + +## Goals / Non-Goals + +**Goals:** + +- One delegated wrapped call per `status --json` or `list` invocation, never one + per change. +- Keep every cospec key and value, and prove it in the same oracle that proves + the upstream keys. +- Text-mode `status` on a cospec-typed change stays spawn-free, unless the + binary decides whether the change can be reported at all (an entry cospec + cannot read, a `.openspec.yaml` the binary refuses, a schema the binary cannot + load). + +**Non-Goals:** + +- `cospec archive`'s namespace-folder refusal (`archive-and-sync-parity`'s row). + This change exports the detector it will call. +- The binary's interactive validate selector. cospec never prompts, and with no + item and no flag it validates everything, which is its documented opinion. +- The noun-form `change validate` / `spec validate` commands. Deprecated + upstream, never in cospec. + +## Decisions + +### D1. Tracks and files + +| Track | Files (exclusive within this change) | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T1 | `core/change.ts`, `core/rules/meta.ts`, plus an export hunk in `core/change-metadata.ts` and an import hunk in `commands/new.ts` | +| T2 | `commands/status.ts`, `core/upstream-keys.ts` (new) | +| T3 | `commands/list.ts` | +| T4 | `commands/validate.ts`, `core/report.ts`, `test/unit/commands/validate.test.ts`, `test/unit/rules/views.test.ts`, `core/rules/views.ts` (its comment only), `test/contract/validation-parity.test.ts` (two rows) | +| T5 | `commands/complete.ts`, `core/completions/{spec,bash,zsh,fish}.ts`, `test/integration/completion.test.ts` | +| T6 | `test/contract/cli-surface.test.ts` (new), `test/contract/support/key-oracle.ts` (new) | +| T7 | `commands/apply.ts` and its unit test | +| Docs | the five docs pages, `.agents/shared.md` (then `agents:sync`) and the archived `validation-parity/tasks.md` | + +Two files are shared across tracks, which the roadmap's track table doesn't +list. `core/command-table.ts` and `test/contract/parity-pending.yaml` get one +hunk per flag, in the task that implements the flag (T2 `--schema`, T3 `--sort`, +T4 `--type`/`--report`/`--concurrency`, T5 the two `__complete` values). The +reachability test requires the yaml line to go in the same commit. Tasks are +sequential in one worktree, so no two hunks are ever in flight. The T1 additions +to `change-metadata.ts` and `new.ts`, T5's `spec.ts` and T4's two test files are +also outside the roadmap's list. Each is the smallest hunk the track's own fix +needs. + +Per-command resolver failure codes are handled inside each command's `run()`, so +`cli.ts` and `root.ts` stay untouched (D10). + +### D2. The nested-change detector (T1) + +`findNestedChangesIn(changesDir, name)` in `core/change.ts` is a synchronous +port of the binary's `utils/nested-change` module. It returns +`{name, nested: string[]} | undefined`, and `describeNestedChange(finding)` +returns the binary's sentence verbatim. That sentence names +`openspec/changes//` paths, not commands, so it stays unspelled. The three +`looksLikeChange` signals are ported one-for-one: + +1. **Root marker.** Any of `.openspec.yaml`, `proposal.md`, `tasks.md`, + `design.md` present as a regular file. +2. **Populated `specs/`.** `hasAnyFileUnder(specs/)`: any non-dot file or + symlink at any depth. ENOENT counts as empty. Any other errno is caught by + the caller, as the binary's `.catch(() => false)` does. +3. **Schema output.** The directory's schema resolves as the binary resolves it + (its `.openspec.yaml` `schema:`, else `config.yaml` `schema:`, else + `spec-driven`), across the project, user (D8) and package tiers. The signal + is set when any artifact's `generates` glob matches a file. A schema that + can't be resolved gives no signal. The match is the binary's own + `artifactOutputExists`, ported line for line in `core/glob.ts` (its + confinement checks and linked-cycle refusal included, each a throw that gives + no signal), over the binary's matcher: cospec pins `fast-glob` to the version + the pinned openspec resolves, so braces, numeric ranges, extglobs and + negation read as the binary reads them. `glob.test.ts` holds the version, the + brace expansions, the compiled regexes and the answers to the binary's + modules. + + **Rejected:** loading fast-glob from the wrapped package's tree at run time + (a standalone install runs the embedded single-file bundle, with no tree to + load from), and re-implementing picomatch and braces by hand (a second + matcher to keep in step with the binary's). + +The guards follow the binary too: `hasOwnFile` means any non-dot, non-directory +entry. Candidates skip `archive` and dot-names. The collect never descends into +a directory that looks like a change. Depth is bounded at 3 below the folder, +the binary's `MAX_NESTING_DEPTH`. Every unreadable directory reads as empty. The +results are sorted. + +**Rejected:** a cheaper detector with only the root-marker signal. The binary's +own reasoning holds for cospec too: a hand-made change that starts from delta +specs, or a custom schema generating into a subdirectory, would read as a +namespace folder and be refused. + +`listChanges` drops dot-directories, as the binary's `getAvailableChanges` does, +so status and validate enumerate what the binary enumerates. `list` follows the +binary's own enumeration (D6), which keeps them. + +`meta/nested-change` (ERROR, `core/rules/meta.ts`) has path `.` and the +explanation as its message. Its hint is the binary's two next-step bullets, +joined. Two more rules land in the same file for T4: + +- `meta/unreadable-artifact`: `could not read ()`. +- `meta/item-missing`: `no change directory at openspec/changes//` or + `no living spec at openspec/specs//spec.md`. + +### D3. Additive merge (T2, reused by T3) + +`core/upstream-keys.ts` exports `mergeUpstream(cospec, upstream, identity)`. It +copies every upstream key absent from cospec's object. It recurses into keys +both documents carry when both values are plain objects. It merges arrays entry +by entry by the identity function (`name`/`change`, `changeName`/`change`, +artifact `id`). An upstream entry with no cospec counterpart is appended, except +into the in-progress status entry's `artifacts: []`: that empty array is +cospec's own pre-existing value, so the binary's artifacts are left out of its +entry before the merge and the key oracle compares it as `kept`. It never +overwrites a cospec value: a key present on both sides whose values differ keeps +cospec's, and the key is returned in a `collisions` list that the oracle's unit +test inspects. The `root` of the status documents is the one planned exception. +`status.ts` sets it to the resolver's `{path, source, store_id?}` (the same +object the binary prints) before merging, so no collision arises. + +**Rejected:** porting `planningHome`, `artifactPaths`, `actionContext` and the +rest natively. They're the binary's facts, they differ across the accepted +version range, and a port would drift. Delegation lets a newer in-range binary's +keys flow through, and the oracle pins them against the pinned one. + +### D4. Status: next step, delegation, --schema (T2) + +**`next` is single-sourced.** `resolveNext(statuses, required, id)` takes the +artifact states in build order as `done | ready | blocked | skipped` and the set +of artifacts the change requires. It returns the first ready required artifact, +else `cospec apply ` once every required one is done, else the first ready +artifact of any kind, else nothing — so an unwritten optional artifact never +holds a change back from its gate (the spec's "Required artifacts done points at +the gate" scenario). For a cospec type the states come from cospec's matrix: +`done` is the file present, and `ready` is not done with every requirement done, +where a `skip_specs`-skipped `specs` counts as done. The declared order is the +build order, and a contract row checks that per type against the binary's +`artifacts[]` order. For any other schema the states come from the delegated +document's `artifacts[].status`, with `applyRequires`: every change on such a +schema is answered from the binary's status, whether or not any artifact is +written, since only its own schema names its artifacts (task 11.5; before, a +change with none of cospec's file names read as an empty change pointing at +`proposal`). Only a cospec-typed change is an empty-change entry, and its `next` +is `resolveNext` over its own matrix with nothing done, which is +`cospec instructions proposal` for every type. The JSON `next` and the human +`Next:` line both print its return value. `nextSteps` isn't recomputed: it's the +binary's value from the delegated document, each element passed through +`respellWholeRemedy` (the `status/next-*-sentence` entries). + +**Why `next` doesn't copy `nextSteps`:** the binary's decision never finishes +while an optional artifact such as `feat`'s `design` is unwritten. cospec knows +which artifacts are optional and points at its gate instead. Both keys are in +the document, and each says what its own tool would do. + +**One delegated call.** `--json` runs `openspec status --change --json` (or +`--all --json`), with `--schema` forwarded when given, threaded with +`root.storeArgs`, in `root.cwd`. Its expected exit codes are {0, 1}. The stdout +deny-list is the existing one. The post-condition is exactly one JSON document +that either carries `changeName === id` (or `changes[]` for `--all`) or carries +a `status` array. + +The merge follows D3. A cospec-typed entry keeps cospec's verdict. If the binary +fails for it, the binary's `status` diagnostic is merged in and the exit code is +cospec's. For a schema cospec doesn't type, the exit code is the binary's. + +**An unreadable `tasks.md` (task 11.12).** It is the one read cospec and the +binary both make whose outcome is the runtime's: whether the binary reports the +change depends on its `realpath`. So when cospec's own read of a change's +`tasks.md` fails (an observed read, never a `stat`/`access` prediction), the +binary decides whether the change can be reported, through the same one +delegated call, made in text mode only then or for a schema the binary cannot +load (below), so text-mode `status` stays spawn-free otherwise. Where the binary +refuses it (Bun on macOS), its failure is the answer: its document under +`--json`, `cospec status: ` in text, and under `--all` a failure entry +carrying its message, into which its `{changeName, status}` merges, exit 1 — +before, cospec's own read refused first and named `open` where the binary names +`realpath`. Where the binary reports it (Linux), so does cospec, the file +counted as no tasks with a `tasks_unreadable` warning. + +**A schema the binary cannot load (task 13.1).** cospec grades a cospec-typed +change from its own type matrix, but the binary loads the change's schema before +it reports anything, and refuses the change when the schema is missing from +every tier, unreadable, unparsable or invalid. So when `loadSchema` (the port of +the binary's `resolveSchema`) fails for the change's schema, text mode makes the +same one delegated call, singly and for the sweep, and the binary's refusal is +the answer as above: `cospec status: ` in text, a failure entry under +`--all`, exit 1. Before, text mode rendered cospec's own table with +`gate: clear` and exited 0 where `--json` and the binary both refused. + +**Metadata the binary refuses (task 13.2).** Before it loads the schema, the +binary reads the change's `.openspec.yaml` through `readChangeMetadata`, which +refuses a file it cannot read, one that is not YAML, one that fails +`ChangeMetadataSchema` (a `created` not `YYYY-MM-DD`, an empty `goal`, a +non-boolean `skip_specs` or `retire_capabilities`, an `affected_areas` not a +list of non-empty strings, an `initiative` not exactly a kebab-case +`{store, id}`), and one naming a schema `listSchemas` does not list. cospec's +own reader keeps only `schema:` and drops the rest, so such a change graded +clean. `changeMetadataRefused` (in `core/change-metadata.ts`, beside the ported +`ChangeMetadataSchema`) mirrors that read, and when it refuses, text mode makes +the same one delegated call and relays the refusal as above. Before, text mode +exited 0 with cospec's table where `--json` and the binary exited 1 with +`Invalid metadata`. + +`changeMetadataRefused`'s own `listSchemas` call can itself fail with an errno +(the project `openspec/schemas` unreadable or not a directory) — distinct from +`loadSchema`'s listing, which task 13.1's call site already guards. A naive port +let that escape `binaryDecides` uncaught in both the `--all` sweep's +upstream-fetch decision and the `--change` lookup's own decision, neither inside +a per-change try/catch, crashing the whole command with a bare top-level +`cospec: ENOTDIR …` line instead of the per-change refusal `--json` already +answered correctly (task 13.3). `changeMetadataRefused` now catches it the same +way it catches every other read failure in that function: refused, not thrown. + +**Rendering a schema cospec doesn't type.** Text mode renders the delegated +document with a port of the binary's `printStatusText`: `Change:`, `Schema:`, +`Change root:`, `Progress:`, the `[x]/[ ]/[-]/[~]` lines, and +`All planning artifacts complete!`. The `Next:` line comes from `resolveNext`. +This replaces the free-text relay, so nothing is regex-respelled. + +The same path answers: + +- a name that resolves nowhere, where the binary refuses with `Unknown schema`, + exit 1; +- a change directory with no `.openspec.yaml`. Its schema comes from + `config.yaml`/`spec-driven` as in D2 signal 3. A cospec type gets cospec's + matrix at `schemaVersion` 1, and any other name goes this path. The same + resolution is used by `status` only: `validate`, `apply` and `archive` keep + their `meta/openspec-yaml` ERROR for a missing file. + +**`--schema`** is forwarded to the delegated call. It also replaces the change's +type for cospec's matrix when it names a cospec type, and otherwise sends the +entry down the delegation path. Text mode with `--schema` checks the name +exactly as the binary does (`validateSchemaExists`: project, user and package +tiers). When the name is unknown, it prints the binary's +`Schema '' not found. Available schemas:\n …` from a delegated `--json` +call, so the list of available schemas is the binary's. + +**Namespace folder.** `--change` is refused with the explanation +(`change_error`). `--all` gets a `{change, error}` entry, into which the +binary's `{changeName, status}` merges. + +**Unreadable archive.** An errno other than ENOENT from the archive index read +is caught in `status.ts`, `list.ts` and, for `validate` and `apply` (task 11.7), +`readValidateContext` in `validate.ts`, never inside `readArchiveIndex`. The +binary's `validate` and `instructions apply` never read the archive either. +`archive` keeps `buildValidateContext` and `archiveMap`, so its collision check +and its on-disk verification still refuse. The gate is computed from an empty +index. That can only err toward `blocked`, never a false `clear`. The same holds +for the validate rules the archive feeds: a blocker or a revert citation naming +an archived change reads as dangling, an issue added, never one removed. +`apply`'s blocker self-heal only checks boxes for archived changes, so with an +empty index it writes nothing it would not write otherwise. A `warnings` entry +`{code: "archive_unreadable", message}` (`--json`, on every document `validate` +and `apply` print past the read) or a stderr line (text) names the directory. An +unreadable `tasks.md` the binary reports past counts as no tasks, as the +binary's `countTaskFile` counts it, with a `{code: "tasks_unreadable", message}` +warning naming the file the same way, so the read is never silently dropped +(`readChangeTasks`, shared with `list`). Any other read failure while computing +an entry becomes the `change_error` document (`--change`) or a failure entry +(`--all`), as the binary answers. + +### D5. The key oracle (T6) + +`test/contract/support/key-oracle.ts` walks the binary's document and cospec's +document together. Arrays of objects are matched by an identity per path, and +each shared path falls into one class: + +| Class | Paths | Check | +| ----------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| exempt | `version` | skipped | +| timing | `durationMs`, `lastModified` | present, same JSON type | +| verdict | validate `items[].valid`, `items[].issues[*]`, `summary.totals.*`, `summary.byType.*.*` | present, same JSON type | +| collision (named) | validate `items[].type` | cospec's value is the schema (change) or absent (spec), and `kind` equals the binary's `type` | +| respelled | status `nextSteps[*]` | equals `respellWholeRemedy(binary value)` | +| equal | everything else | deep-equal | + +A second check snapshots cospec's pre-existing key set per command. The snapshot +is written into the test from the shapes documented before this change, and +every one of those keys must still be present with the value cospec computes +natively. + +| Row | cospec argv | Binary argv | Fixture | +| ----------------- | ----------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------- | +| list | `list --json` | `list --json` | three changes with staged mtimes (`utimes`), a namespace folder, one change with tasks | +| list --specs | `list --specs --json` | same | two living specs | +| status --change | `status --change alpha --json` | same | `feat` with only `proposal.md` | +| status --all | `status --all --json` | same | the list fixture | +| validate single | `validate alpha --json` | same | a `feat` change with one delta | +| validate bulk | `validate --all --json` | same | the list fixture plus a spec | +| validate findings | `validate --all --report findings --json` | same | same | +| apply unknown | `apply nope --json` | `instructions apply --change nope --json` | any root | + +The oracle's unit test hands it a document whose `root` is a string and one +missing an upstream key, and expects both to fail. + +### D6. List (T3) + +`list` always makes one delegated `openspec list --json` call, with +`--sort name` forwarded when the flag's value is `name`, threaded and spawned +like `--specs`. The binary's rows set the order and the membership. Each gets +cospec's native columns by name, computed as today, and D3 merges the rest. Text +mode renders cospec's table in the binary's order. A namespace row shows +`not a change` in the tasks column and has `state: 'not-a-change'`. That +replaces a value that was wrong (it called the folder an empty change), rather +than letting an upstream key overwrite a cospec one. Each of the binary's nested +warnings is printed after the table as `Warning: `. + +A delegated failure document (`status` present, exit 1) is relayed as one +document under `--json` and as its messages on stderr otherwise, exit 1. That +covers every read the binary refuses, such as an unreadable change directory or +a `tasks.md` its runtime's `realpath` refuses (Bun on macOS). Where the binary +lists the change instead (Linux), an unreadable `tasks.md` counts as no tasks +with D4's `tasks_unreadable` warning. An unreadable archive is caught as in D4. +A failure reading a cospec-only file, `blocking-changes.md`, becomes +`error: ` on that row, and the command exits 1, the same rule +`status --all` applies to a per-change failure. `list --specs --json` copies the +delegated `root` into cospec's `{version: 1, specs}` document. + +**Rejected:** porting `getLastModified` and task counting natively to keep text +mode spawn-free. `completedTasks`/`totalTasks` are the binary's own count, +schema-aware through `apply.tracks`, and a native copy would be a second count +under the binary's key. + +### D7. Validate (T4) + +**Precedence**, in the binary's order: + +1. `--report` request validation, before the root is resolved. +2. `--archived`. +3. Any bulk flag, with the name ignored. +4. A name: a forced `--type`, else membership. +5. No name and no flag: everything, cospec's opinion. + +**Item resolution** ports `validateDirectItem`: `normalizeType`, a membership +check over `listChanges` ids and living spec ids, the ambiguity refusal and +`nearestMatches` (a port of the binary's `utils/match`). With a forced type, the +`folderStyleNameProblem` guard runs per segment for a spec, then the item is +validated. A change dir or spec file that doesn't exist becomes one +`meta/item-missing` ERROR. Text refusals keep cospec's `cospec: ` prefix before +the binary's message. The ambiguity fix is `Pass --type change|spec.` on every +root, which is the `validate/ambiguous-noun-form` remedy's cospec spelling. + +**`--report`** refusals are the binary's four messages and fix, verbatim. +`core/report.ts` gains `toFindings(items, scope)`. It builds +`{version: 1, report: {kind, version: "1.0", scope, returnedItems, totalItems}, itemFindings, summary, root}`. +`report.version` is upstream's nested key, so cospec's own top-level `version` +stays 1. Text `findings` prints cospec's human report with issue-free items +folded into the counts line, which already happens for valid specs. The exit +code is `exitCode(items, strict)` over every item, `full`'s. + +**`--concurrency`**: a small promise pool with bound +`normalize(--concurrency) ?? normalize(OPENSPEC_CONCURRENCY) ?? 6`, where +`normalize` is the binary's `parseInt`-positive rule. Reports are collected by +index, so the order is independent of completion. The pool bounds change +validations, each of which may spawn one wrapped `validate `. The single +`--specs` delegation stays one call. + +**Keys**: `root` from the resolver's `{path, source, store_id?}`, +`items[].durationMs` measured around each item, and `summary.totals` +(`{items, passed, failed}`) and `summary.byType` for the kinds in scope, +computed in `toJson` beside cospec's `errors`/`warnings`/`byRule`. +`items[].type` stays the schema (D5 collision). + +**Unreadable artifacts**: `loadChange` reads through one helper that records +`{path, code}` for any errno. `validateChange` short-circuits to the +`meta/unreadable-artifact` ERRORs, the same way the `.openspec.yaml` +precondition does, and delegates nothing. A namespace folder short-circuits the +same way, to `meta/nested-change`. + +**Relays**: `mapDelegated` passes each message through `respellRemedies`. + +**`--archived`** (task 11.6) is one wrapped `validate --archived --json` call +with expected exit codes {0, 1} and a post-condition of one JSON document that +is either a report (`items[]`) or the binary's failure document (`status[]` with +an error). A report renders through cospec's renderer as before. A failure +document (an unreadable `openspec/changes/archive/` is `validate_error` +`EACCES … scandir`) is the answer: under `--json` that document, each `message` +and `fix` through `respellRemedies`, with the binary's exit code; in text +`cospec: ` (and `Fix: `) on stderr. Whether the binary is too old +for the flag is read from its `--version` (`openspecBelow(version, "1.9.0")`, +one memoized read shared with the version assertion), never inferred from its +output: before, any answer without `items` was reported as "it needs OpenSpec + +> =1.9.0", misattributing a delegated failure. + +**Dedupe**: the `archive/target-invalid` entry becomes a function matcher. It +checks the fixed head with an anchored regex, splits the rest on `\n`, and tests +each line against a per-line regex with no repeated group. The header span is +`.*` before a fixed suffix, so a `"` inside the header matches. The ReDoS unit +test uses quote-heavy lines and a non-matching tail, and a bound the pre-fix +pattern provably exceeds. A contract row runs a living spec with a duplicated +`Widget "quoted" name` header. + +**The scenario-depth exception.** `views.ts` and `views.test.ts` name the +contract row that proves it. In that row the binary's `archive -y` moves a +change whose delta holds a commented-out `### Scenario:`. `docs/validation.md` +states the standing rule verbatim. + +### D8. Schema classification (T1) + +`resolveSchema`'s user tier reads `userSchemasDir()`: `XDG_DATA_HOME`, then +`LOCALAPPDATA` on win32, then `~/.local/share`, each with `openspec/schemas`. +That's the binary's `getGlobalDataDir`. The helper already exists privately in +`change-metadata.ts`, and a copy of it exists in `new.ts`. T1 exports the +`change-metadata.ts` one, and `change.ts` and `new.ts` import it, so one place +computes the directory. + +### D9. Completion (T5) + +`COMPLETE_SOURCES` gains `schemas` and `archived-changes`, and the source is +lowercased before matching. `schemas` calls `openspec schemas --json` and emits +each `name`, described by its `description` or else `schema`. The order is the +binary's. + +`archived-changes` reads `openspec/changes/archive/` under the resolved root, +non-dot directories, sorted, each described `archived change`. It makes no +wrapped call. The binary's scripts don't consume this source either, but it's +reachable. The binary reads the archive under `process.cwd()` rather than the +selected root. cospec reads it under the resolved root, a deliberate superset: +the answers agree in a local root, and under `--store` or a store pointer cospec +completes the store's archive, which is the one every other command there +operates on. + +`spec.ts` gains `DynamicSource` `schemas`, `FLAG_VALUES` for every row that +declares `--schema`, and subcommand positionals for `schema which`, +`schema validate` and `schema fork`. The three generators render both. + +### D10. --json failure documents (T2, T3, T4, T7) + +Each of `status`, `list`, `validate` and `apply` wraps its `resolveRoot` call +and catches every `RootSelectionError` under `--json`. It prints +`rootSelectionDocument(error, payload)` and returns 1. The payload is the +binary's per-command null-shape: `{changes: [], root: null}` for `list` and +`status --all`, `{specs: [], root: null}` for `list --specs`, none for +`status --change`, `validate` and `apply`. The payload applies to every +selection diagnostic (an unknown store, a malformed pointer, …), whose code +stays the binary's. For a `RawSelectionError` only, the generic `store_error` +code is replaced by the command's own: `list_error`, `change_error` (`status`, +`apply`) or `validate_error`. Nothing reaches `cli.ts`'s generic handler from +these four commands under `--json`, so `cli.ts` and `root.ts` are untouched and +no module needs a flag-dependent `jsonFailurePayload` export. Text mode is +unchanged, and the error propagates as today. + +**`apply`**: its four early exits (no root, unknown change with the +`Did you mean` suggestion folded into the message as `status` does, a failed +legacy delegation, a failed step-5 call) each print one +`{status: [{severity: "error", code: "change_error", message, fix?}]}` under +`--json`. `change_error` is the code the binary's `instructions apply` uses for +the same lookups. A unit test drives every path. + +### D11. Every upstream string is probed, never hand-typed + +Each string below is read from the pinned binary's output in a contract row, not +copied into a test: + +- the nested-change explanation and its `nested_change_directory` warning; +- `list_error` and `change_error` on an unreadable `tasks.md`; +- `ambiguous_item`, `unknown_item` (with the suggestion list) and + `invalid_item`; +- the four `invalid_validation_report_request` messages and their fix; +- `Schema '' not found. Available schemas:`; +- `Unknown schema ''. Available:`; +- the `nextSteps` sentences; +- the `__complete` `schema` and `archived change` descriptions and the schema + order. + +Where cospec prints one of these in its own text, the unit test imports the same +constant the command uses. + +### D12. Docs + +The page that owns each fact is updated: + +- `reference/commands.md`: the list/status/validate/`__complete` rows, the + flags, the JSON keys and the BREAKING items. +- `reference/validation-rules.md`: the three `meta/*` ids and the + `--json`/findings shapes. +- `concepts/how-it-relates-to-openspec.md`: namespace folders are now detected, + not relayed, and `--schema` is an override. +- `docs/architecture.md`: the additive-merge and one-delegated-call pattern, and + the detector's home. +- `docs/validation.md`: the standing rule. + +`.agents/shared.md` gains one Engineering-discipline paragraph, since this +change sets a convention every later JSON change follows (`archive --json` +next). The paragraph: upstream keys are added to cospec's JSON documents from +one delegated call, merged by identity through `core/upstream-keys.ts`; no +cospec key or value is removed or changed; `version` stays 1; and the key oracle +(`test/contract/support/key-oracle.ts`) with its named collision list is the +gate. `mise run agents:sync` propagates it to `CLAUDE.md` and `AGENTS.md`. + +`test/contract/support/remedy-sources.ts` holds no entry owned by this change. +Every upstream sentence the new relays print (the `status/next-*` commands and +sentences, `validate/*`, `validation/no-deltas-tip`) is already an allowlist +entry, and `remedy-enumeration.test.ts` stays green unchanged. + +## Operational surface + +The interactive surface is the human and `--json` output of `list`, `status`, +`validate`, `apply`'s failure paths and `__complete`. + +- Five flags become accepted. +- JSON documents gain keys. The one value change is `status`'s `root`. +- Three rule ids are new. +- The `list` default order changes, and exit codes change only as BREAKING + lists. + +`status --json` and every `list` now make one wrapped spawn each, where before +they made none (human `status` on a cospec-typed change still makes none). That +adds the pinned binary's start-up time to those commands. There's no bind +address, container, secret or connection limit. The wrapped binary is still +resolved by path at the pinned version, inside the accepted range. Every +contract row spawns it the way the suite does, under Bun with the product env +and a sandboxed HOME. + +## Risks / Trade-offs + +- [`list` and `status --json` depend on the wrapped call succeeding] → The + binary's own failure is relayed as its document, the exact parity answer. + cospec-only reads fail per row. +- [The named `items[].type` collision contradicts the roadmap's single-exemption + wording] → Kept because the value is documented cospec contract + (`validation-rules.md`), and `kind` already carries upstream's meaning. The + oracle pins the collision, so any second collision fails. +- [Changing `status`'s `root` from a string to an object breaks a caller reading + it as a path] → BREAKING in the proposal. The key was added to mirror the + binary's key, and an object `root` is what the binary's callers expect. +- [A newer in-range binary adds status keys] → They flow through D3 unchanged. + The oracle pins the pinned binary's set. +- [An unreadable archive makes the gate read `blocked`] → Conservative by + construction, with a warning naming the cause. It never reads `clear` falsely. +- [The detector's schema-output signal resolves a schema per candidate] → + Bounded to directories with no marker and no `specs/` file, which is the + binary's own cost profile. diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/proposal.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/proposal.md new file mode 100644 index 00000000..f9041139 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/proposal.md @@ -0,0 +1,248 @@ +# Proposal + +## Why + +`cospec list`, `cospec status` and `cospec validate` are cospec's own +implementations, and they have drifted from what the wrapped binary answers for +the same invocation, so a script or agent that swaps `openspec` for `cospec` +loses flags, JSON keys and diagnostics it relied on. Each gap below was probed +against the pinned binary run under Bun in a sandboxed HOME: + +- **Missing flags.** `list --sort `, `status --schema `, + `validate --type `, `validate --report ` and + `validate --concurrency ` are refused as pending. `__complete` serves + `changes`, `specs` and `types`, but not upstream's `schemas` and + `archived-changes` sources. +- **Missing JSON keys.** cospec's `--json` documents lack upstream's `root`, + list's `changes[].{name, completedTasks, totalTasks, lastModified, status}`, + status's `changeName`, `schemaName`, `planningHome`, `changeRoot`, + `artifactPaths`, `isPlanningComplete`, `isComplete`, `applyRequires`, + `nextSteps`, `actionContext` and per-artifact + `outputPath`/`status`/`requires`, and validate's `items[].durationMs` and + `summary.{totals, byType}`. +- **No next step mid-build.** `status` prints a next command only for a change + with no artifacts yet. The binary prints `Next: …` for every change that has + one. +- **Namespace folders.** A folder under `openspec/changes/` that only wraps + nested change directories (`changes/mobile/refresh-token/`) is reported by + cospec as an empty change: `status --change mobile` says "in progress — no + artifacts yet" and exits 0, `validate mobile` reports a missing + `.openspec.yaml`. The binary names the folder for what it is ("is not a + change: it is a folder wrapping …") on status, list and validate. +- **Schemas cospec doesn't type.** For a forked or `spec-driven` change, + `status --json` returns a three-key `{change, type, legacy}` stub, and + `status --all` tells the reader to go run another command. For a schema that + resolves nowhere, `status --json` exits 0 while the binary (and cospec's own + text mode) exits 1. A change directory with no `.openspec.yaml` is classified + as legacy, where the binary resolves the root's `config.yaml` default schema. +- **Item resolution in validate.** `validate ` tries change then spec and + never notices a name that is both, has no `--type`, prints a bare unknown-item + line with no suggestion, and lets the name win over `--all`/`--changes`/ + `--specs`, where the binary runs the bulk scope. +- **Crashes instead of answers.** An unreadable artifact makes `validate` throw. + An unreadable `openspec/changes/archive/` makes `list` and `status` crash with + no JSON document, although the binary never reads it there. An unreadable + `tasks.md` crashes `list`, where the binary answers with a `list_error` + document. `apply --json` prints plain stderr and no document. A raw + resolver failure under `--json` carries the generic `store_error` code where + the binary reports a per-command code and payload. +- **Relays and lookups.** `validate` relays the binary's issue text without + respelling its `openspec …` remedies. cospec's schema classification looks for + user schemas under `~/.config`, a directory the binary never reads. +- **A slow dedupe pattern.** The `archive/target-invalid` entry in + `DUPLICATE_CLASSES` no longer matches a quoted requirement header (so one + defect is reported twice), and its guard test would not fail on the old + exponential pattern. + +## What Changes + +- **Nested-change detector** (`core/change.ts`). A port of the binary's + detector: a directory under `changes/` is a namespace folder when it carries + no change-root marker (`.openspec.yaml`, `proposal.md`, `tasks.md`, + `design.md`), no file anywhere under `specs/`, no output of the schema it + resolves to, and no file of its own, and at least one subdirectory within + three levels does look like a change. `archive` and dot-directories are never + candidates. The new `meta/nested-change` ERROR carries the binary's + explanation verbatim. +- **`status`.** + - Every entry that has a status carries `next`, and the human output prints a + `Next:` line, both from one function: the first ready artifact the change + requires, else the first ready artifact, else `cospec apply ` once every + required artifact is done. The empty-change `next` keeps its spelling. + - `--json` adds every key the binary's own `status --json` document carries, + from one delegated call per invocation. `nextSteps` is the binary's own + value with its commands spelled `cospec`. + - `--schema ` is accepted with the binary's meaning, a schema override + (not a filter). An unknown name gets the binary's refusal. + - A change whose schema cospec doesn't type (forked, `spec-driven`, or + resolving nowhere) is answered from the binary's `status --json` document: + rendered as the binary renders it in text, merged into cospec's + `{change, type, legacy}` keys under `--json`, with the binary's exit code. + - A change directory without `.openspec.yaml` resolves its schema the way the + binary does, from `config.yaml` and else `spec-driven`, grandfathered at + `schemaVersion` 1. + - A namespace folder is refused (`--change`) or reported as a failure entry + (`--all`), with exit 1, as the binary does. + - An unreadable archive index no longer crashes: the gate is computed from an + empty index and a warning names the directory. Any other read failure is a + `change_error` document. +- **`list`.** + - `--sort `, default `recent`. An unknown value means `recent`, + as in the binary. + - Rows carry the binary's own entry keys and `nested`, and the document + carries `root` and `warnings`, all from one delegated `openspec list --json` + call. cospec's own columns stay exactly as they are. + - A namespace folder's row reads `not a change` and its `state` is + `not-a-change`, with the binary's warning after the table. + - `list --specs --json` gains `root`. + - An unreadable archive lists normally, with a warning. A read failure the + binary refuses is relayed as the binary's `list_error` document, and a + failure only cospec's columns hit becomes a per-row `error`. +- **`validate`.** + - `--type change|spec` forces the kind, and an unrecognised value is ignored, + as the binary ignores it. A name that matches both a change and a spec is + refused with the binary's `ambiguous_item` message and fix. An unknown name + gets the binary's `unknown_item` message with its nearest matches, and a + path-shaped name the binary's `invalid_item` refusal. + - `--all`, `--changes` or `--specs` beside a name runs the bulk scope and + ignores the name. + - `--report full|findings` gets the binary's four request-validation refusals + (`invalid_validation_report_request`), checked before the root resolves. + `findings` emits the binary's findings document inside cospec's envelope, + and its exit code is always `full`'s. + - `--concurrency ` bounds the change validations that run at once, falling + back to `OPENSPEC_CONCURRENCY` and then 6. A value that isn't a positive + integer is ignored, as in the binary. + - `--json` adds `root`, `items[].durationMs` and `summary.{totals, byType}`. + - An unreadable artifact is a `meta/unreadable-artifact` ERROR, and a forced + `--type` naming nothing on disk is a `meta/item-missing` ERROR. + - Delegated issue text and the `--archived` fallback relay are spelled through + cospec's remedy allowlist. + - The `archive/target-invalid` dedupe matches a quoted header in linear time. +- **`__complete`** gains the `schemas` source (delegated to + `openspec schemas --json`) and the `archived-changes` source (a walk of + `changes/archive/`), and every source name is case-insensitive. The generated + bash, zsh and fish scripts complete `--schema` values and the + `schema which|validate|fork` positionals from `schemas`. +- **`apply`**: every early exit under `--json` (no root, unknown change, a + failed wrapped call) is one `{status: [{severity, code, message, fix?}]}` + document, exit 1. +- **Per-command resolver failures.** Under `--json`, a raw resolver failure + carries the binary's per-command code (`list_error`, `change_error`, + `validate_error`) and payload (`{changes: [], root: null}` for list and + `status --all`, `{specs: [], root: null}` for `list --specs`). +- **Schema classification** reads user schemas from the directory the binary + reads: `$XDG_DATA_HOME/openspec/schemas`, else `%LOCALAPPDATA%` on Windows, + else `~/.local/share/openspec/schemas`. +- **JSON additivity.** Every upstream key is added without removing a cospec key + or changing a cospec value, and every cospec envelope keeps `version: 1`. A + contract key oracle runs each command against the pinned binary and fails on a + missing upstream key, on an upstream value cospec reports differently, and on + any key both tools emit with different values, outside a named collision list: + `version`, and validate's `items[].type`, which cospec keeps as the change's + schema (upstream's `change|spec` is cospec's `kind`). The `root` of the + `status` documents becomes upstream's `{path, source}` object. cospec added + that key to mirror the binary's key, but gave it the wrong value shape. +- **Docs.** `docs/validation.md` gains the standing rule for gate-rule views, + and the enumeration test's `deltas/scenario-depth` exception cites a + differential fixture that proves it. The archived `validation-parity` record's + unticked task 7.2 is ticked, with a note on how that archive ran. +- **BREAKING:** + - `cospec list` now orders by most recent change first. Pass `--sort name` for + the old order. + - `cospec list` outside an OpenSpec root answers OpenSpec's own + `no_openspec_root` refusal, exit 1, where it printed `No active changes.` + - `cospec validate --all|--changes|--specs` validates the bulk scope, + not the one item. + - An ambiguous `validate` name is refused, and an unknown one prints the + binary's message. + - A namespace folder makes `status --change` and `status --all` exit 1 and + `validate` fail. `validate`, `apply` and `archive` (which share + `validateChange`) now report it as `meta/nested-change`, not + `meta/openspec-yaml` — a script grepping the old rule id for this case needs + the new one. + - `status --json` on a schema cospec doesn't type exits 1 when the binary + does. + - `status --change` and `status --all`, in text and `--json`, exit 1 with the + binary's message on a cospec-typed change whose schema the binary cannot + load (removed, unreadable, unparsable or invalid), where they reported it. + - `status --change` and `status --all`, in text and `--json`, exit 1 with the + binary's `Invalid metadata` message on a cospec-typed change whose + `.openspec.yaml` the binary refuses (a malformed `created`, an empty `goal`, + a non-boolean `skip_specs`, …), where they reported it. + - `status` types a change directory without `.openspec.yaml` by `config.yaml`. + `validate`, `apply` and `archive` still refuse it. + - The `root` key of the `status --all` and no-active-changes documents is an + object, not a path string. + +## Capabilities + +### New Capabilities + +- `nested-change-detection`: how a namespace folder under `openspec/changes/` is + recognised and reported by status, list and validate. +- `json-document-parity`: upstream keys are added to cospec's JSON documents + without changing cospec's own, checked by a key oracle, and every `--json` + failure (resolver, apply early exit, list-time read) is one document. + +### Modified Capabilities + +- `change-progress-reporting`: status gains `--schema`, `next` on every entry, + delegation for schemas cospec doesn't type, and the binary's keys. +- `openspec-list-validate-extensions`: list gains `--sort` and the binary's + keys; validate gains `--type`, `--report`, `--concurrency`, upstream's item + resolution and bulk-flag precedence, and unreadable-artifact ERRORs. +- `cospec-shell-completion`: two more dynamic sources, case-insensitive source + names, and `schemas` wired into the generated scripts. +- `schema-customization`: user-level schemas are found where the binary keeps + them. +- `spec-parsing-and-discovery`: the delegated-duplicate match is linear in the + message and covers quoted headers. + +## Impact + +- `apps/cli/src/core/change.ts` (detector, schema classification, dot-directory + exclusion), `apps/cli/src/core/rules/meta.ts` (`meta/nested-change`, + `meta/unreadable-artifact`, `meta/item-missing`), + `apps/cli/src/core/change-metadata.ts` and `apps/cli/src/commands/new.ts` + (share the one user-schema directory helper). +- `apps/cli/src/commands/status.ts`, `apps/cli/src/core/upstream-keys.ts` (new: + the additive merge), `apps/cli/src/commands/list.ts`, + `apps/cli/src/commands/validate.ts`, `apps/cli/src/core/report.ts`, + `apps/cli/src/commands/complete.ts`, + `apps/cli/src/core/completions/{spec,bash,zsh,fish}.ts`, + `apps/cli/src/commands/apply.ts`. +- `apps/cli/src/core/command-table.ts` (five flags and two `__complete` values + move from pending to handled) and `apps/cli/test/contract/parity-pending.yaml` + (all seven `cli-surface-parity` entries removed). +- Tests: `apps/cli/test/contract/cli-surface.test.ts` (new: flag and output + differentials, the nested-change fixture on status/list/validate, the key + oracle), `apps/cli/test/contract/validation-parity.test.ts` (the quoted-header + dedupe row and the scenario-depth proving fixture), + `apps/cli/test/unit/rules/views.test.ts` (the exception's citation), + `apps/cli/test/unit/commands/validate.test.ts` (the ReDoS guard), unit tests + for the detector, the next-step decision, the concurrency pool, the findings + projection and every `apply` early exit, and + `apps/cli/test/integration/completion.test.ts`. +- Docs: `apps/docs/reference/commands.md`, + `apps/docs/reference/validation-rules.md`, + `apps/docs/concepts/how-it-relates-to-openspec.md` (its namespace-folder + sentence), `docs/architecture.md`, `docs/validation.md`, `.agents/shared.md` + (the additive-JSON discipline, synced to `CLAUDE.md`/`AGENTS.md`), and the + archived `openspec/changes/archive/2026-09-28-validation-parity/tasks.md`. +- JSON: keys added on list, list --specs, status, validate and apply failure + paths. Rule ids gain `meta/nested-change`, `meta/unreadable-artifact` and + `meta/item-missing`. Exit codes change only where BREAKING says. +- `status --json` and `list` each make one wrapped call per invocation. Human + `status` on a cospec-typed change still makes none, unless the binary decides + whether the change can be reported (an unreadable entry, a `.openspec.yaml` it + refuses, a schema it cannot load). + +## Surfaces + +- [x] 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-cli-surface-parity/specs/change-progress-reporting/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/change-progress-reporting/spec.md new file mode 100644 index 00000000..5fb9a681 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/change-progress-reporting/spec.md @@ -0,0 +1,223 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Status sweeps every active change + +`cospec status` SHALL accept `--all`, mutually exclusive with `--change`, which +computes the existing per-change status for every active change under the +resolved root, ordered by change id. Active changes SHALL be the directories +under `openspec/changes/` other than `archive` and dot-directories, as the +wrapped binary enumerates them. A failure computing one change's status, a +namespace folder included, SHALL NOT abort the sweep: that change SHALL appear +as a failure entry carrying its id and the error, and the command SHALL exit 1 +while still emitting the complete envelope. `--all --json` SHALL emit +`{ changes: [...], root }`, where `root` is the binary's `{path, source}` +object; text mode SHALL render one blank-line-separated block per change. The +single-change output SHALL keep every key and line it had, gaining only what +this change adds: `next` and the `Next:` line, and under `--json` the binary's +keys. + +#### Scenario: Sweep lists every active change in id order + +- **WHEN** `cospec status --all` runs in a repo with several active changes +- **THEN** one status block per change is rendered, ordered by change id + +#### Scenario: One bad change does not abort the sweep + +- **WHEN** one active change cannot have its status computed +- **THEN** the other changes are still reported, the bad change appears as a + failure entry naming it, and the command exits 1 + +#### Scenario: Flags are mutually exclusive + +- **WHEN** `cospec status --all --change ` runs +- **THEN** the command reports the conflict and exits non-zero without computing + any status + +#### Scenario: Single-change output is unchanged + +- **WHEN** `cospec status --change --json` runs +- **THEN** every key the document carried before this change is present with the + same value, beside the added `next` and the binary's keys + +## ADDED Requirements + +### Requirement: Status names the next step on every entry + +Every status entry that has a status SHALL carry `next`, and the human output +SHALL print it as a `Next: ` line. Both SHALL come from one function +over the entry's artifact states in the schema's build order. An artifact is +ready when it isn't done and every artifact it requires is done, and an artifact +that `skip_specs` skips counts as done. The next step SHALL be the first ready +artifact the change requires to apply, else the first ready artifact of any +kind, each spelled `cospec instructions --change `. Once every +artifact the change requires is done, it SHALL be `cospec apply `. When +there is none of these, `next` SHALL be absent and no line printed. For a cospec +type the artifact states SHALL be cospec's own matrix, so no wrapped call is +needed in text mode. For another schema they SHALL be the delegated document's +`artifacts[].status` and `applyRequires`. The empty-change entry's `next` SHALL +keep its current spelling. Under `--json`, `nextSteps` SHALL be the binary's own +value from the delegated document, each command spelled through cospec's remedy +allowlist. + +#### Scenario: A mid-build change prints a Next line + +- **WHEN** `cospec status --change alpha` runs on a `feat` change with only + `proposal.md` +- **THEN** the output ends with + `Next: cospec instructions blocking-changes --change alpha` + +#### Scenario: nextSteps is the binary's, spelled cospec + +- **WHEN** `cospec status --change alpha --json` and + `openspec status --change alpha --json` run on the same change +- **THEN** cospec's `nextSteps` equals the binary's with `openspec instructions` + spelled `cospec instructions`, and `next` names the same artifact + +#### Scenario: Required artifacts done points at the gate + +- **WHEN** every artifact a `feat` change requires exists and the optional + `design.md` doesn't +- **THEN** `next` is `cospec apply `, and `nextSteps` is still the binary's + own sentence for `design`, spelled `cospec` + +### Requirement: Status --schema overrides the schema as the binary does + +`cospec status` SHALL accept `--schema ` with the binary's meaning, a +schema override for every change it reports and not a filter. Before enumerating +changes under `--all`, and before reporting the change named by `--change`, an +unknown name SHALL be refused with the binary's +`Schema '' not found. Available schemas:` message, exit 1, as a +`change_error` document under `--json` (with the `{changes: [], root: null}` +payload under `--all`). With neither `--all` nor `--change` the name SHALL NOT +be checked, as the binary doesn't check it. An override naming a cospec type +SHALL render cospec's matrix for that type. Any other override SHALL render as a +schema cospec doesn't type. + +#### Scenario: An override re-renders every change + +- **WHEN** `cospec status --all --schema fix --json` runs +- **THEN** every entry is computed as a `fix` change + +#### Scenario: An unknown override is refused + +- **WHEN** `cospec status --change alpha --schema nope --json` runs +- **THEN** stdout is one `change_error` document carrying the binary's message + and the command exits 1 + +### Requirement: Status answers a schema cospec does not type from the binary + +For a change whose schema is not a cospec type (a forked or project schema, +`spec-driven`, or a name that resolves nowhere), `cospec status` SHALL call the +wrapped `openspec status --change --json`, or `--all --json` for a sweep, +once. In text mode it SHALL render that document the way the binary renders it, +with its `Next:` line spelled through cospec. Under `--json` it SHALL merge the +document into cospec's `{change, type, legacy: true}` entry. The command SHALL +exit with the binary's outcome: an unknown schema exits 1 in both modes. A +change directory without `.openspec.yaml` SHALL take its schema as the binary +does, from the root's `config.yaml` `schema:` and else `spec-driven`, at +`schemaVersion` 1. No output SHALL name a bare `openspec` command. + +#### Scenario: A forked schema gets real status + +- **WHEN** `cospec status --change legacy-one` runs on a change whose schema is + a project fork +- **THEN** the output lists the fork's artifacts with their state and a + `Next: cospec instructions …` line, and names no bare `openspec` command + +#### Scenario: An unknown schema fails under --json + +- **WHEN** `cospec status --change ghost --json` runs on a change whose schema + resolves nowhere +- **THEN** the document carries the binary's `Unknown schema` diagnostic and the + command exits 1 + +#### Scenario: A hand-made change is typed by config.yaml + +- **WHEN** `cospec status --change bare-dir --json` runs on a directory holding + only `proposal.md` in a root whose `config.yaml` says `schema: feat` +- **THEN** the entry is a `feat` change graded at `schemaVersion` 1, and its + `schemaName` is `feat` as the binary reports + +### Requirement: Status answers an unreadable tasks file as the binary does + +When cospec's own read of a cospec-typed change's `tasks.md` fails, +`cospec status` SHALL ask the binary whether the change can be reported, through +its one delegated `openspec status --json` call, made in text mode only then, +for metadata the binary refuses, or for a schema the binary cannot load. Where +the binary refuses the change (its runtime's `realpath` refuses the file), the +binary's failure SHALL be the answer: its `change_error` document under +`--json`, its message on stderr in text, and under `--all` a failure entry +carrying its message, exit 1. Where the binary reports the change, the file +SHALL count as no tasks, as the binary counts it, with a warning naming the file +on stderr, or in `warnings` as `{code: "tasks_unreadable", message}` under +`--json`. + +#### Scenario: The binary refuses the change + +- **WHEN** `cospec status --change beta --json` runs with `beta`'s `tasks.md` at + mode 000 where the binary's `realpath` refuses the file (Bun on macOS) +- **THEN** stdout is the binary's `change_error` document and the command exits + 1 + +#### Scenario: The binary reports the change + +- **WHEN** `cospec status --change beta --json` runs with `beta`'s `tasks.md` at + mode 000 where the binary reports the change (Linux) +- **THEN** `tasks` counts 0 of 0, `warnings` names the file with + `tasks_unreadable`, and the command exits 0 + +### Requirement: Status answers a change whose schema the binary cannot load as the binary does + +When the binary cannot load a cospec-typed change's schema (missing from every +tier, unreadable, unparsable or invalid), `cospec status` SHALL ask the binary +whether the change can be reported, through its one delegated +`openspec status --json` call, in text mode as under `--json`, for `--change` +and for the `--all` sweep. The binary's refusal SHALL be the answer: its +`change_error` document under `--json`, its message on stderr in text with +nothing on stdout, and under `--all` a failure entry carrying its message, with +exit code 1. Every other change in the sweep SHALL be reported as it is alone. + +#### Scenario: A removed project schema refuses the change in text + +- **WHEN** `cospec status --change ch1` runs in text mode on a `chore` change + whose root's `openspec/schemas/chore/` has been removed +- **THEN** stderr is `cospec status: ` followed by the binary's `Unknown schema` + message, stdout is empty, and the command exits 1 + +#### Scenario: The sweep carries the refusal as a failure entry + +- **WHEN** `cospec status --all` runs in text mode on the same root beside a + `feat` change `other` +- **THEN** `ch1`'s block is `ch1: ERROR — `, `other` is reported, and + the command exits 1 + +### Requirement: Status answers a change whose metadata the binary refuses as the binary does + +When the binary's `readChangeMetadata` refuses a cospec-typed change's +`.openspec.yaml` (unreadable, not YAML, naming a schema the binary does not +list, or failing its `ChangeMetadataSchema`: a `created` not `YYYY-MM-DD`, an +empty `goal`, a non-boolean `skip_specs` or `retire_capabilities`, an +`affected_areas` not a list of non-empty strings, an `initiative` not exactly a +kebab-case `{store, id}`), `cospec status` SHALL ask the binary whether the +change can be reported, through its one delegated `openspec status --json` call, +in text mode as under `--json`, for `--change` and for the `--all` sweep. The +binary's refusal SHALL be the answer: its `change_error` document under +`--json`, its message on stderr in text with nothing on stdout, and under +`--all` a failure entry carrying its message, with exit code 1. Every other +change in the sweep SHALL be reported as it is alone. + +#### Scenario: A malformed created date refuses the change in text + +- **WHEN** `cospec status --change ch1` runs in text mode on a `chore` change + whose `.openspec.yaml` sets `created: notadate` +- **THEN** stderr is `cospec status: ` followed by the binary's + `Invalid metadata` message, stdout is empty, and the command exits 1 + +#### Scenario: The sweep carries the metadata refusal as a failure entry + +- **WHEN** `cospec status --all` runs in text mode on the same root beside a + `feat` change `other` +- **THEN** `ch1`'s block is `ch1: ERROR — `, `other` is reported, and + the command exits 1 diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/cospec-shell-completion/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/cospec-shell-completion/spec.md new file mode 100644 index 00000000..80c9fcc4 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/cospec-shell-completion/spec.md @@ -0,0 +1,65 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The dynamic completion source fails silently + +`cospec __complete ` SHALL be a +hidden command emitting one tab-separated id and description pair per line on +stdout, and SHALL exit 1 with **no output on stdout or stderr** on any failure — +an unresolvable root, a wrapped-call error, an unknown source name — because a +Tab press must never be corrupted by an error message. The source name SHALL be +matched case-insensitively, as the wrapped binary matches it. `changes` and +`specs` SHALL be sourced from the existing typed wrapped list calls; `types` +SHALL be sourced from `COSPEC_TYPES` with no wrapped spawn at all; `schemas` +SHALL be sourced from `openspec schemas --json`, one line per schema name +described by its `description`; `archived-changes` SHALL list the non-dot +directories under the resolved root's `openspec/changes/archive/`, sorted, each +described `archived change`, with no wrapped spawn. + +#### Scenario: Change ids complete inside a repo + +- **WHEN** `cospec __complete changes` runs in a repo with active changes +- **THEN** stdout lists each active change id with a tab-separated description + and the command exits 0 + +#### Scenario: Types complete without spawning the wrapped binary + +- **WHEN** `cospec __complete types` runs +- **THEN** the eleven cospec conventional-commit types are listed and no wrapped + binary is spawned + +#### Scenario: Failure is silent on both streams + +- **WHEN** `cospec __complete changes` runs outside any resolvable openspec + root, or `cospec __complete nonsense` is invoked +- **THEN** the command exits 1 having written nothing to stdout and nothing to + stderr + +#### Scenario: Schemas complete from the wrapped listing + +- **WHEN** `cospec __complete schemas` runs in a root with a project fork +- **THEN** stdout lists the same schema names, in the same order, as + `openspec __complete schemas`, the fork included + +#### Scenario: Archived changes complete from the archive + +- **WHEN** `cospec __complete ARCHIVED-CHANGES` runs in a root with two archived + changes +- **THEN** stdout lists both directory names, as + `openspec __complete archived-changes` does + +## ADDED Requirements + +### Requirement: Generated scripts complete schema names + +The bash, zsh and fish scripts `cospec completion` generates SHALL complete the +value of every `--schema` option a table row declares, and the first positional +of `schema which`, `schema validate` and `schema fork`, from +`cospec __complete schemas`, the positions where the wrapped binary's scripts +complete schema names. No generated script SHALL call the `openspec` binary. + +#### Scenario: A --schema value completes + +- **WHEN** the generated zsh script completes `cospec status --schema ` +- **THEN** it calls `cospec __complete schemas` diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/json-document-parity/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/json-document-parity/spec.md new file mode 100644 index 00000000..936245eb --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/json-document-parity/spec.md @@ -0,0 +1,111 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Upstream keys are added without changing cospec's + +For `cospec list`, `cospec list --specs`, `cospec status --change`, +`cospec status --all`, `cospec validate` (a single item, a bulk scope, and +`--report findings`) under `--json`, cospec SHALL emit every key the wrapped +binary's own document for the same invocation carries, with the binary's value, +matching array entries by their identity (`name`, `changeName`, artifact `id`, +item `id` plus kind). It SHALL keep every key it emitted before, with its own +value. It SHALL keep `version: 1` on every envelope that carries one, and it +SHALL NOT take the binary's `version`. Remedy commands inside upstream values +(`nextSteps`) SHALL be spelled `cospec`. + +Where a key both tools emit would need two different values, cospec's value +SHALL win only for a key on the named collision list, which this change defines +as `version` and validate's `items[].type` (cospec's is the change's schema; the +binary's `change|spec` is cospec's `kind`). The `root` of the `status` documents +SHALL take the binary's `{path, source, store_id?}` object. + +#### Scenario: A mid-build status document carries both shapes + +- **WHEN** `cospec status --change alpha --json` runs on a `feat` change with + only `proposal.md` +- **THEN** the document carries cospec's `change`, `type`, `state`, `gate`, + `tasks`, `archiveReady`, `verification` and `next`, and the binary's + `changeName`, `schemaName`, `planningHome`, `changeRoot`, `artifactPaths`, + `isPlanningComplete`, `isComplete`, `applyRequires`, `nextSteps`, + `actionContext` and `root`, and each `artifacts[]` entry carries cospec's + `done`, `required`, `ready` and the binary's `outputPath`, `status`, + `requires` + +#### Scenario: Validate keeps its format marker and its type + +- **WHEN** `cospec validate --all --json` runs +- **THEN** `version` is `1`, each change item's `type` is its schema, each item + also carries `kind` and `durationMs`, and the document carries `root` and + `summary.totals`/`summary.byType` beside cospec's `errors`, `warnings` and + `byRule` + +### Requirement: A key oracle proves the additivity against the binary + +A contract test SHALL run each command listed in the requirement above, and +`cospec apply --json` beside +`openspec instructions apply --change --json`, against the pinned +binary on the same fixture and environment. It SHALL fail when an upstream key +path is missing from cospec's document; when a key both tools emit carries +different values and is not on the named collision list; and when a key from +cospec's own pre-existing shape is missing or changed. Timing values +(`durationMs`, `lastModified`) SHALL be compared by presence and type. +Validation verdicts (`valid`, `issues`, the summary counts) SHALL be compared by +presence and type, since each lane keeps its own findings. A key whose value is +cospec's own pre-existing one SHALL be compared by presence and type too: the +in-progress status entry's `artifacts: []`, into which the binary's artifacts +are never appended. The oracle SHALL itself be tested to fail on a synthetic +collision. + +#### Scenario: The oracle passes for every covered command + +- **WHEN** the contract suite runs the key oracle +- **THEN** every row passes and its fixture exercises at least one entry of + every array it compares + +#### Scenario: The oracle fails on an unlisted collision + +- **WHEN** the oracle is handed a cospec document whose `root` is a string where + the binary's is an object +- **THEN** it reports the collision and fails + +### Requirement: Every --json failure is one document + +Under `--json` a command SHALL answer every failure with exactly one JSON +document on stdout and nothing else there: + +- Every root-selection failure SHALL carry the binary's per-command payload: + `{changes: [], root: null}` for `list` and `status --all`, and + `{specs: [], root: null}` for `list --specs`. A selection diagnostic (such as + an unknown store) SHALL keep its own code. +- A raw resolver failure, one the binary rethrows rather than turning into a + selection diagnostic (such as an unreadable store registry), SHALL carry the + binary's per-command code: `list_error` for `list`, `change_error` for + `status` and `apply`, and `validate_error` for `validate`. +- Every early exit of `cospec apply` (no `openspec/` root, an unknown change, a + failed wrapped call) SHALL be + `{status: [{severity: "error", code, message, fix?}]}` with exit 1. +- A list-time read failure SHALL be the binary's own answer when the binary + reads that path, and otherwise a per-row `error`. + +#### Scenario: Apply names an unknown change in one document + +- **WHEN** `cospec apply nope --json` runs +- **THEN** stdout is one + `{status: [{severity: "error", code: "change_error", message}]}` document + naming `nope`, stderr carries nothing, and the exit code is 1 + +#### Scenario: A raw registry failure carries the command's code + +- **WHEN** `cospec list --json --store s1` runs with an unreadable store + registry +- **THEN** stdout is + `{changes: [], root: null, status: [{…, code: "list_error", message}]}` and + the exit code is 1, as the binary answers + +#### Scenario: An unknown store carries the command's payload + +- **WHEN** `cospec list --json --store nope` runs with stores registered +- **THEN** stdout is + `{changes: [], root: null, status: [{…, code: "unknown_store", message, target, fix}]}` + and the exit code is 1, as the binary answers diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/nested-change-detection/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/nested-change-detection/spec.md new file mode 100644 index 00000000..4227e1b1 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/nested-change-detection/spec.md @@ -0,0 +1,97 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: A namespace folder is detected as the binary detects it + +cospec SHALL decide whether a directory directly under `openspec/changes/` is a +namespace folder by the wrapped binary's rule. The directory SHALL be a +namespace folder only when all of these hold: it looks like no change itself (no +change-root marker file `.openspec.yaml`, `proposal.md`, `tasks.md` or +`design.md`; no file anywhere under its `specs/` directory, dot-entries skipped; +and no existing output of any artifact of the schema the directory resolves to); +it holds no file of its own other than dot-entries; and at least one +subdirectory, searched to at most three levels below it, looks like a change. +The search SHALL not descend into a subdirectory that looks like a change, and +SHALL skip dot-directories. A directory named `archive`, or one whose name +starts with a dot, SHALL never be a namespace folder. A directory that cannot be +read SHALL count as holding nothing, so the detector never fails the command +around it. The nested ids SHALL be reported sorted, as +`/[/…]`. + +#### Scenario: A folder wrapping a change is a namespace folder + +- **WHEN** `openspec/changes/mobile/` holds only `refresh-token/` with a + `.openspec.yaml` +- **THEN** `mobile` is a namespace folder wrapping `mobile/refresh-token` + +#### Scenario: A change with a root marker is never a namespace folder + +- **WHEN** `openspec/changes/alpha/` holds `proposal.md` and a subdirectory that + itself holds a `.openspec.yaml` +- **THEN** `alpha` is reported as a change, not a namespace folder + +#### Scenario: A file of its own keeps a directory a change + +- **WHEN** a directory holds a `README.md` and a subdirectory that looks like a + change +- **THEN** it is not a namespace folder + +#### Scenario: Nesting deeper than three levels is not searched + +- **WHEN** the only change-looking directory sits four levels below the folder +- **THEN** the folder is not a namespace folder + +#### Scenario: The detector agrees with the binary + +- **WHEN** the contract suite lists a fixture covering each signal (root marker, + a delta file only under `specs/`, a schema output only, a file of its own, + depths one to four, a dot-directory, `archive`) with `cospec list --json` and + `openspec list --json` +- **THEN** every row's `nested` value is the same in both documents + +### Requirement: Status, list and validate report a namespace folder as one + +A namespace folder SHALL be reported with the binary's explanation, verbatim: +`"" is not a change: it is a folder wrapping openspec/changes//, … . … Rename each nested change to a flat name (for example "").` + +- `cospec status --change ` SHALL refuse it, on stderr in text mode and + as a `{status: [{severity: "error", code: "change_error", message}]}` document + under `--json`, and exit 1. +- `cospec status --all` SHALL report the folder as a failure entry carrying the + explanation, keep every other change's entry, and exit 1. +- `cospec list` SHALL keep the folder's row, show `not a change` in place of its + task count, set the row's `state` to `not-a-change` and `nested` to the nested + ids, print `Warning: ` after the table, and add a `warnings` + entry `{code: "nested_change_directory", name, nested, message}` under + `--json`. +- `cospec validate` SHALL report the folder, singly or in a bulk scope, as + exactly one `meta/nested-change` ERROR carrying the explanation, run no other + rule on it and delegate nothing for it. + +`cospec archive` is not covered by this requirement. + +#### Scenario: Status refuses a namespace folder + +- **WHEN** `cospec status --change mobile --json` runs on a namespace folder +- **THEN** stdout is one document whose `status[0]` has code `change_error` and + the binary's explanation, and the command exits 1 + +#### Scenario: The sweep carries the folder as a failure + +- **WHEN** `cospec status --all --json` runs in a root with a namespace folder + and two changes +- **THEN** both changes have full entries, the folder's entry carries the + explanation, and the command exits 1 + +#### Scenario: List marks the folder + +- **WHEN** `cospec list` runs in that root +- **THEN** the folder's row reads `not a change` and the binary's warning + follows the table + +#### Scenario: Validate reports only the nesting + +- **WHEN** `cospec validate mobile --json` runs +- **THEN** the item carries exactly one issue, `meta/nested-change` at ERROR, + and no `meta/openspec-yaml` issue diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/openspec-list-validate-extensions/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/openspec-list-validate-extensions/spec.md new file mode 100644 index 00000000..2a12a1f9 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/openspec-list-validate-extensions/spec.md @@ -0,0 +1,274 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: List --specs enumerates capability specs + +`cospec list --specs` SHALL delegate to `openspec list --specs --json` and +render the resulting capability specs as a typed table (or, under `--json`, +cospec's `{version: 1, specs}` document carrying the delegated `root`), +alongside the changes `cospec list` lists when `--specs` is absent. Without +`--specs`, cospec's table columns and `--json` row keys SHALL be unchanged, +gaining only the rows' order under `--sort`, the binary's keys and the +namespace-folder marking. + +#### Scenario: List --specs renders capability specs + +- **WHEN** `cospec list --specs` runs in a repo with capability specs under + `openspec/specs/` +- **THEN** the command renders one row per capability spec, delegated from + `openspec list --specs --json` + +#### Scenario: Default list behavior is unchanged + +- **WHEN** `cospec list` runs without `--specs` +- **THEN** each row keeps its type, gate, task and archive-ready columns, and + each `--json` row keeps `change`, `type`, `state`, `gate`, `gateState`, + `tasks` and `archiveReady` with their values + +### Requirement: Validate bulk and standalone-spec modes + +`cospec validate` SHALL accept `--all`, `--specs`, and `--changes` flags that +delegate the bulk and standalone-spec validation paths to `openspec validate`, +merging delegated spec issues into cospec's existing issue-reporting shape via +the existing delegated-issue mapping. Any of those flags SHALL select its bulk +scope even when an item name is also given, and the name SHALL then be ignored, +as the wrapped binary ignores it. Single-change validation (an item name and no +bulk flag) SHALL continue to run cospec's own rules. A bulk run SHALL exit 1 if +any item fails. + +#### Scenario: Validate --specs delegates and surfaces spec issues + +- **WHEN** `cospec validate --specs` runs against a repo with an invalid + capability spec +- **THEN** the command delegates to `openspec validate --specs` and surfaces the + resulting spec issue in cospec's issue-reporting shape, exiting 1 + +#### Scenario: Validate --all aggregates changes and specs + +- **WHEN** `cospec validate --all` runs +- **THEN** the command aggregates cospec's own change-rule results with + delegated spec/bulk results from `openspec validate --all` into one report + +#### Scenario: Single-change validation is unaffected + +- **WHEN** `cospec validate ` runs without any bulk flag on a name + that is only a change +- **THEN** the command's single-change rule-and-delegation behavior is as before + +#### Scenario: A bulk flag beside a name runs the bulk scope + +- **WHEN** `cospec validate alpha --all` runs +- **THEN** every change and spec is validated, as + `openspec validate alpha --all` does + +## ADDED Requirements + +### Requirement: List sorts as the binary sorts + +`cospec list` SHALL accept `--sort `. `name` SHALL order rows by change +name; any other value, and no flag, SHALL order them most recently modified +first, by the latest modification time of any file in the change, as the wrapped +binary orders them. The order, the rows' `name`, `completedTasks`, `totalTasks`, +`lastModified`, `status` and `nested` keys, and the document's `root` and +`warnings` SHALL come from one delegated `openspec list --json` call, merged +into cospec's rows by name. cospec's `--blocked` filter SHALL apply after the +merge. + +#### Scenario: Default order is most recent first + +- **WHEN** `cospec list --json` runs on changes whose files were last touched in + the order `alpha`, `beta`, `gamma` +- **THEN** the rows are ordered `gamma`, `beta`, `alpha`, as in + `openspec list --json` + +#### Scenario: Name order on request + +- **WHEN** `cospec list --sort name` runs +- **THEN** the rows are ordered by name + +### Requirement: List answers read failures as the binary does + +`cospec list` SHALL NOT crash on a read failure. An unreadable +`openspec/changes/archive/`, which the binary never reads when listing, SHALL +leave the listing as the binary's. cospec's gate column SHALL then be computed +from an empty archive index, and a warning naming the directory SHALL be printed +on stderr, or added to `warnings` as `{code: "archive_unreadable", message}` +under `--json`. A read failure the binary itself refuses (an unreadable change +directory, or a `tasks.md` its runtime's `realpath` refuses) SHALL be answered +with the binary's refusal: its `list_error` document under `--json`, its message +on stderr otherwise, and exit 1. An unreadable `tasks.md` the binary lists past +SHALL count as no tasks, as the binary counts it, with a warning naming the file +on stderr, or in `warnings` as `{code: "tasks_unreadable", message}` under +`--json`. A read failure only cospec's columns reach (an unreadable +`blocking-changes.md`) SHALL become that row's `error`, with the other rows +listed, and exit 1. + +#### Scenario: An unreadable archive still lists + +- **WHEN** `cospec list --json` runs with `openspec/changes/archive/` at mode + 000 +- **THEN** stdout is one document listing every change, `warnings` names the + archive, and the command exits 0, as `openspec list --json` lists them + +#### Scenario: An unreadable tasks file is the binary's list_error + +- **WHEN** `cospec list --json` runs with one change's `tasks.md` at mode 000 + where the binary's `realpath` refuses the file (Bun on macOS) +- **THEN** stdout is one + `{changes: [], root: null, status: [{…, code: "list_error"}]}` document and + the command exits 1 + +#### Scenario: An unreadable tasks file the binary lists past + +- **WHEN** `cospec list --json` runs with one change's `tasks.md` at mode 000 + where the binary lists the change (Linux) +- **THEN** the change's row counts 0 of 0 tasks with no `error`, `warnings` + names the file with `tasks_unreadable`, and the command exits 0, as + `openspec list --json` does + +### Requirement: Validate resolves one item as the binary does + +`cospec validate ` SHALL resolve the name as the wrapped binary does. + +- `--type change|spec`, matched case-insensitively, SHALL force the kind. Any + other value SHALL be ignored. +- Without a forced kind, a name that is both an active change and a living spec + SHALL be refused with + `Ambiguous item '' matches both a change and a spec.` and the fix + `Pass --type change|spec.`, exit 1. Under `--json` this is one + `ambiguous_item` document. +- A name that is neither SHALL be refused with + `Unknown item ''. Did you mean: ?`, by edit + distance over the change ids then the spec ids, duplicates kept, or + `Unknown item ''.` when there is no candidate, exit 1. Under `--json` + this is one `unknown_item` document. +- A forced kind SHALL first reject a name the binary rejects (empty, `.`/`..`, + or containing a path separator, checked per segment for a spec) with the + binary's `invalid_item` message. A forced kind naming nothing on disk SHALL be + reported as that item with one `meta/item-missing` ERROR. +- The noun-form alternative in the binary's text refusal SHALL be left out, as + cospec has no noun-form commands. + +#### Scenario: An ambiguous name is refused + +- **WHEN** `cospec validate gamma --json` runs where `gamma` is a change and a + spec +- **THEN** stdout is one `ambiguous_item` document with the binary's message and + fix, and the command exits 1 + +#### Scenario: An unknown name gets the binary's suggestions + +- **WHEN** `cospec validate gamm` runs +- **THEN** stderr carries `Unknown item 'gamm'. Did you mean: …?` naming the + same ids, in the same order, as `openspec validate gamm`, and the command + exits 1 + +#### Scenario: --type settles the ambiguity + +- **WHEN** `cospec validate gamma --type spec` runs +- **THEN** only the living spec `gamma` is validated + +### Requirement: Validate --report selects the full or the findings report + +`cospec validate` SHALL accept `--report `. Before resolving the +root it SHALL refuse, exit 1, with the fix +`Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes.`: +an unknown value (`Unknown validation report ''.`), an item name +(`A validation report cannot be combined with an item name.`), `--archived` with +a bulk flag (`A validation report cannot combine archived and active scopes.`), +and no bulk scope (`A validation report requires an explicit bulk scope.`). +These SHALL go to stderr as `Error: ` and `Fix: `, or under +`--json` as one `invalid_validation_report_request` document. `full` SHALL be +the report cospec prints today. `findings` SHALL keep only the items with at +least one issue, under the binary's `report` object +(`kind: "validation-findings"`, `scope`, `returnedItems`, `totalItems`), +`itemFindings`, `summary` and `root`, inside cospec's `version: 1` envelope. Its +exit code SHALL always be the one `full` would give. + +#### Scenario: A report without a bulk scope is refused + +- **WHEN** `cospec validate --report findings --json` runs +- **THEN** stdout is one `invalid_validation_report_request` document naming the + missing bulk scope, and the command exits 1 without resolving a root + +#### Scenario: Findings keep full's exit code + +- **WHEN** `cospec validate --all --report findings` and + `cospec validate --all --report full` run on a root with one failing change +- **THEN** both exit 1, and the findings report lists only items with issues + +### Requirement: Validate --concurrency bounds the change validations + +`cospec validate` SHALL run at most N change validations at once in a bulk +scope. N SHALL be `--concurrency ` when it parses as a positive integer, else +`OPENSPEC_CONCURRENCY` when that does, else 6. A value that is not a positive +integer SHALL be ignored, not refused, as the binary ignores it. The report's +item order SHALL NOT depend on N. + +#### Scenario: The bound holds + +- **WHEN** a bulk validation of eight changes runs with `--concurrency 2` +- **THEN** no more than two change validations are ever in flight, and the + report equals the report of the same run with `--concurrency 8` + +#### Scenario: A bad value falls back + +- **WHEN** `cospec validate --all --concurrency abc` runs with + `OPENSPEC_CONCURRENCY=3` +- **THEN** the run is bounded at three and exits as an unbounded run would + +### Requirement: An unreadable artifact is a validation error + +`cospec validate` SHALL report a change artifact it cannot read (a proposal, +blockers, tasks, verification or design file, a delta or unread spec file, or +`.openspec.yaml`) as a `meta/unreadable-artifact` ERROR naming the file and its +error code, and SHALL run no other rule on that change and delegate nothing for +it. The command SHALL never throw on such a file. Under `--json` the report +SHALL still be one document. + +An unreadable `openspec/changes/archive/` is not a change artifact: the binary's +`validate` and `instructions apply` never read it, so `cospec validate` and +`cospec apply` SHALL answer as they do with it empty, carrying an +`{code: "archive_unreadable", message}` warning naming the directory under +`--json`, or printing it on stderr. `cospec archive` SHALL still refuse. + +#### Scenario: An unreadable archive leaves validate and apply answering + +- **WHEN** `cospec validate --all --json` and `cospec apply --json` run + with `openspec/changes/archive/` at mode 000 +- **THEN** each prints one document that, bar its one `archive_unreadable` + warning, equals its answer with the archive readable, and `validate --all` + exits as the binary does + +#### Scenario: An unreadable tasks file fails the change, not the command + +- **WHEN** `cospec validate --all --json` runs with one change's `tasks.md` at + mode 000 +- **THEN** that change carries one `meta/unreadable-artifact` ERROR naming + `tasks.md` and `EACCES`, every other item is reported, and the command exits 1 + +### Requirement: Validate relays are spelled through cospec + +Every issue message `cospec validate` relays from the wrapped binary, and each +message and fix of the failure document the binary answers `--archived` with, +SHALL have each allowlisted upstream remedy spelled through cospec, with every +other byte unchanged. That failure document SHALL be the answer: under `--json` +that one document with the binary's exit code, in text `cospec: ` on +stderr. Whether the binary is too old for `--archived` SHALL be read from its +version, never inferred from its answer. + +#### Scenario: An unreadable archive's --archived failure is the binary's + +- **WHEN** `cospec validate --archived --json` runs with + `openspec/changes/archive/` at mode 000 +- **THEN** it prints the binary's one `validate_error` document and exits 1, as + the binary does, and the text form prints `cospec: ` with no "needs + OpenSpec >=1.9.0" attribution + +#### Scenario: The no-deltas tip names cospec + +- **WHEN** a delegated issue carries the binary's + `Tip: run "openspec change show --json --deltas-only"` sentence +- **THEN** cospec's report carries that sentence in its cospec spelling and no + bare `openspec` command diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/schema-customization/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/schema-customization/spec.md new file mode 100644 index 00000000..c243fc27 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/schema-customization/spec.md @@ -0,0 +1,26 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Schema classification reads the binary's user schema directory + +When cospec classifies a change's `schema:` value, the user tier SHALL be the +directory the wrapped binary reads user schemas from: +`$XDG_DATA_HOME/openspec/schemas` when `XDG_DATA_HOME` is set and non-empty, +else `%LOCALAPPDATA%\openspec\schemas` on Windows (falling back to +`~/AppData/Local/openspec/schemas`), else `~/.local/share/openspec/schemas`. It +SHALL never read `~/.config/openspec/schemas`. cospec SHALL compute that +directory in one place, and every reader of the user tier SHALL use it. + +#### Scenario: A user-level fork is a legacy schema + +- **WHEN** a change names schema `house-style` that exists only under + `$XDG_DATA_HOME/openspec/schemas/house-style/schema.yaml` +- **THEN** cospec classifies it as a legacy schema from the user tier, as the + binary resolves it, and `cospec validate` delegates it rather than reporting + an unknown schema + +#### Scenario: The config directory is not a schema tier + +- **WHEN** the schema exists only under `~/.config/openspec/schemas/` +- **THEN** cospec classifies it as unknown, as the binary does diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/spec-parsing-and-discovery/spec.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/spec-parsing-and-discovery/spec.md new file mode 100644 index 00000000..f456f6ca --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/specs/spec-parsing-and-discovery/spec.md @@ -0,0 +1,28 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Delegated duplicate matching is linear and covers quoted headers + +Each `DUPLICATE_CLASSES` pattern SHALL match a delegated message in time linear +in the message's length, whatever text a spec author puts in a requirement +header. A pattern that reads a list of defect lines SHALL match the fixed head +once and then each line on its own, with no repeated group around a quantified +span. The `archive/target-invalid` pairing SHALL recognise a +structurally-invalid-target message whose quoted header text itself contains +`"`, so a header such as `### Requirement: Widget "quoted" name` is reported +once, by cospec's rule. + +#### Scenario: A quoted header is reported once + +- **WHEN** `cospec validate --json` runs on a change whose living spec + duplicates `### Requirement: Widget "quoted" name` +- **THEN** the report carries cospec's `archive/target-invalid` ERROR and not + the binary's structurally-invalid INFO for the same spec + +#### Scenario: An adversarial message is matched quickly + +- **WHEN** the dedupe runs on a structurally-invalid message of two hundred + quote-heavy defect lines followed by a line that doesn't match +- **THEN** it finishes within the unit test's bound, a bound the previous + pattern exceeds on the same input diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/tasks.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/tasks.md new file mode 100644 index 00000000..f328b05a --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/tasks.md @@ -0,0 +1,337 @@ +# Tasks + +Tracks follow design D1. Each track owns its files, and the shared +`command-table.ts` / `parity-pending.yaml` hunks land only in the task named for +them. Contract rows are written first (T6) as `test.failing`, and each +implementing task flips exactly its own rows in the commit that makes them pass. +Every task ends with `mise run check` green and one commit (the harness's +Co-Authored-By trailer, never `--no-verify`). + +## 1. Rebase + +- [x] 1.1 Once `passthrough-json-and-doctor` has merged, rebase this branch onto + `main` (`--force-with-lease`, no merge commit), run + `bun install --frozen-lockfile` and `mise run check`. Re-check that none + of its files are in D1's windows, and that design's `status.ts`, + `list.ts`, `validate.ts`, `apply.ts` and `complete.ts` line references + still hold. The rebased branch is verified by `git log main..HEAD` showing + only this change's commits, and it adds no commit of its own + +## 2. T6 — contract rows first (`apps/cli/test/contract/cli-surface.test.ts`, `apps/cli/test/contract/support/key-oracle.ts`) + +- [x] 2.1 Write `key-oracle.ts` (design D5: identity matching, the six path + classes, the native-key snapshot) and its self-test (verification 1.8), + plus the fixture builders: the staged-mtime list fixture via `utimes`, the + namespace folder, the detector matrix, a project fork, a `spec-driven` + change, an unknown-schema change, a hand-made change directory, an + ambiguous `gamma`, and the mode-000 cases. The self-test passing under + `mise run test:contract` is the check. Commit + `test(cli): add the upstream key oracle and cli-surface fixtures` +- [x] 2.2 Add every contract row of verification groups 1, 3.2, 3.4, 4, 5, 6, + 7.1–7.6, 7.8, 7.9, 8.1, 8.4, 9.1, 10.2 to `cli-surface.test.ts`. Each + reads the binary's answer at test time through the upstream oracle, and + each row that fails on this tree is marked `test.failing`. Record the + failing count, run `mise run test:contract` green, and commit + `test(cli): pin cli-surface-parity differentials as failing rows` + +## 3. T1 — detector, meta rules, schema tier (`apps/cli/src/core/change.ts`, `apps/cli/src/core/rules/meta.ts`) + +- [x] 3.1 Port `findNestedChangesIn`, `findNestedChanges` and + `describeNestedChange` into `change.ts` (design D2), with the detector + unit table (verification 4.5), and make `listChanges` drop + dot-directories. Verify with the unit table green and 4.6 flipped. Commit + `feat(cli): detect namespace folders the way OpenSpec does` +- [x] 3.2 Add `meta/nested-change`, `meta/unreadable-artifact` and + `meta/item-missing` issue builders to `meta.ts`, each unit-tested for + level, path and message. Commit + `feat(validate): add the nested-change, unreadable and missing-item rules` +- [x] 3.3 Export `userSchemasDir` from `change-metadata.ts`, and make + `resolveSchema` (`change.ts`) and `new.ts` import it (design D8). Verify + with the unit row 10.1 and the flipped contract row 10.2. Commit + `fix(cli): classify user schemas from the directory OpenSpec reads` + +## 4. T2 — status (`apps/cli/src/commands/status.ts`, `apps/cli/src/core/upstream-keys.ts`) + +- [x] 4.1 Add `resolveNext` and wire it to `next` on every entry and to the + human `Next:` line (design D4), with the unit table 3.3 and the rows 3.1, + 3.4 and 3.5. Commit `feat(status): name the next step on every entry` +- [x] 4.2 Add `core/upstream-keys.ts` (design D3), its unit test (collision + list, identity merge), and the one delegated `status --json` / + `--all --json` call merged into cospec's documents with `root` as the + resolver's object and `nextSteps` respelled. Flip rows 1.3, 1.4 and 3.2. + Commit `feat(status): add OpenSpec's status keys to the JSON documents` +- [x] 4.3 Answer a schema cospec doesn't type (fork, `spec-driven`, unknown, no + `.openspec.yaml`) from the delegated document: the text renderer port, the + merged `--json` entry and the binary's exit code. Flip rows 5.1–5.4 and + 5.6. Commit + `feat(status): render schemas cospec does not type from OpenSpec's status` +- [x] 4.4 Accept `--schema` as an override, forwarded and checked as the binary + checks it. Move it from pending to handled in `command-table.ts` and + delete its `parity-pending.yaml` entry in this commit. Flip row 5.5. + Commit `feat(status): accept --schema as OpenSpec's schema override` +- [x] 4.5 Wire the detector (refusal and sweep failure entry), the + unreadable-archive warning with the empty-index gate, and `change_error` + for other read failures and raw resolver failures (design D4, D10). Flip + rows 4.1, 4.2, the status parts of 6.2, 6.3, 8.1 and 8.4. Commit + `fix(status): report namespace folders and read failures as OpenSpec does` + +## 5. T3 — list (`apps/cli/src/commands/list.ts`) + +- [x] 5.1 Make `list` one delegated `openspec list --json` call merged by name + (design D6), with `--sort` forwarded, and `root` on `--specs`. Move + `--sort` from pending to handled and delete its yaml entry in this commit. + Flip rows 1.1, 1.2 and 6.1. Commit + `feat(list): sort and carry OpenSpec's list keys` +- [x] 5.2 Mark namespace rows (`not a change`, `state: 'not-a-change'`, + `nested`, the trailing warnings), relay the binary's failure document, the + archive warning, per-row `error` for `blocking-changes.md`, and the + raw-resolver `list_error` payloads. Flip rows 4.3, 6.2–6.4 and the list + parts of 8.1 and 8.4. Commit + `fix(list): answer namespace folders and read failures as OpenSpec does` + +## 6. T4 — validate (`apps/cli/src/commands/validate.ts`, `apps/cli/src/core/report.ts`) + +- [x] 6.1 Port item resolution: `--type`, the ambiguity refusal, + `nearestMatches`, `invalid_item`, `meta/item-missing`, and bulk-flag + precedence (design D7). Move `--type` from pending to handled and delete + its yaml entry in this commit. Flip rows 7.1–7.4. Commit + `feat(validate): resolve items and bulk scopes as OpenSpec does` +- [x] 6.2 Add `--report full|findings` with the four request refusals ahead of + root resolution and `toFindings` in `report.ts`. Move `--report` from + pending to handled and delete its yaml entry in this commit. Flip rows + 1.6, 7.5 and 7.6. Commit `feat(validate): add --report full|findings` +- [x] 6.3 Replace `Promise.all` with the bounded pool honouring `--concurrency`, + `OPENSPEC_CONCURRENCY` and 6, with the unit row 7.7. Move `--concurrency` + from pending to handled and delete its yaml entry in this commit. Commit + `feat(validate): bound bulk validation with --concurrency` +- [x] 6.4 Add `root`, `items[].durationMs`, `summary.totals` and + `summary.byType` to the report JSON, keeping `version: 1` and the schema + `items[].type`. Flip row 1.5. Commit + `feat(validate): add OpenSpec's report keys to the JSON document` +- [x] 6.5 Read artifacts through the errno-recording helper and short-circuit to + `meta/unreadable-artifact`, and a namespace folder to + `meta/nested-change`. Respell every delegated message and the `--archived` + fallback, and give raw resolver failures `validate_error`. Flip rows 4.4, + 7.8, 7.9 and the validate parts of 8.1 and 8.4. Commit + `fix(validate): report unreadable artifacts and respell relayed remedies` +- [x] 6.6 Rewrite the `archive/target-invalid` dedupe as the per-line matcher, + and make the ReDoS unit test fail on the pre-fix pattern + (`test/unit/commands/validate.test.ts`). Add the quoted-header row to + `validation-parity.test.ts`. Verify with rows 11.1 and 11.2. Commit + `fix(validate): match structurally-invalid targets in linear time` +- [x] 6.7 Add the commented mis-depth-scenario archive row to + `validation-parity.test.ts`, and cite it from the `deltas/scenario-depth` + exception in `views.ts` and `views.test.ts`. Verify with row 12.1. Commit + `test(validate): prove the scenario-depth masked-view exception` + +## 7. T5 — completion (`apps/cli/src/commands/complete.ts`, `apps/cli/src/core/completions/`) + +- [x] 7.1 Add the `schemas` and `archived-changes` sources and case-insensitive + source names. Move both `__complete` values from pending to handled and + delete both yaml entries in this commit. Flip row 9.1. Commit + `feat(completion): serve the schemas and archived-changes sources` +- [x] 7.2 Wire `schemas` into `spec.ts` (`--schema` values and the three + `schema` subcommand positionals) and the bash, zsh and fish generators, + verified by `completion.test.ts` (row 9.2). Commit + `feat(completion): complete schema names in the generated scripts` + +## 8. T7 — apply (`apps/cli/src/commands/apply.ts`) + +- [x] 8.1 Answer every early exit under `--json` with one `change_error` + document (design D10), with the unit test over every path (row 8.2). Flip + rows 1.7 and 8.3. Commit + `fix(apply): answer every early exit with one JSON document` + +## 9. Docs + +- [x] 9.1 Update `apps/docs/reference/commands.md`, + `apps/docs/reference/validation-rules.md`, + `apps/docs/concepts/how-it-relates-to-openspec.md` and + `docs/architecture.md` (design D12). Add the standing rule verbatim to + `docs/validation.md`'s parser-tolerances section. Verify with rows 12.2, + 13.1–13.4 and 13.6 (`mise run docs:build` exit 0). Commit + `docs(cli): document cli-surface-parity behavior` +- [x] 9.2 Tick row 7.2 in + `openspec/changes/archive/2026-09-28-validation-parity/tasks.md` with a + note: the archive ran with `--force-incomplete`, which waived the tasks + gate for that one self-referential row, and both hard gates ran. Verify + with row 13.5. Commit + `docs(validate): record how the validation-parity archive ran` + +- [x] 9.3 Add the additive-JSON discipline paragraph to `.agents/shared.md` + (design D12), run `mise run agents:sync`, and verify with row 13.7 + (`mise run agents:check` exit 0). Commit + `docs(agents): record the additive upstream-key discipline` + +## 10. Close-out + +- [x] 10.1 Record observed evidence on every verification row, confirm zero + `test.todo`/`test.failing` in `cli-surface.test.ts` (row 14.1), the + pending count 7 → 0 (row 2.1), the BREAKING list (row 14.3), + `validate --all --strict` (row 14.2) and `mise run check` (row 14.4). + Commit `docs(cli): record cli-surface-parity evidence` +- [x] 10.2 After the final rebase onto `main`, tick this row, run + `mise run cospec -- validate cli-surface-parity --strict`, then + `mise run cospec -- archive cli-surface-parity` with no `--force*` flag, + as the PR branch's final commit. Verify with `git show --stat` listing + only `openspec/` paths — performed by the next commit (the archive); + verified by its `git show --stat` + +## 11. Round-2 review fixes + +Each fix below lands in its own commit, flipping its own `test.failing` rows +(verification group 15) and ticking its own task. Task 10.2 stays the branch's +final commit. + +- [x] 11.1 Write rows 15.1–15.10 first: the round-2 contract rows in + `cli-surface.test.ts`, `nested-detector.test.ts` and `glob.test.ts`, and + the bench parser rows in `packages/bench/test/unit/mechanical.test.ts`, + each as `test.failing`. Commit + `test(cli): add the round-2 review rows as failing` +- [x] 11.2 `validate --type spec` on a spec file discovery skips (a + dot-directory, a linked capability) validates that file as the binary + does, never an empty passing report. Verify with row 15.1. Commit + `fix(validate): validate a forced spec that discovery skips` +- [x] 11.3 The namespace-folder detector matches `generates` with the binary's + glob semantics (fast-glob: braces, extglobs, negation) through + `core/glob.ts`, a line-for-line port of the binary's + `artifactOutputExists` over `fast-glob` pinned to the version the pinned + openspec resolves (embedded by `bun build --compile`, so the standalone + binary has it too), held to the pinned binary's modules. Verify with rows + 15.2 and 15.3. Commit + `fix(cli): match schema outputs with the binary's glob semantics` +- [x] 11.4 `packages/bench` `parseSchemaConformanceJson` returns null for a + document with a `status[]` error or without `summary` or `items`. Verify + with row 15.10. Commit + `fix(bench): count a refused validate document as no report` +- [x] 11.5 `status` answers every change on a schema cospec doesn't type from + the binary's status, and a cospec-typed change with no artifacts takes its + next step from its own matrix. Verify with row 15.4. Commit + `fix(cli): take a custom schema's status from its own artifacts` +- [x] 11.6 `validate --archived` relays the binary's failure document: under + `--json` that one document with the binary's exit code, in text its + messages. Verify with row 15.5. Commit + `fix(validate): relay the binary's --archived failure document` +- [x] 11.7 An unreadable `openspec/changes/archive/` leaves `validate` and + `apply` answering from an empty archive with an `archive_unreadable` + warning. Verify with row 15.6. Commit + `fix(cli): validate and apply past an unreadable archive` +- [x] 11.8 `validate --json` with no `openspec/` directory prints one + `no_openspec_root` document. Verify with row 15.7. Commit + `fix(validate): answer --json outside a root with one document` +- [x] 11.9 `list --specs` relays the binary's failure document under `--json` + and its message and fix in text. Verify with row 15.8. Commit + `fix(cli): relay a failed list --specs as the binary's document` +- [x] 11.10 An unreadable living `spec.md` is one `meta/unreadable-artifact` + ERROR on that spec. Verify with row 15.9. Commit + `fix(validate): report an unreadable living spec as an issue` +- [x] 11.11 Record observed evidence on every group-15 row, re-observe rows + 14.1–14.4, and update the docs pages that own each fact. Commit + `docs(cli): record the round-2 review fixes` +- [x] 11.12 An unreadable `tasks.md` is answered as the binary answers it on + each OS (CI run 36547287646: Linux red, macOS green). Where the binary + refuses the change (its `realpath` confinement check, which fails under + Bun on macOS), its `list_error` or `change_error` is relayed: its document + under `--json`, its message in text, and `status --all`'s entry for the + change. Where it reports the change (Linux), cospec does too, counting the + file as no tasks, as the binary's `countTaskFile` does, with a + `tasks_unreadable` warning. `status` makes its one delegated call in text + mode too. Rows 15.11, 15.12 and 6.3 pass on macOS and in a Linux container + as a non-root user. Commit + `fix(cli): answer an unreadable tasks.md as the binary does` +- [x] 11.13 CodeQL alert #15 (js/redos, high): the unit file keeps no + exponential regex. The pre-fix target-invalid pattern survives only as + text, and a star-height guard (an unbounded quantifier over a group that + holds one) flags it while passing both of `TARGET_INVALID`'s patterns. + Verify with row 15.13. Commit + `test(validate): replace the exponential reference regex with a guard` + +## 12. Round-3 review fixes + +Each fix below lands in its own commit, flipping its own `test.failing` rows +(verification group 16) and ticking its own task. Task 10.2 stays the branch's +final commit. + +- [x] 12.1 Write rows 16.1–16.12 first: the round-3 contract rows in + `cli-surface.test.ts`, each as `test.failing`, and the key oracle's `kept` + class with its self-test. Commit + `test(cli): add the round-3 review rows as failing` +- [x] 12.2 Every delegated validation names its kind (`--type change` per + change, `--type spec` per named spec) under the wrapped-call discipline, + and a refusal the binary answers with a `status[]` document is the item's + `openspec/validate` ERROR, never an empty report; `--strict` fails a + warning-only spec in `valid` and the totals. Verify with rows 16.1, 16.2, + 16.4 and 15.9. Commit + `fix(validate): name the kind on every delegated validation` +- [x] 12.3 The living spec a delta targets is read through the change's reader: + an unreadable one is the change's `meta/unreadable-artifact` ERROR on the + delta's path, naming the file, and a change whose validation throws an + errno failure is that change's ERROR in the bulk pool. Verify with row + 16.3. Commit + `fix(validate): fail a change whose target living spec is unreadable` +- [x] 12.4 An errno failure `validate`, `status` or `apply` lets escape (an + unreadable `openspec/changes/`, `openspec/specs/` or capability directory) + is one `--json` document with the binary's per-command code and payload. + Verify with row 16.10. Commit + `fix(cli): answer an unreadable planning directory with one document` +- [x] 12.5 `validate ` with no `openspec/` directory resolves the name as + the binary does: `unknown_item`. Verify with row 16.5. Commit + `fix(validate): resolve a named item outside any root` +- [x] 12.6 `resolveChange` looks a change up as the binary's + `validateChangeExists` does: a directory, any name its + `validateChangeLookupName` accepts. Verify with rows 16.7 and 16.8. Commit + `fix(cli): look a change up as the binary does` +- [x] 12.7 Every binary diagnostic `status` relays is spelled through the remedy + allowlist, in text and in `status[]` under `--json`. Verify with row + 16.13. Commit + `fix(cli): respell every status diagnostic the binary relays` +- [x] 12.8 An in-progress cospec-typed entry keeps `artifacts: []` under + `--json`, singly and in the sweep. Verify with row 16.6. Commit + `fix(cli): keep an empty change's artifacts empty under --json` +- [x] 12.9 `status` refuses a change the binary refuses: any error in the + delegated document is the answer (its document under `--json`, its message + in text, the change's sweep entry), and a change cospec cannot read asks + the binary in text mode too. Verify with row 16.9. Commit + `fix(cli): refuse a change status the binary refuses` +- [x] 12.10 `apply` relays the binary's failure document when + `instructions apply` refuses after the gate clears. Verify with row 16.11. + Commit `fix(apply): relay the binary's apply instructions refusal` +- [x] 12.11 An unreadable directory no artifact lives in (a dot-directory, a + directory outside `specs/`) leaves the change's answer unchanged. Verify + with row 16.12. Commit + `fix(validate): read past a directory no artifact lives in` +- [x] 12.12 Rows 15.4, 15.6 and 15.7 assert what their ledger rows promise, and + row 15.3 records the test file's own counts. Verify with rows 15.3, 15.4, + 15.6 and 15.7. Commit + `test(cli): hold rows 15.4, 15.6 and 15.7 to their ledger` +- [x] 12.13 Record observed evidence on every group-16 row, re-observe rows + 14.1–14.4, and update the docs pages that own each fact. Commit + `docs(cli): record the round-3 review fixes` + +## 13. Round-4 review fixes + +Each fix below lands in its own commit with its own contract row (verification +group 17). Task 10.2 stays the branch's final commit. + +- [x] 13.1 Text-mode `status` asks the binary for a cospec-typed change whose + schema the binary cannot load (missing, unreadable, unparsable or + invalid), singly and in the `--all` sweep, and relays its refusal as + `--json` does; the docs pages that own the fact say so. Verify with rows + 17.1 and 17.2. Commit + `fix(cli): refuse in text a change whose schema cannot load` +- [x] 13.2 Text-mode `status` asks the binary for a cospec-typed change whose + `.openspec.yaml` the binary's `readChangeMetadata` refuses (unreadable, + not YAML, an unlisted schema, or failing `ChangeMetadataSchema`), singly + and in the `--all` sweep, and relays its refusal as `--json` does; the + docs pages that own the fact say so. Verify with rows 17.3 and 17.4. + Commit `fix(cli): refuse in text a change whose metadata is refused` +- [x] 13.3 `changeMetadataRefused`'s own `listSchemas` call (13.2's `listed()`) + catches its errno failure (the project `openspec/schemas` unreadable or + not a directory) and answers refused, the same as every other read failure + in that function, instead of escaping `binaryDecides` uncaught — which, + called directly in the `--all` sweep's upstream-fetch decision and the + `--change` lookup's own decision, both outside any per-change try/catch, + crashed the whole command with a bare `cospec: ENOTDIR …` line instead of + the per-change refusal `--json` already answers correctly. Verify with row + 17.5. Commit + `fix(cli): catch listSchemas's errno in changeMetadataRefused` diff --git a/openspec/changes/archive/2026-10-05-cli-surface-parity/verification.md b/openspec/changes/archive/2026-10-05-cli-surface-parity/verification.md new file mode 100644 index 00000000..7efdce59 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-cli-surface-parity/verification.md @@ -0,0 +1,142 @@ +# Verification + +## 1. The key oracle passes and keeps cospec's keys [critical] + +- [x] 1.1 @equivalence (agent) key oracle row `list`: `cospec list --json` and `openspec list --json` on the staged-mtime fixture (three changes, a namespace folder, one change with tasks) -> every binary key path is present with the binary's value (`lastModified` by type), rows are in the binary's order, `warnings` and `root` equal the binary's, and every pre-existing cospec row key keeps its native value -> observed: cli-surface.test.ts `1.1 list --json on the staged-mtime fixture` passes; every binary key (`lastModified` by type, `warnings`, `root`) is present in the binary's order and value, and every pre-existing cospec row key keeps its native value +- [x] 1.2 @equivalence (agent) key oracle row `list --specs --json` on a two-spec fixture -> `specs` and `root` equal the binary's; `version` is 1 -> observed: cli-surface.test.ts `1.2 list --specs --json on a two-spec fixture` passes; `specs` and `root` equal the binary's, `version` is 1 +- [x] 1.3 @equivalence (agent) key oracle row `status --change alpha --json` on a `feat` change with only `proposal.md` -> every binary key is present with its value (`nextSteps` after respelling), each `artifacts[]` entry carries both tools' keys, `root` equals the binary's object, cospec's `change`/`type`/`state`/`gate`/`gateState`/`tasks`/`archiveReady`/`verification` keep their native values -> observed: cli-surface.test.ts `1.3 status --change alpha --json on a feat change with only proposal.md` passes; every binary key present with its value, `root` equals the binary's object, cospec's native keys unchanged +- [x] 1.4 @equivalence (agent) key oracle row `status --all --json` on the list fixture -> the same checks per entry, matched by `change`/`changeName`, including the namespace folder's failure entry; `root` is the binary's object -> observed: cli-surface.test.ts `1.4 status --all --json on the list fixture` passes; each entry matched by change/changeName including the namespace folder's failure entry, `root` is the binary's object +- [x] 1.5 @equivalence (agent) key oracle rows `validate alpha --json` and `validate --all --json` -> `root`, `items[].durationMs` (by type), `summary.totals`, `summary.byType` present; `version` is 1; `items[].type` is the schema on change items and `kind` equals the binary's `type`; verdict paths match by type only; no other collision -> observed: cli-surface.test.ts `1.5 validate alpha --json and validate --all --json` passes; `root`, `items[].durationMs`, `summary.totals`/`byType` present, `items[].type` is the schema and `kind` equals the binary's `type`, no other collision +- [x] 1.6 @equivalence (agent) key oracle row `validate --all --report findings --json` -> the binary's `report` (with `kind`, `version: "1.0"`, `scope`, `returnedItems`, `totalItems`), `itemFindings`, `summary` and `root` are present; top-level `version` is 1 -> observed: cli-surface.test.ts `1.6 validate --all --report findings --json` passes; the binary's `report` (kind, version "1.0", scope, returnedItems, totalItems), `itemFindings`, `summary`, `root` present, top-level `version` is 1 +- [x] 1.7 @equivalence (agent) key oracle row `cospec apply nope --json` beside `openspec instructions apply --change nope --json` -> `status[0].severity`/`code`/`message` keys present, `code` equal (`change_error`), exit 1 in both -> observed: cli-surface.test.ts `1.7 apply nope --json beside instructions apply --change nope --json` passes; `status[0]` severity/code/message present, code `change_error` in both, exit 1 in both +- [x] 1.8 @unit (agent) `key-oracle.ts` self-test: a cospec document whose `root` is a string where the binary's is an object, one missing an upstream key, and one dropping a snapshotted cospec key -> each fails with the offending path named; a document differing only in `version` passes -> observed: key-oracle.ts self-test (`the key oracle` describe, cli-surface.test.ts:341-463, 9 tests) all pass: a string `root` where the binary has an object fails naming the path, a missing upstream key fails naming its path, a dropped/changed snapshotted cospec key fails naming its path, a document differing only in `version` passes + +## 2. Every pending entry this change owns is gone [critical] + +- [x] 2.1 @integration (agent) `grep -c 'owner: cli-surface-parity' apps/cli/test/contract/parity-pending.yaml` before and after, and `mise run test:contract` reachability -> 7 before (`list --sort`, `status --schema`, `validate --type`, `validate --report`, `validate --concurrency`, `__complete schemas`, `__complete archived-changes`), 0 after, reachability green with no pending mark left for this owner in the command table -> observed: before: `git show main:apps/cli/test/contract/parity-pending.yaml | grep -c 'owner: cli-surface-parity'` = 7 (`list --sort`, `status --schema`, `validate --type`, `validate --report`, `validate --concurrency`, `__complete schemas`, `__complete archived-changes`); after: 0 in the worktree; `reachability.test.ts` green inside `mise run check` with no pending mark left for this owner + +## 3. Every status entry names its next step [critical] + +- [x] 3.1 @e2e (agent) `cospec status --change alpha` on a `feat` change with only `proposal.md`, through the real CLI -> output ends `Next: cospec instructions blocking-changes --change alpha`; `--json` carries the same command as `next` -> observed: cli-surface.test.ts `3.1 a mid-build feat change prints and carries its next step` passes; output ends `Next: cospec instructions blocking-changes --change alpha`, `--json`'s `next` carries the same command +- [x] 3.2 @equivalence (agent) `nextSteps` versus the binary's on five fixtures (empty change, mid-build, every required artifact done with `design.md` absent, `skip_specs: true`, a `spec-driven` change) -> cospec's `nextSteps` equals the binary's respelled through the allowlist; no element names bare `openspec` -> observed: cli-surface.test.ts `3.2 nextSteps equals the binary's, respelled, on five fixtures` passes; cospec's `nextSteps` equals the binary's respelled through the allowlist on all five fixtures, no element names bare `openspec` +- [x] 3.3 @unit (agent) `resolveNext` table: first ready required, first ready optional when no required is ready, `cospec apply ` when every required is done, a skipped `specs` counts as done, nothing when all are blocked -> each case returns the expected command or nothing; JSON `next` and the text line call the same function -> observed: status.test.ts `resolveNext (verification 3.3)` (6 tests) passes: first ready required, first ready optional when none required is ready, `cospec apply ` once every required is done, a skipped `specs` counts as done, nothing when all blocked, and JSON `next`/text `Next:` call the same function +- [x] 3.4 @equivalence (agent) for each of the 11 cospec types, a change with only `proposal.md` -> the declared artifact order equals the binary's `artifacts[]` order in `status --json` -> observed: cli-surface.test.ts `3.4 each cospec type declares its artifacts in the binary order` passes; all 11 cospec types match the binary's `artifacts[]` order +- [x] 3.5 @regression (agent) the empty-change entry (`.openspec.yaml` only) -> `next` is still `cospec instructions proposal --change ` in both modes -> observed: cli-surface.test.ts `3.5 an empty change keeps its next spelling in both modes` passes; `next` is still `cospec instructions proposal --change ` in both modes + +## 4. A namespace folder is reported as one [critical] + +- [x] 4.1 @equivalence (agent) `cospec status --change mobile` and `--json` beside the binary's on `changes/mobile/refresh-token/` -> text: the binary's explanation on stderr, exit 1; `--json`: one `change_error` document whose message equals the binary's, exit 1 -> observed: cli-surface.test.ts `4.1 status --change mobile refuses it in text and --json` passes; text: binary's explanation on stderr, exit 1; `--json`: one `change_error` document, message equal, exit 1 +- [x] 4.2 @equivalence (agent) `cospec status --all --json` on the same root -> the folder's entry carries the explanation, every other entry is full, exit 1 as the binary exits -> observed: cli-surface.test.ts `4.2 status --all --json carries the folder as a failure entry` passes; the folder's entry carries the explanation, every other entry is full, exit 1 +- [x] 4.3 @equivalence (agent) `cospec list` and `list --json` -> the row reads `not a change`, `state` is `not-a-change`, `nested` and `warnings` equal the binary's, and the text ends with the binary's `Warning:` line -> observed: cli-surface.test.ts `4.3 list marks the folder in text and --json` passes; row reads `not a change`, state `not-a-change`, `nested`/`warnings` equal the binary's, text ends with the binary's `Warning:` line +- [x] 4.4 @equivalence (agent) `cospec validate mobile --json` and `validate --all --json` -> exactly one `meta/nested-change` ERROR on the folder carrying the binary's explanation, no `meta/openspec-yaml`, exit 1 -> observed: cli-surface.test.ts `4.4 validate reports the folder as one meta/nested-change` passes; exactly one `meta/nested-change` ERROR carrying the binary's explanation, no `meta/openspec-yaml`, exit 1 +- [x] 4.5 @unit (agent) detector table: each root marker, a delta file only under `specs/`, a dot-file only under `specs/`, a schema output only, a file of its own, a dot-file of its own, depths one to four, a dot-directory, `archive`, an unreadable subdirectory -> namespace only where the binary's rule says so, nested ids sorted, nothing thrown -> observed: change.test.ts `the namespace-folder detector (verification 4.5)` (8 tests) passes: each root marker, a delta file only under `specs/`, a schema output only, a file of its own, a dot-file of its own, depths one to three searched and four not, a dot-directory/archive/change-looking child never descended, nested ids sorted, nothing thrown +- [x] 4.6 @equivalence (agent) the detector matrix fixture through `cospec list --json` and `openspec list --json` -> every row's `nested` equal -> observed: cli-surface.test.ts `4.6 the detector matrix nests every row as the binary's list does` passes; every row's `nested` equal between cospec and the binary + +## 5. A schema cospec doesn't type gets real status [critical] + +- [x] 5.1 @equivalence (agent) a change on a project fork of `spec-driven`, `cospec status --change` in text and `--json` -> text lists the fork's artifacts as the binary renders them with a `Next: cospec instructions …` line; `--json` carries `change`, `type`, `legacy: true` and every binary key; exit 0; no output names bare `openspec` -> observed: cli-surface.test.ts `5.1 a project fork gets the binary status, spelled cospec` passes; text lists the fork's artifacts with a `Next:` line, `--json` carries `change`/`type`/`legacy: true` and every binary key, exit 0, no bare `openspec` +- [x] 5.2 @equivalence (agent) a built-in `spec-driven` change, the same two runs, plus `status --all` text -> the sweep's block for it is its real status, not a pointer to another command -> observed: cli-surface.test.ts `5.2 a spec-driven change, singly and in the sweep` passes; the sweep's block is real status, not a pointer to another command +- [x] 5.3 @regression (agent) a change whose schema resolves nowhere, `cospec status --change ghost --json` -> before: `{change, type, legacy}` exit 0; after: the binary's `Unknown schema` diagnostic in the document, exit 1, matching text mode's exit -> observed: cli-surface.test.ts `5.3 an unknown schema fails under --json` passes; the binary's `Unknown schema` diagnostic in the document, exit 1, matching text mode's exit +- [x] 5.4 @equivalence (agent) a hand-made change directory with only `proposal.md` in a root whose `config.yaml` says `schema: feat` -> cospec's entry is a `feat` matrix graded at `schemaVersion` 1, `schemaName` is `feat` as the binary reports; with `config.yaml` naming no schema it is `spec-driven` via the delegation path -> observed: cli-surface.test.ts `5.4 a hand-made change is typed by config.yaml` passes; entry is a `feat` matrix at `schemaVersion` 1, `schemaName` `feat` as the binary reports; with no schema named it is `spec-driven` via delegation +- [x] 5.5 @equivalence (agent) `status --all --schema fix --json`, `status --change alpha --schema nope --json`, `status --all --schema nope --json` on an empty root, `status --schema nope --json` on an empty root -> every entry computed as `fix`; the binary's `Schema 'nope' not found` `change_error` (with the `{changes: [], root: null}` payload under `--all`), exit 1; the last is the no-active-changes document, exit 0, as the binary answers -> observed: cli-surface.test.ts `5.5 --schema overrides, and an unknown one is refused as the binary refuses it` passes; every entry computed as `fix`, the binary's `Schema 'nope' not found` `change_error` with `{changes: [], root: null}` under `--all`, exit 1; the no-active-changes document under `--schema nope` on an empty root, exit 0 +- [x] 5.6 @integration (agent) grep every status output the contract rows capture -> no line names a bare `openspec` command -> observed: cli-surface.test.ts `5.6 status outputs` / "no captured status output names a bare openspec command" passes + +## 6. List sorts and survives read failures + +- [x] 6.1 @equivalence (agent) `cospec list --json`, `--sort name --json`, `--sort bogus --json` on the staged-mtime fixture -> the row order equals the binary's for each (recent, name, recent) -> observed: cli-surface.test.ts `6.1 --sort recent|name|bogus orders rows as the binary does` passes; row order equals the binary's for each +- [x] 6.2 @equivalence (agent) `openspec/changes/archive/` at mode 000, `cospec list`, `list --json`, `status --change alpha --json` -> the listing is the binary's; one document; a `warnings` entry `archive_unreadable` (JSON) or stderr line (text) names the directory; exit 0 -> observed: cli-surface.test.ts `6.2 list: an unreadable archive lists normally with a warning` and `6.2 status: an unreadable archive reports with a warning` pass; listing/status match the binary, one document, an `archive_unreadable`/stderr warning names the directory, exit 0 +- [x] 6.3 @equivalence (agent) one change's `tasks.md` at mode 000, `cospec list --json` and `status --change --json` -> the binary's `list_error` document (`{changes: [], root: null, status}`) and `change_error` document, compared by code and path, exit 1 in both -> observed: cli-surface.test.ts `6.3 list: an unreadable tasks.md is the binary's list_error` and `6.3 status: an unreadable tasks.md is the binary's change_error` pass, compared by code and path (design note: Node and Bun disagree on this case; the oracle is read under Bun at test time, the runtime cospec and the oracle both run under) Re-observed at task 11.12: that answer is macOS-only (Bun's `realpath` opens the mode-000 file and refuses it); on Linux (CI run 36547287646, and a non-root `oven/bun:1.3.14` container) the binary lists and reports the change, exit 0, so the 6.3 rows now compare against the binary's answer on either OS, its branch fixed by the runtime's observed `realpath` (rows 15.11, 15.12) +- [x] 6.4 @regression (agent) one change's `blocking-changes.md` at mode 000, `cospec list --json` -> that row carries `error`, the other rows are listed, one document, exit 1 -> observed: cli-surface.test.ts `6.4 an unreadable blocking-changes.md fails only its row` passes; that row carries `error`, other rows are listed, one document, exit 1 + +## 7. Validate resolves items and scopes as the binary does [critical] + +- [x] 7.1 @equivalence (agent) `gamma` both a change and a spec, `cospec validate gamma` and `--json` -> text: the binary's ambiguity message then `Pass --type change|spec.`, exit 1; `--json`: one `ambiguous_item` document equal to the binary's -> observed: cli-surface.test.ts `7.1 an ambiguous name is refused in text and --json` passes; text: binary's ambiguity message then `Pass --type change|spec.`, exit 1; `--json`: one `ambiguous_item` document equal to the binary's +- [x] 7.2 @equivalence (agent) `cospec validate gamm`, `validate zzzz`, both with `--json`, and a root with no candidates -> the message equals the binary's, suggestion list and order included (duplicates kept), `unknown_item` code, exit 1 -> observed: cli-surface.test.ts `7.2 an unknown name gets the binary's nearest matches` passes; message equal to the binary's, suggestion list/order included with duplicates kept, `unknown_item` code, exit 1 +- [x] 7.3 @equivalence (agent) `validate gamma --type spec`, `--type CHANGE`, `--type bogus`, `validate ../x --type change --json`, `validate nope --type spec --json` -> forced kind honoured, case-insensitive; `bogus` behaves as no flag (ambiguity refusal); `invalid_item` document equal to the binary's; a `meta/item-missing` ERROR item, exit 1 as the binary exits -> observed: cli-surface.test.ts `7.3 --type forces the kind, case-insensitively` passes; forced kind honoured case-insensitively, `bogus` behaves as no flag, `invalid_item` document equal to the binary's, a `meta/item-missing` ERROR item, exit 1 +- [x] 7.4 @equivalence (agent) `validate alpha --all`, `validate alpha --changes`, `validate alpha --specs` -> the bulk scope runs and the name is ignored, the item set equal to the binary's -> observed: cli-surface.test.ts `7.4 a bulk flag beside a name runs the bulk scope` passes; item set equal to the binary's for `--all`/`--changes`/`--specs` +- [x] 7.5 @equivalence (agent) the four `--report` refusals in text and `--json` (`--report bogus --all`, `alpha --report full`, `--archived --all --report full`, `--report findings`) run from a directory with no root -> messages and fix equal to the binary's, one `invalid_validation_report_request` document under `--json`, exit 1, no root refusal printed -> observed: cli-surface.test.ts `7.5 the four --report refusals, before any root` passes; messages/fix equal to the binary's, one `invalid_validation_report_request` document under `--json`, exit 1, no root refusal printed +- [x] 7.6 @regression (agent) `validate --all --report findings` and `--report full` on a root with one failing and one clean change, both modes -> the same exit code (1); findings lists only the failing item -> observed: cli-surface.test.ts `7.6 --report findings keeps full's exit code and lists only failing items` passes; same exit code (1) in both modes, findings lists only the failing item +- [x] 7.7 @unit (agent) the concurrency pool: `--concurrency 2` over eight stubbed validations; `0`, `abc`, unset with `OPENSPEC_CONCURRENCY=3`, and all unset -> in-flight never exceeds the bound (2, 6, 6, 3, 6); the report order equals the input order at every bound -> observed: validate.test.ts `the bulk validation pool (verification 7.7)` (parametrized bound tests plus "a bad OPENSPEC_CONCURRENCY falls back to the default; the flag outranks the env") pass; in-flight never exceeds the bound (2, 6, 6, 3, 6), report order equals input order at every bound +- [x] 7.8 @regression (agent) `proposal.md`, `tasks.md` and a delta file each at mode 000, `cospec validate --json` and `validate --all --json` -> before: the command throws; after: one document, one `meta/unreadable-artifact` ERROR naming the file and `EACCES`, other items reported, exit 1 -> observed: cli-surface.test.ts `7.8 an unreadable artifact is one meta/unreadable-artifact ERROR` passes; one document, one `meta/unreadable-artifact` ERROR naming the file and `EACCES`, other items reported, exit 1 +- [x] 7.9 @regression (agent) a change that trips the binary's no-deltas tip through delegation, and the `--archived` fallback relay (expected: cospec's report carries the tip spelled `cospec show --json --deltas-only`; no relayed line names bare `openspec`) -> observed: both halves pass on macOS (2026-10-04, after task 11.6). The no-deltas tip: cli-surface.test.ts `7.9 the binary's no-deltas tip is relayed spelled cospec` passes; cospec's report carries the tip spelled `cospec show --json --deltas-only`, and no relayed line names bare `openspec`. The `--archived` relay: row 15.5 (`openspec/changes/archive/` at mode 000) passes; the binary answers `{status:[{severity:"error", code:"validate_error", message:"EACCES: permission denied, scandir '/openspec/changes/archive'"}]}`, exit 1, and cospec relays that one document under `--json` with exit 1. In text it prints `cospec: EACCES: permission denied, scandir '/openspec/changes/archive'`, exit 1, with no ">=1.9.0" attribution and no relayed line naming a bare `openspec` command. A readable archive still renders cospec's report, exit 0. The too-old case is now decided from the binary's `--version` (`openspecBelow`, unit-tested in `test/unit/core/openspec.test.ts`), not from a missing report +- [x] 8.1 @equivalence (agent) an unreadable store registry (mode 000) with `--store s1` for `list --json`, `list --specs --json`, `status --change a --json`, `status --all --json`, `validate --all --json` -> codes `list_error`, `list_error`, `change_error`, `change_error`, `validate_error` and the payloads the binary emits, message by code and path, exit 1 -> observed: apply.test.ts `apply early exits under --json` (5 tests) plus cli-surface.test.ts `8.1 list --json` / `list --specs --json` / `status --change a --json` / `status --all --json` / `validate --all --json` (unreadableRegistry) pass; codes `list_error`/`list_error`/`change_error`/`change_error`/`validate_error` and the binary's payloads, message by code and path, exit 1 +- [x] 8.2 @unit (agent) `apply` early exits under `--json`: no `openspec/`, unknown change with and without a suggestion, a failed legacy delegation, a failed step-5 call -> each prints exactly one `{status: [{severity, code, message, fix?}]}` document on stdout, nothing on stderr, exit 1 -> observed: apply.test.ts `apply early exits under --json` (5 cases: no `openspec/`, unknown change with/without suggestion, failed legacy delegation, failed step-5 call) pass; each prints exactly one `{status: [{severity, code, message, fix?}]}` document, nothing on stderr, exit 1 +- [x] 8.3 @integration (agent) `cospec apply nope --json` through the real CLI -> one `change_error` document naming `nope`, exit 1 -> observed: cli-surface.test.ts `8.3 cospec apply nope --json is one change_error document naming nope` passes +- [~] 8.4 @equivalence (agent) `--store nope` with a store registered, for `list --json`, `list --specs --json`, `status --all --json`, `status --change a --json`, `validate --all --json` (expected: the binary's `unknown_store` diagnostic — code, message, target, fix — inside the binary's payload (`{changes: [], root: null}`, `{specs: [], root: null}`, `{changes: [], root: null}`, none, none), one document, exit 1) -> defer: code/target/fix (respelled)/payload equal, and the message names the store and the registered ids, ARE observed — cli-surface.test.ts `8.4 an unknown store carries the binary's diagnostic inside its payload` (list --json / list --specs --json / status --change a --json / status --all --json / validate --all --json) pass on those. The message text itself is `root-resolution-parity`'s documented wording (`unknown store '...' — register it with 'cospec store register ' ...`), not the binary's (`Unknown store '...'. Registered stores: ...`); `root.ts` is outside this change's files (design D10) — ruled not to compare message text for this row: the orchestrator's round-1 review ruling (handoff progress.md) accepts `root-resolution-parity`'s documented wording, and the row compares code, target, fix, payload and exit, all observed; re-observed at task 11.11, all five `8.4` tests pass + +## 9. Completion serves schemas and archived changes + +- [x] 9.1 @equivalence (agent) `cospec __complete schemas`, `archived-changes`, `SCHEMAS` beside the binary's in a root with a project fork and two archived changes -> the same ids in the same order; each line tab-separated; exit 0; outside a root both are silent exit 1 -> observed: cli-surface.test.ts `9.1 schemas and archived-changes complete as the binary lists them` passes; same ids in the same order, tab-separated, exit 0; silent exit 1 outside a root +- [x] 9.2 @integration (agent) `completion.test.ts` on the generated bash, zsh and fish scripts -> `status --schema`, `templates --schema`, `instructions --schema` and `schema which|validate|fork` positionals call `cospec __complete schemas`; no script names the `openspec` binary -> observed: completion.test.ts `generated scripts complete schema names from cospec __complete schemas` (3 tests: no generated script names the openspec binary, zsh each slot calls \_cospec_dynamic schemas, fish each slot completes from cospec \_\_complete schemas) pass + +## 10. Schema classification reads the binary's user directory + +- [x] 10.1 @unit (agent) `userSchemasDir` with `XDG_DATA_HOME` set, empty, unset on darwin/linux, and win32 with and without `LOCALAPPDATA` -> the binary's `getGlobalDataDir` path plus `schemas` in every case; `change.ts`, `new.ts` and `change-metadata.ts` import the one helper -> observed: change.test.ts `userSchemasDir is the binary's getGlobalDataDir plus schemas in every case` plus commands.test.ts `userSchemasDir follows its global data dir, never ~/.config` and `with no $XDG_DATA_HOME the home directory decides` pass; `change.ts`, `new.ts` and `change-metadata.ts` all import the one helper, asserted by the source-grep in the change.test.ts case +- [x] 10.2 @equivalence (agent) a change on a schema present only under `$XDG_DATA_HOME/openspec/schemas`, and one present only under `~/.config/openspec/schemas` -> the first classifies `legacy`/`user` and `cospec validate` delegates it as the binary resolves it; the second is `unknown`, as the binary refuses it -> observed: cli-surface.test.ts `10.2 a user-dir schema is legacy; one under ~/.config is unknown` passes; the `$XDG_DATA_HOME` schema classifies legacy/user and delegates as the binary resolves it, the `~/.config` one is unknown as the binary refuses it + +## 11. The target-invalid dedupe is linear + +- [x] 11.1 @regression (agent) living spec duplicating `### Requirement: Widget "quoted" name`, `cospec validate --json` -> before: cospec's `archive/target-invalid` ERROR plus the binary's structurally-invalid INFO; after: the ERROR only -> observed: validation-parity.test.ts `12.5 a duplicated quoted requirement header is reported once, by cospec` passes; cospec's `archive/target-invalid` ERROR appears once, the binary's structurally-invalid INFO is not also reported +- [x] 11.2 @unit (agent) the ReDoS guard: 200 quote-heavy defect lines plus a non-matching tail, run against the pre-fix pattern and the new matcher -> the pre-fix pattern exceeds the bound (the test fails when pointed at it), the new matcher finishes under it; the existing dedupe cases still pass -> observed: re-observed at task 11.11 (2026-10-04): validate.test.ts `the target-invalid dedupe is linear (verification 11.2, 15.13)` passes 4/4: `the per-line matcher refuses the adversarial message well under the bound` (200 quote-heavy lines plus an unmatched tail, < 100 ms) and `the per-line matcher reads a quoted header and keys on the capability`; the pre-fix pattern is no longer compiled to be timed (task 11.13, CodeQL js/redos #15) — it is kept as text and flagged by `the star-height guard flags the pre-fix pattern and the textbook shapes` (row 15.13), so the row still fails when pointed at the pre-fix pattern; the existing `mergeDelegated` dedupe cases (6 tests) still pass + +## 12. The masked-view exception is proven + +- [x] 12.1 @equivalence (agent) `validation-parity.test.ts` row: a delta whose only mis-depth scenario is inside an HTML comment, `openspec archive -y` and `cospec validate --strict` -> the binary archives it (the change moves), cospec reports no `deltas/scenario-depth`; `views.ts` and `views.test.ts` cite this row by name -> observed: validation-parity.test.ts `36.1 commented mis-depth scenario: the binary archives it and cospec raises no deltas/scenario-depth` passes; the binary's archive exits 0 and moves the change, cospec reports no `deltas/scenario-depth` and exits 0; `views.ts` and `views.test.ts` cite this row by name +- [x] 12.2 @manual (agent) `grep -F` the standing rule sentence in `docs/validation.md`'s parser-tolerances section -> present verbatim -> observed: `docs/validation.md`'s parser-tolerances section (line 210) carries the standing rule verbatim: "a gate rule reads the verbatim view unless a differential fixture proves the verbatim view makes cospec refuse something the binary accepts AND the structural outcome is gated elsewhere on the verbatim view; such exceptions live only in the enumeration test with their fixture." + +## 13. Docs match the shipped behavior + +- [x] 13.1 @manual (agent) `apps/docs/reference/commands.md` -> the list/status/validate/`__complete` rows name `--sort`, `--schema` (override), `--type`, `--report`, `--concurrency`/`OPENSPEC_CONCURRENCY`, the added keys, the named collision, `next`, and each BREAKING item -> observed: `apps/docs/reference/commands.md`'s list/status/validate/\_\_complete rows (lines 111-113) name `--sort`, `--schema` (override), `--type`, `--report`, `--concurrency`/`OPENSPEC_CONCURRENCY`, the added keys, the named collision (`items[].type`/`kind`), `next`, and every BREAKING item, including the `no_openspec_root` addition and the `meta/nested-change` rule-id note +- [x] 13.2 @manual (agent) `apps/docs/concepts/how-it-relates-to-openspec.md` -> the sentence calling namespace folders "a delegated refusal cospec relays verbatim" is replaced by the native detection statement -> observed: `apps/docs/concepts/how-it-relates-to-openspec.md` (lines 61-66) states the namespace folder is natively detected, not a delegated refusal relayed verbatim +- [x] 13.3 @manual (agent) `docs/architecture.md` -> the one-delegated-call additive merge (`core/upstream-keys.ts`), the key oracle and the detector's home are described -> observed: `docs/architecture.md` describes the one-delegated-call additive merge (`core/upstream-keys.ts`, line 290), the key oracle (line 305) and the detector's home (`core/change.ts`, lines 470/538) +- [x] 13.4 @manual (agent) `apps/docs/reference/validation-rules.md` -> `meta/nested-change`, `meta/unreadable-artifact`, `meta/item-missing` rows and the `--json`/findings document shapes -> observed: `apps/docs/reference/validation-rules.md` carries `meta/nested-change` (line 93), `meta/unreadable-artifact` (line 94), `meta/item-missing` (line 95) rows and the `--json`/findings document shapes (lines 27-52) +- [x] 13.5 @manual (agent) `openspec/changes/archive/2026-09-28-validation-parity/tasks.md` -> row 7.2 is `[x]` with a note that the archive ran with the tasks gate waived for that one self-referential row and both hard gates run -> observed: `openspec/changes/archive/2026-09-28-validation-parity/tasks.md` row 7.2 is `[x]` (line 97) with the note that the archive ran with `--force-incomplete`, waiving the tasks gate for that one self-referential row, both hard gates run +- [x] 13.6 @integration (agent) `mise run docs:build` -> exit 0 -> observed: `mise run docs:build` exits 0 (part of the `mise run check` run recorded at 14.4) +- [x] 13.7 @integration (agent) `.agents/shared.md` gains the additive-JSON discipline paragraph (one delegated call, merge by identity through `core/upstream-keys.ts`, no cospec key or value changed, `version` 1, the key oracle and its named collision list), then `mise run agents:sync` and `mise run agents:check` -> the paragraph is present in `CLAUDE.md` and `AGENTS.md`, agents:check exit 0 -> observed: `.agents/shared.md` line 233 (synced to `CLAUDE.md` line 233, `AGENTS.md` line 237) carries the "JSON documents are additive" paragraph — one delegated call, merge by identity through `core/upstream-keys.ts`, no cospec key or value changed, `version` 1, the key oracle and its named collision list; `mise run agents:check` exits 0 (part of the check run at 14.4) + +## 14. Close-out + +- [x] 14.1 @integration (agent) `grep -c 'test.todo\|test.failing' apps/cli/test/contract/cli-surface.test.ts` -> 0 -> observed: `grep -c 'test.failing\|test.todo' apps/cli/test/contract/cli-surface.test.ts` = 0; repo-wide `grep -rn 'test\.failing\|test\.todo\|KNOWN_FAILING' apps/cli/test/` finds only two empty `KNOWN_FAILING: ReadonlySet = new Set([])` declarations (`unknown-option-differential.test.ts`, `precedence-matrix.test.ts`) with zero members — no failing/todo row anywhere in the suite Re-observed at task 11.11 (2026-10-04, `2e2c60c3` plus 11.11's docs/ledger edits): `grep -rnE 'test\.failing|test\.todo|KNOWN_FAILING|\.only\(' apps/cli/test packages/bench/test` finds no `test.todo` and no `.only(`; its only hits are the two `KNOWN_FAILING` declarations from `main` (`unknown-option-differential.test.ts:492`, `precedence-matrix.test.ts:1607`), each `new Set([])` with 0 members, and the `test.failing` branches that only those empty sets could select Re-observed at task 12.13 (2026-10-04, `f4578caa` plus 12.13's docs/ledger edits): `grep -c 'test.failing\|test.todo' apps/cli/test/contract/cli-surface.test.ts` = 0 with every group-16 row flipped; the repo-wide grep still finds only the two empty `KNOWN_FAILING` sets Re-observed at task 13.1: `grep -c 'test.todo\|test.failing' apps/cli/test/contract/cli-surface.test.ts` = 0 +- [x] 14.2 @integration (agent) `mise run cospec -- validate --all --strict` on this repo -> exit 0 -> observed: part of the `mise run check` run at 14.4: `[//:cospec-validate-all]` step exits with "0 errors, 0 warnings — validation passed" Re-observed at task 11.11: `[//:cospec-validate-all]` in that `mise run check` run prints "0 errors, 0 warnings — validation passed", exit 0 Re-observed at task 12.13: the `[//:cospec-validate-all]` step of the `mise run check` run at 14.4 passes with 0 errors, 0 warnings Re-observed at task 13.1: `[//:cospec-validate-all]` in that `mise run check` run prints "0 errors, 0 warnings — validation passed", exit 0 +- [x] 14.3 @manual (agent) the proposal's BREAKING list against the shipped behavior -> each item is observed in a contract row above and none is missing -> observed: the proposal's BREAKING list checked against the shipped behavior: each item is observed in a contract row above (list `--sort`/order, `status` `root` shape, namespace-folder exits on `status`/`list`/`validate`, `validate`'s bulk/ambiguous/unknown resolution, `status --json` on a schema cospec doesn't type, `config.yaml` typing a change with no `.openspec.yaml`, `list`'s `no_openspec_root` refusal, and `meta/nested-change` replacing `meta/openspec-yaml` for `validate`/`apply`/`archive`, added to the BREAKING list at this stage) and none is missing Re-observed at task 11.11: round 2 adds no BREAKING item. Every round-2 fix brings an answer back to the binary's (rows 15.1–15.13) without changing a documented cospec key, rule id or exit code beyond the list. The list in proposal.md is unchanged and is relayed verbatim in the PR body Re-observed at task 13.1: the BREAKING list gains `status` exiting 1 on a cospec-typed change whose schema the binary cannot load, observed in row 17.1 Re-observed at task 13.2: the BREAKING list gains `status` exiting 1 on a cospec-typed change whose `.openspec.yaml` the binary refuses, observed in row 17.3 +- [x] 14.4 @integration (agent) `mise run check` -> exit 0 -> observed: `env -u FORCE_COLOR -u NO_COLOR -u COLORTERM -u CLICOLOR MISE_AUTO_INSTALL=0 mise run check` exit 0, on `a9b87755` plus this stage's two fixes (the stale rule-id comment and the BREAKING-list addition): unit 1759, contract 2281, integration 176, bench 339, release 14 — 0 fail; lint, format, typecheck, `generate:check`, `vendor:openspec:check`, `agents:check`, `cospec-validate-all` and `openspec:schema:validate` all green. `mise run docs:build` (not part of `check`, apps/docs changed by task 9.1) exits 0 separately Re-observed at task 11.11 (2026-10-04, macOS): `env -u FORCE_COLOR -u NO_COLOR -u COLORTERM -u CLICOLOR -u NODE_OPTIONS MISE_AUTO_INSTALL=0 mise run check` exit 0 in 1192 s, with unit 1837/0, contract 2499/0 (one process), integration 176/0, bench 343/0 and release 14/0. Lint, format:check (942 files), typecheck, generate:check, vendor:openspec:check, agents:check, cospec-validate-all and openspec:schema:validate are all green. `mise run docs:build` exits 0 separately Re-observed at task 12.13 (2026-10-04, `f4578caa` plus 12.13's docs/ledger edits): `env -u FORCE_COLOR -u NO_COLOR -u COLORTERM -u CLICOLOR MISE_AUTO_INSTALL=0 mise run check` exit 0: unit 1847, integration 176, contract 2512, bench 343 and release-test 14 pass, 0 fail Re-observed at task 13.1 (2026-10-04, `dd8e9d87` plus 13.1's fix, row and docs): `env -u FORCE_COLOR -u NO_COLOR -u COLORTERM -u CLICOLOR MISE_AUTO_INSTALL=0 mise run check` exit 0: unit 1847, integration 176, contract 2513, bench 343 and release-test 14 pass, 0 fail Re-observed at the merge stage (2026-10-05, rebased onto `main` at `67f20c5d` plus 13.3's fix, row 17.5 and docs): `env -u FORCE_COLOR -u NO_COLOR -u COLORTERM -u CLICOLOR -u NODE_OPTIONS MISE_AUTO_INSTALL=0 mise run check` exit 0: unit 1858, integration 176, contract 2521, bench 343 and release-test 14 pass, 0 fail — lint, format:check (950 files), typecheck, generate:check, vendor:openspec:check, agents:check, cospec-validate-all (1 change, 22 specs) and openspec:schema:validate all green. `mise run docs:build` exits 0 separately + +## 15. Round-2 review fixes [critical] + +- [x] 15.1 @equivalence (agent) `validate .hidden --type spec` and `validate linked --type spec` (a capability behind a symlinked directory), `--json` and text -> the binary's exit code (1), the same item ids and verdicts, every message the binary reports present in cospec's issues — never an empty passing report -> observed: cli-surface.test.ts `15.1 --type spec on a spec discovery skips validates the file, as the binary does` passes (2026-10-04, macOS); sandbox probe: `validate .hidden --type spec --json` and `validate linked --type spec --json` exit 1 from both tools, one item each (`.hidden`/`linked`, kind `spec`, `valid: false`) carrying the binary's ERROR `Spec must have a Purpose section. Missing required sections. …` (cospec's rule `openspec/validate`); text mode exits 1 too +- [x] 15.2 @equivalence (agent) a hand-made change holding only `rfc/proposal.md` on a project schema generating `rfc/{proposal,design}*.md` -> `list --json` does not mark it `not-a-change` (the binary's row has no `nested`) and `validate --json` reports no `meta/nested-change` -> observed: cli-surface.test.ts `15.2 a hand-made change whose schema output a brace glob matches is a change` passes: the binary's `rfc-change` row has no `nested`, cospec's row `state` is not `not-a-change`, and `validate rfc-change --json` carries no `meta/nested-change` +- [x] 15.3 @equivalence (agent) `nested-detector.test.ts`: cospec's `findNestedChangesIn` beside the pinned binary's over every schema in the pinned dist, every cospec type, and project schemas generating with braces, a numeric range, each extglob and a negation (a hand-made change holding one output, a folder wrapping one) -> every answer equal, and no such change is ever reported as a folder; `glob.test.ts` holds the port's regex sources, brace expansions and `artifactOutputExists` answers to the binary's modules -> observed: nested-detector.test.ts `the namespace-folder detector answers as the binary's findNestedChangesIn` passes all 21 tests: the pinned dist's `spec-driven`, the 11 cospec types, 8 project schemas (`rfc/{proposal,design}*.md`, `rfc/@(proposal|design)*.md`, `rfc/!(README)*.md`, `rfc/+([a-z])-notes.md`, `rfc/?(draft-)proposal.md`, `notes/{1..3}-*.md`, `{rfc,adr}/**/*.md`, `!rfc/*.md`) and `no hand-made change holding only its schema output is reported as a folder`; glob.test.ts `core/glob.ts answers as the pinned binary's glob modules` passes all 5 (`cospec resolves the fast-glob the pinned binary resolves` — 3.3.3 — brace expansions, compiled regex sources, `artifactOutputExists` over a populated change and with each file alone) +- [x] 15.4 @equivalence (agent) a project schema `rfc` (`doc.md`, `notes.md`): `status --change r-empty` and `--change r-doc` in text and `--json`, `status --all` in text and `--json` -> the binary's status body, exit and keys; `next` is `cospec instructions doc --change r-empty` and, `notes` being optional under `apply.requires: [doc]`, `cospec apply r-doc` (D4: an optional artifact never holds a change back from its gate), while `nextSteps` is the binary's own sentence for `doc`/`notes` spelled cospec; nothing names `proposal` -> observed: cli-surface.test.ts `15.4 a custom schema's artifacts decide its status, singly and in the sweep` passes: `status --change r-empty|r-doc` text and `--json` exit as the binary (0), the text body equals the binary's, `next` is `cospec instructions doc --change r-empty` and `cospec apply r-doc`, `nextSteps` is the binary's respelled (naming `cospec instructions doc`/`notes`), the key oracle passes on each single-change document and on the `--all --json` sweep (`STATUS_ALL_SPEC`), `status --all` text and `--json` exit as the binary's (0), carry the same `next` and the binary's body lines, and nothing names `proposal`: no sweep entry's `next` or `nextSteps` and no `cospec instructions proposal` line in the text sweep (re-observed 2026-10-04, macOS) +- [x] 15.5 @equivalence (agent) `validate --archived` with `openspec/changes/archive/` at mode 000, `--json` and text -> under `--json` the binary's one failure document, respelled, with its exit code; in text `cospec: `, and no ">=1.9.0" attribution -> observed: cli-surface.test.ts `15.5 validate --archived relays the binary's failure document` passes: both tools exit 1; under `--json` cospec prints the binary's `{status:[{severity:"error", code:"validate_error", message:"EACCES: permission denied, scandir '/openspec/changes/archive'"}]}` respelled; text stderr is exactly `cospec: EACCES: permission denied, scandir '/openspec/changes/archive'`, no `1.9.0`, no bare `openspec` command +- [x] 15.6 @regression (agent) `openspec/changes/archive/` at mode 000, `validate ready --json`, `validate --all --json`, `apply ready --json` and their text forms -> each answers as it does with the archive readable, one document carrying one `archive_unreadable` warning, text printing it on stderr; `validate --all`'s exit code is the binary's -> observed: cli-surface.test.ts `15.6 an unreadable archive leaves validate and apply answering with a warning` passes: `validate ready --json`, `validate --all --json` and `apply ready --json` each exit 0 (`validate --all` equal to the binary's exit), apply's gate `clear`, each document carries exactly `warnings: [{code: "archive_unreadable", …openspec/changes/archive…}]`, each text form prints `Warning: could not read …openspec/changes/archive…` on stderr and exits as its `--json` form (0), and with the archive readable the documents are the same bar the warning and each text form's stdout and exit are the same, with no warning (re-observed 2026-10-04, macOS) +- [x] 15.7 @equivalence (agent) `validate --all|--changes|--specs --json` outside any root -> the binary's one `no_openspec_root` document (`fix` spelled `cospec init`), exit 1; bare `validate --json` answers the same document -> observed: cli-surface.test.ts `15.7 validate --json outside a root is the binary's one no_openspec_root document` passes: `validate --all|--changes|--specs --json` exit 1 as the binary, one document equal to the binary's respelled, `{severity: "error", code: "no_openspec_root", message: "No OpenSpec root found from the current directory.", target: "openspec.root", fix: "Run cospec init to create a root here."}`; bare `validate --json` exits 1 with a document equal to each scoped form's (re-observed 2026-10-04, macOS) +- [x] 15.8 @equivalence (agent) `list --specs` with a capability directory at mode 000, `--json` and text -> the binary's failure document respelled, exit 1; text `cospec: ` then its `Fix:` line when it has one -> observed: cli-surface.test.ts `15.8 list --specs relays the binary's failure document and fix` passes: both tools exit 1, cospec's `--json` document equals the binary's `{specs: [], root: null, status: [{code: "list_error", message: "EACCES: … scandir …"}]}` respelled, and text exits 1 with stderr `cospec: ` plus `Fix:` only when the binary's diagnostic has one +- [x] 15.9 @regression (agent) a living `spec.md` at mode 000, `validate foo`, `validate foo --type spec`, `validate --specs`, `validate --all`, each `--json` -> one document, exit 1 as the binary's, the spec's one issue a `meta/unreadable-artifact` ERROR naming the file and EACCES; every other spec reported as it is alone, bar — where the binary refuses it over the unreadable file (Bun's `realpath` on macOS) — that refusal as its one added `openspec/validate` ERROR -> observed: cli-surface.test.ts `15.9 an unreadable living spec is one meta/unreadable-artifact ERROR` passes: `validate foo`, `validate foo --type spec`, `validate --specs`, `validate --all`, each `--json`, exit 1 as the binary's, one document, `foo`'s only issue `{level: "ERROR", rule: "meta/unreadable-artifact"}` naming `specs/foo/spec.md` and `EACCES`; `bar`'s issues and verdict equal to `validate bar --json` alone; text `validate foo` exits 1 printing `meta/unreadable-artifact` +- [x] 15.10 @unit (agent) `parseSchemaConformanceJson` on a `status[]` refusal, a report carrying a `status[]` error, a document without `summary`, one without `items`, and a non-object -> null for each -> observed: mechanical.test.ts `parseSchemaConformanceJson` passes 9/9, including `returns null on a status[] refusal document (cospec validate --json no-root)`, `returns null on a status[] error beside a report`, `returns null when summary is absent: no report is not a clean pass`, `returns null when items is absent` and `returns null on a JSON value that is not an object` +- [x] 15.11 @equivalence (agent) `beta`'s `tasks.md` at mode 000, `list` and `status --change beta`, text and `--json`, on macOS and in a Linux container as a non-root user -> the binary's exit code on each OS; where the binary refuses (its runtime's `realpath` refuses the file) its document relayed whole and `cospec : ` in text; where it reports, the key oracle passes, `beta` counts 0/0 tasks with no `error`, and one `tasks_unreadable` warning names the file (`Warning:` on stderr in text) -> observed: cli-surface.test.ts `15.11 an unreadable tasks.md: list and status --change answer as the binary does` passes on macOS (`mise run check`, the binary refuses: `list_error`/`change_error` naming `realpath` relayed whole, exit 1 in text and `--json`) and in a non-root (uid 1000) `oven/bun:1.3.14` + Node 22.23.3 container over a copy of the worktree (the binary reports: exit 0, key oracle green, `beta` 0/0 tasks, one `tasks_unreadable` warning) +- [x] 15.12 @equivalence (agent) the same fixture, `status --all` text and `--json`, on both OSes -> the binary's exit code; where the binary's `beta` entry is a failure, cospec's `beta` entry carries its message as `error` and its `status`, and text prints `beta: ERROR — `; where it reports, the key oracle passes and `beta` counts 0/0 tasks with the warning -> observed: cli-surface.test.ts `15.12 an unreadable tasks.md: status --all answers its change as the binary does` passes on macOS (beta's entry carries the binary's `realpath` message and `status`) and in the same Linux container (beta reported 0/0 with the warning, key oracle green) +- [x] 15.13 @unit (agent) `validate.test.ts`: the star-height guard over the pre-fix target-invalid pattern (as text, never compiled), the textbook shapes, `TARGET_INVALID_HEAD` and `TARGET_INVALID_LINE`, and the per-line matcher on the 200-line adversarial message -> the pre-fix pattern and every textbook shape flagged, both `TARGET_INVALID` patterns clear, no regex the file compiles repeats a group over an unbounded quantifier (CodeQL js/redos alert #15), and the matcher refuses the message under 100 ms -> observed: validate.test.ts `the target-invalid dedupe is linear (verification 11.2, 15.13)` passes 4/4: `the star-height guard flags the pre-fix pattern and the textbook shapes`, `neither of the per-line matcher's patterns repeats a quantified group` (`TARGET_INVALID_HEAD`, `TARGET_INVALID_LINE` clear), `the per-line matcher refuses the adversarial message well under the bound` (< 100 ms), `the per-line matcher reads a quoted header and keys on the capability`; the file compiles no regex with a repeated group over an unbounded quantifier (u4 mutation check: injecting `(?:x.*)+` into `TARGET_INVALID_LINE` turns the guard row red); CodeQL alert #15 state recorded at close-out in the PR's code-scanning query + +## 16. Round-3 review fixes [critical] + +- [x] 16.1 @equivalence (agent) a `spec-driven` change `foo` with no deltas beside a living spec `foo`, `validate foo --type change --json` and `validate --changes --json`; a `feat` change `bar` with a delta, `validate bar --type change --json` with and without a living spec `bar` -> exit 1 as the binary's, `foo` invalid carrying every message the binary reports for it, and `bar`'s document the same either way, never naming `Ambiguous` -> observed: cli-surface.test.ts `16.1 a change sharing a living spec's name is validated as one, in both lanes` passes (2026-10-04, macOS, and in a non-root uid 1000 `cospec-r6-linux` (oven/bun:1.3.14) container over a copy of the worktree): `validate foo --type change --json` and `validate --changes --json` exit 1 from both tools, `foo` `valid: false` carrying the binary's `Change must have at least one delta. …` ERROR respelled; `validate bar --type change --json` is the same document with and without the living spec `bar`, naming no `Ambiguous`; red before the fix (cospec exited 0, `foo` `valid: true`) +- [x] 16.2 @regression (agent) living specs `foo` (valid) and `bar` (no `## Purpose`), `foo` at mode 000, `validate bar --json` -> exit 1 as the binary's, `bar` invalid; where the binary answers for `bar` alone the document equals the one with `foo` readable, and where it refuses over `foo` its refusal is `bar`'s `openspec/validate` ERROR -> observed: cli-surface.test.ts `16.2 validate alone is answered whatever a sibling spec's mode` passes on macOS (the binary refuses `validate bar --json` with `validate_error` `EACCES … realpath …/specs/foo/spec.md`, exit 1; cospec exits 1 with `bar` `valid: false` carrying that refusal as its `openspec/validate` ERROR) and in the Linux container (the binary answers for `bar` alone; cospec's document equals the one with `foo` readable, the `Spec must have a Purpose section` ERROR included); red before the fix (exit 0, `bar` `valid: true`) +- [x] 16.3 @regression (agent) a `feat` change `c1` whose MODIFIED delta targets a living `gadgets` spec at mode 000, `validate c1 --json`, `validate --all --json`, `validate --changes --json`, `apply c1 --json` and text `validate c1` -> one document each, exit 1 (the binary's for validate), `c1`'s one issue a `meta/unreadable-artifact` ERROR on `specs/gadgets/spec.md` naming `openspec/specs/gadgets/spec.md` and EACCES; every other change keeps its own answer -> observed: cli-surface.test.ts `16.3 an unreadable living spec a delta targets fails that change, never the command` passes on macOS and in the Linux container: each of `validate c1 --json`, `validate --all --json`, `validate --changes --json` and `apply c1 --json` prints one document, exit 1 (the binary's exit for the validate forms), `c1`'s only issue `{level: ERROR, rule: meta/unreadable-artifact, path: specs/gadgets/spec.md}` with message `could not read openspec/specs/gadgets/spec.md (EACCES)`; `ready` keeps its own issues, plus on macOS the binary's refusal over the spec as one `openspec/validate` ERROR; text `validate c1` exits 1 printing `meta/unreadable-artifact`; red before the fix (empty stdout, `cospec: EACCES …` on stderr) +- [x] 16.4 @equivalence (agent) a living spec carrying only the binary's WARNINGs, `validate --specs --strict --json` and `validate baz --strict --json` -> exit, `items[].valid`, `summary.totals` and `summary.byType` equal to the binary's -> observed: cli-surface.test.ts `16.4 --strict fails a warning-only spec as the binary does, in valid and the totals` passes: both argvs exit 1 from both tools, `items` `[['baz', false]]`, `summary.totals` `{items: 1, passed: 0, failed: 1}` and `summary.byType` equal to the binary's; red before the fix (`valid: true`, `passed: 1`) +- [x] 16.5 @equivalence (agent) `validate foo` outside any root, `--json` and text -> the binary's `unknown_item` document and exit, text `cospec: Unknown item 'foo'.`; `validate foo --all --json` keeps `no_openspec_root` -> observed: cli-surface.test.ts `16.5 validate outside any root is an unknown item, as the binary answers` passes: `validate foo --json` exits 1 with a document equal to the binary's `{status: [{severity: error, code: unknown_item, message: "Unknown item 'foo'."}]}`, text stderr exactly `cospec: Unknown item 'foo'.`, and `validate foo --all --json` still `no_openspec_root` +- [x] 16.6 @regression (agent) a `fix` change with no artifacts, `status --change e1 --json` and `status --all --json` -> `artifacts: []` in both (the binary's artifacts never appended into it), every other binary key present (`kept: ['artifacts']`) -> observed: cli-surface.test.ts `16.6 an empty cospec-typed change keeps artifacts: [] in --change and --all` passes: the binary lists the `fix` schema's artifacts while cospec's `status --change e1 --json` carries `artifacts: []` and `state: in-progress`, the key oracle passes with `kept: ['artifacts']`, and the `--all --json` entry for `e1` carries `artifacts: []` beside the binary's `changeName`; the oracle's own self-test `a kept path is compared by presence and type, never by its entries` passes +- [x] 16.7 @equivalence (agent) a regular file `openspec/changes/todo`, `status --change todo` text and `--json`, `apply todo --json` -> exit 1 as the binary's, nothing on stdout in text, one `change_error` document, apply's `unknown change 'todo'` -> observed: cli-surface.test.ts `16.7 a regular file under changes/ is no change, as the binary refuses it` passes: text `status --change todo` exits 1 as the binary's with nothing on stdout, `--json` is one `{status: [{code: change_error, …}]}` document with the binary's exit, and `apply todo --json` is `change_error` `unknown change 'todo'`; change.test.ts `looks a change up by its directory name, as the binary does` passes +- [x] 16.8 @equivalence (agent) a change directory `Add_Auth`, `status --change Add_Auth` text and `--json`; `archive` and `.hidden` as lookup names -> exit 0 as the binary's, the key oracle passes, no `Did you mean`; the reserved and hidden names refused and never suggested back -> observed: cli-surface.test.ts `16.8 a non-kebab change directory is looked up as the binary looks it up` passes: `status --change Add_Auth` exits 0 as the binary's in text and `--json` (no `Did you mean`), the key oracle passes with `change: Add_Auth`, and `archive` and `.hidden` exit 1 without suggesting themselves back; upstream-spellings.test.ts's `instructions apply --change 1foo` rows pass with apply's gate refusing `1foo` by `meta/name-kebab` +- [x] 16.9 @equivalence (agent) a `feat` change `demo` whose directory, then whose `proposal.md`, is at mode 000: `status --change demo` text and `--json`, `status --all` text and `--json` -> each exit as the binary's; where the binary refuses, its document relayed whole, `cospec status: ` in text with nothing on stdout, and `demo`'s sweep entry carrying the message as `error` -> observed: cli-surface.test.ts `16.9 a change the binary refuses is refused by status, in both modes and the sweep` passes on macOS (directory and `proposal.md` both refused by the binary: cospec's `--json` document equals the binary's, text stderr is `cospec status: EACCES: permission denied, realpath '…'` with empty stdout, the `--all` entry for `demo` carries the message as `error` and `other` none, every exit the binary's) and in the Linux container (the directory refused the same way; the binary reads past the mode-000 `proposal.md`, so cospec reports `demo`, exit 0); red before the fix (exit 0 with the binary's `change_error` merged into an in-progress entry) +- [x] 16.10 @equivalence (agent) `openspec/changes`, `openspec/specs` and a capability directory at mode 000: `validate --all|--specs|demo --json`, `status --change demo --json`, `status --all --json`, `apply demo --json` -> one document each equal to the binary's respelled (apply's `change_error` naming the directory and EACCES), exit 1, nothing on stderr; text `validate --all` prints the message, exit 1 -> observed: cli-surface.test.ts `16.10 an unreadable planning directory is one --json document per command` passes on macOS and in the Linux container: `validate --all|--specs|demo --json`, `status --change demo --json` and `status --all --json` each exit 1 with a document equal to the binary's (`validate_error`/`change_error`, `EACCES: permission denied, scandir '…'`; `{changes: [], root: null, status}` for `--all`) and nothing on stderr; `apply demo --json` is `change_error` naming `openspec/changes` and EACCES; text `validate --all` exits 1 printing the message; errno.test.ts passes 4/4 +- [x] 16.11 @equivalence (agent) a clear `chore` change whose project schema file is at mode 000, `apply c1 --json` and text -> the binary's `instructions apply` failure document respelled, exit 1; text `cospec apply: ` then its `Fix:` line when it has one -> observed: cli-surface.test.ts `16.11 apply relays the binary's refusal of the apply instructions` passes: with the clear `chore` change's `schema.yaml` at mode 000 the binary's `instructions apply --change c1 --json` exits 1 with its failure document, cospec's `apply c1 --json` exits 1 with that document respelled, and text stderr is `cospec apply: ` (plus `Fix:` only when the binary has one); apply.test.ts's failed legacy delegation and failed step-5 call relay the binary's `Invalid schema at …` and `Unknown schema 'ci'.` +- [x] 16.12 @regression (agent) a change holding `.cache/`, `specs/.h/` and `scratch/`, each at mode 000 in turn, `validate demo --json` -> the document and exit equal to the readable ones (the binary's exit unchanged too); `specs/widgets/` at mode 000 still a `meta/unreadable-artifact` -> observed: cli-surface.test.ts `16.12 an unreadable directory no artifact lives in leaves the change as it is` passes on macOS and in the Linux container: with `.cache/`, `specs/.h/` and `scratch/` each at mode 000 the binary's exit is unchanged and cospec's document and exit equal the readable ones; `specs/widgets/` at mode 000 is still a `meta/unreadable-artifact`; red before the fix (a lone `meta/unreadable-artifact` on `.cache`) +- [x] 16.13 @unit (agent) `status.test.ts`: the relayed binary failure document carrying the allowlisted `Change '' not found. No changes exist. Create one with: openspec new change ` sentence -> its `status[].message` and the text relay spelled through the allowlist, no bare `openspec` command left -> observed: status.test.ts `every relayed binary diagnostic is spelled cospec (verification 16.13)` passes 2/2: `upstreamFailure` yields `Change 'todo' not found. No changes exist. Create one with: cospec new `, and `respelledUpstream` respells `status[].message` and `fix` singly and in a sweep entry, leaving no bare `openspec` command + +## 17. Round-4 review fixes [critical] + +- [x] 17.1 @equivalence (agent) a `chore` change `ch1` beside a `feat` change `other`, the root's `openspec/schemas/chore/` removed, its `schema.yaml` unparsable, its `schema.yaml` invalid, and the directory at mode 000 in turn: `status --change ch1` and `status --all`, text and `--json` -> every exit 1 as the binary's; `--json` the binary's `change_error` document respelled; text stderr `cospec status: ` per diagnostic with nothing on stdout (the removed schema's message naming `Unknown schema 'chore'`); the sweep's `ch1` entry carrying the message as `error`, text `ch1: ERROR — `, and `other` reported -> observed: cli-surface.test.ts `17.1 a cospec-typed change whose schema the binary cannot load is refused in text too` passes on macOS (63 expect() calls, all four breakages): the binary exits 1 for `ch1` in both modes and for the sweep; cospec's `--json` document equals the binary's respelled, text exits 1 with empty stdout and the binary's message on stderr, the sweep's `ch1` entry carries it as `error` while `other` renders `other (feat)`; with the schema reinstalled text mode answers `ch1` itself, exit 0; red before the fix (text exit 0 rendering `ch1 (chore)` with `gate: clear` and `Next: cospec instructions blocking-changes --change ch1`) +- [x] 17.2 @manual (agent) `apps/docs/reference/commands.md`, `docs/architecture.md`, `design.md`, `proposal.md` and the `change-progress-reporting` delta -> each says text-mode `status` asks the binary for a change whose schema it cannot load, no sentence claims text mode stays spawn-free for it, and both BREAKING lists name the new exit 1 -> observed: the `cospec status` row of `commands.md` names an unloadable schema (missing, unreadable, unparsable or invalid) beside an unreadable entry as the cases text mode asks OpenSpec about, with the removed-schema example; `architecture.md` and design.md's Goals carve the case out of "spawn-free"; design.md D4 gains the "A schema the binary cannot load" paragraph and the delta gains the requirement with its two scenarios; the `commands.md` BREAKING clause and the proposal's BREAKING list (and its Impact line on human `status` spawns) name the exit 1 for a cospec-typed change whose schema OpenSpec can't load. A sandbox probe with `openspec/schemas/` itself at mode 000 (the listing `loadSchema` makes fails with an errno) shows the delegated call answering it: `status --change ch1` and `--all`, text and `--json`, each exit 1 with the binary's `EACCES: permission denied, scandir '…/openspec/schemas'`, text and `--json` agreeing +- [x] 17.3 @equivalence (agent) a `chore` change `ch1` beside a `feat` change `other`, its `.openspec.yaml` setting in turn `created: notadate`, `skip_specs: "yes"`, `goal: ""`, `affected_areas: [1]`, `initiative: {store: s1}`, `initiative: {store: s1, id: i1, extra: x}` and `retire_capabilities: "no"`, its schema still loading: `status --change ch1` and `status --all`, text and `--json` -> every exit 1 as the binary's; `--json` the binary's `change_error` document respelled; text stderr `cospec status: ` per diagnostic (an `Invalid metadata` message) with nothing on stdout; the sweep's `ch1` entry carrying the message as `error`, text `ch1: ERROR — `, and `other` reported -> observed: cli-surface.test.ts `17.3 a cospec-typed change whose metadata the binary refuses is refused in text too` passes on macOS (107 expect() calls, all seven shapes): the binary exits 1 for `ch1` in both modes and for the sweep with `Invalid metadata`; cospec's `--json` document equals the binary's respelled, text exits 1 with empty stdout and the binary's message on stderr, the sweep's `ch1` entry carries it as `error` while `other` renders `other (feat)`; with the valid metadata restored text mode answers `ch1` itself, exit 0; red before the fix (text exit 0 rendering `ch1 (chore)` with `gate: clear` and `Next: cospec instructions blocking-changes --change ch1` for `created: notadate`) +- [x] 17.4 @manual (agent) `apps/docs/reference/commands.md`, `docs/architecture.md`, `design.md`, `proposal.md` and the `change-progress-reporting` delta -> each says text-mode `status` asks the binary for a change whose `.openspec.yaml` it refuses, no sentence claims text mode stays spawn-free for it, and both BREAKING lists name the new exit 1 -> observed: the `cospec status` row of `commands.md` names a refused `.openspec.yaml` (unreadable, not YAML, an unlisted schema, or each `ChangeMetadataSchema` failure) beside an unreadable entry and an unloadable schema as the cases text mode asks OpenSpec about, with the malformed-`created` example and its `Invalid metadata` message, and its BREAKING clause names the exit 1; `architecture.md`, design.md's Goals and the proposal's Impact line carve the case out of "spawn-free"; design.md D4 gains the "Metadata the binary refuses" paragraph, the delta gains the requirement with its two scenarios, and the proposal's BREAKING list gains the item +- [x] 17.5 @equivalence (agent) a `chore` change `ch1`, the root's `openspec/schemas` directory itself replaced by a regular file (so `changeMetadataRefused`'s own `listSchemas` call fails with `ENOTDIR`, not the per-schema listing `loadSchema` already guards): `status --change ch1` and `status --all`, text and `--json` -> every exit 1 as the binary's; `--json` the binary's `change_error` document respelled; text stderr `cospec status: ` per diagnostic with nothing on stdout; the sweep's `ch1` entry carrying the message as `error`, text `ch1: ERROR — ` -> observed: cli-surface.test.ts `17.5 a project schemas directory the binary cannot read is refused in text too` passes on macOS (13 expect() calls): the binary exits 1 for `ch1` in both modes and for the sweep; cospec's `--json` document equals the binary's respelled, text exits 1 with empty stdout and the binary's message on stderr, the sweep's `ch1` entry carries it as `error`; red before the fix (`binaryDecides`'s unguarded `changeMetadataRefused` call let the `ENOTDIR` escape to a bare top-level `cospec: ENOTDIR: …` line, exit 1, in both the `--change` lookup and the `--all` sweep's text mode, while `--json` already answered it correctly via the per-change document) diff --git a/openspec/specs/change-progress-reporting/spec.md b/openspec/specs/change-progress-reporting/spec.md index f1dd1a0e..0873dd76 100644 --- a/openspec/specs/change-progress-reporting/spec.md +++ b/openspec/specs/change-progress-reporting/spec.md @@ -13,12 +13,17 @@ re-emitted from the wrapped `openspec instructions apply --json` payload. `cospec status` SHALL accept `--all`, mutually exclusive with `--change`, which computes the existing per-change status for every active change under the -resolved root, ordered by change id. A failure computing one change's status -SHALL NOT abort the sweep: that change SHALL appear as a failure entry carrying -its id and the error, and the command SHALL exit 1 while still emitting the -complete envelope. `--all --json` SHALL emit `{ changes: [...], root }`; text -mode SHALL render one blank-line-separated block per change. The single-change -output shape, both text and `--json`, SHALL be unchanged. +resolved root, ordered by change id. Active changes SHALL be the directories +under `openspec/changes/` other than `archive` and dot-directories, as the +wrapped binary enumerates them. A failure computing one change's status, a +namespace folder included, SHALL NOT abort the sweep: that change SHALL appear +as a failure entry carrying its id and the error, and the command SHALL exit 1 +while still emitting the complete envelope. `--all --json` SHALL emit +`{ changes: [...], root }`, where `root` is the binary's `{path, source}` +object; text mode SHALL render one blank-line-separated block per change. The +single-change output SHALL keep every key and line it had, gaining only what +this change adds: `next` and the `Next:` line, and under `--json` the binary's +keys. #### Scenario: Sweep lists every active change in id order @@ -40,8 +45,8 @@ output shape, both text and `--json`, SHALL be unchanged. #### Scenario: Single-change output is unchanged - **WHEN** `cospec status --change --json` runs -- **THEN** the emitted document is byte-identical to the shape emitted before - this change +- **THEN** every key the document carried before this change is present with the + same value, beside the added `next` and the binary's keys ### Requirement: Task accounting agrees with the wrapped payload @@ -194,3 +199,182 @@ through delegation, and cospec SHALL NOT run these rules there. task id - **THEN** the report carries the wrapped binary's two WARNINGs and no `tasks/id-mismatch` or `tasks/id-duplicate` + +### Requirement: Status names the next step on every entry + +Every status entry that has a status SHALL carry `next`, and the human output +SHALL print it as a `Next: ` line. Both SHALL come from one function +over the entry's artifact states in the schema's build order. An artifact is +ready when it isn't done and every artifact it requires is done, and an artifact +that `skip_specs` skips counts as done. The next step SHALL be the first ready +artifact the change requires to apply, else the first ready artifact of any +kind, each spelled `cospec instructions --change `. Once every +artifact the change requires is done, it SHALL be `cospec apply `. When +there is none of these, `next` SHALL be absent and no line printed. For a cospec +type the artifact states SHALL be cospec's own matrix, so no wrapped call is +needed in text mode. For another schema they SHALL be the delegated document's +`artifacts[].status` and `applyRequires`. The empty-change entry's `next` SHALL +keep its current spelling. Under `--json`, `nextSteps` SHALL be the binary's own +value from the delegated document, each command spelled through cospec's remedy +allowlist. + +#### Scenario: A mid-build change prints a Next line + +- **WHEN** `cospec status --change alpha` runs on a `feat` change with only + `proposal.md` +- **THEN** the output ends with + `Next: cospec instructions blocking-changes --change alpha` + +#### Scenario: nextSteps is the binary's, spelled cospec + +- **WHEN** `cospec status --change alpha --json` and + `openspec status --change alpha --json` run on the same change +- **THEN** cospec's `nextSteps` equals the binary's with `openspec instructions` + spelled `cospec instructions`, and `next` names the same artifact + +#### Scenario: Required artifacts done points at the gate + +- **WHEN** every artifact a `feat` change requires exists and the optional + `design.md` doesn't +- **THEN** `next` is `cospec apply `, and `nextSteps` is still the binary's + own sentence for `design`, spelled `cospec` + +### Requirement: Status --schema overrides the schema as the binary does + +`cospec status` SHALL accept `--schema ` with the binary's meaning, a +schema override for every change it reports and not a filter. Before enumerating +changes under `--all`, and before reporting the change named by `--change`, an +unknown name SHALL be refused with the binary's +`Schema '' not found. Available schemas:` message, exit 1, as a +`change_error` document under `--json` (with the `{changes: [], root: null}` +payload under `--all`). With neither `--all` nor `--change` the name SHALL NOT +be checked, as the binary doesn't check it. An override naming a cospec type +SHALL render cospec's matrix for that type. Any other override SHALL render as a +schema cospec doesn't type. + +#### Scenario: An override re-renders every change + +- **WHEN** `cospec status --all --schema fix --json` runs +- **THEN** every entry is computed as a `fix` change + +#### Scenario: An unknown override is refused + +- **WHEN** `cospec status --change alpha --schema nope --json` runs +- **THEN** stdout is one `change_error` document carrying the binary's message + and the command exits 1 + +### Requirement: Status answers a schema cospec does not type from the binary + +For a change whose schema is not a cospec type (a forked or project schema, +`spec-driven`, or a name that resolves nowhere), `cospec status` SHALL call the +wrapped `openspec status --change --json`, or `--all --json` for a sweep, +once. In text mode it SHALL render that document the way the binary renders it, +with its `Next:` line spelled through cospec. Under `--json` it SHALL merge the +document into cospec's `{change, type, legacy: true}` entry. The command SHALL +exit with the binary's outcome: an unknown schema exits 1 in both modes. A +change directory without `.openspec.yaml` SHALL take its schema as the binary +does, from the root's `config.yaml` `schema:` and else `spec-driven`, at +`schemaVersion` 1. No output SHALL name a bare `openspec` command. + +#### Scenario: A forked schema gets real status + +- **WHEN** `cospec status --change legacy-one` runs on a change whose schema is + a project fork +- **THEN** the output lists the fork's artifacts with their state and a + `Next: cospec instructions …` line, and names no bare `openspec` command + +#### Scenario: An unknown schema fails under --json + +- **WHEN** `cospec status --change ghost --json` runs on a change whose schema + resolves nowhere +- **THEN** the document carries the binary's `Unknown schema` diagnostic and the + command exits 1 + +#### Scenario: A hand-made change is typed by config.yaml + +- **WHEN** `cospec status --change bare-dir --json` runs on a directory holding + only `proposal.md` in a root whose `config.yaml` says `schema: feat` +- **THEN** the entry is a `feat` change graded at `schemaVersion` 1, and its + `schemaName` is `feat` as the binary reports + +### Requirement: Status answers an unreadable tasks file as the binary does + +When cospec's own read of a cospec-typed change's `tasks.md` fails, +`cospec status` SHALL ask the binary whether the change can be reported, through +its one delegated `openspec status --json` call, made in text mode only then, +for metadata the binary refuses, or for a schema the binary cannot load. Where +the binary refuses the change (its runtime's `realpath` refuses the file), the +binary's failure SHALL be the answer: its `change_error` document under +`--json`, its message on stderr in text, and under `--all` a failure entry +carrying its message, exit 1. Where the binary reports the change, the file +SHALL count as no tasks, as the binary counts it, with a warning naming the file +on stderr, or in `warnings` as `{code: "tasks_unreadable", message}` under +`--json`. + +#### Scenario: The binary refuses the change + +- **WHEN** `cospec status --change beta --json` runs with `beta`'s `tasks.md` at + mode 000 where the binary's `realpath` refuses the file (Bun on macOS) +- **THEN** stdout is the binary's `change_error` document and the command exits + 1 + +#### Scenario: The binary reports the change + +- **WHEN** `cospec status --change beta --json` runs with `beta`'s `tasks.md` at + mode 000 where the binary reports the change (Linux) +- **THEN** `tasks` counts 0 of 0, `warnings` names the file with + `tasks_unreadable`, and the command exits 0 + +### Requirement: Status answers a change whose schema the binary cannot load as the binary does + +When the binary cannot load a cospec-typed change's schema (missing from every +tier, unreadable, unparsable or invalid), `cospec status` SHALL ask the binary +whether the change can be reported, through its one delegated +`openspec status --json` call, in text mode as under `--json`, for `--change` +and for the `--all` sweep. The binary's refusal SHALL be the answer: its +`change_error` document under `--json`, its message on stderr in text with +nothing on stdout, and under `--all` a failure entry carrying its message, with +exit code 1. Every other change in the sweep SHALL be reported as it is alone. + +#### Scenario: A removed project schema refuses the change in text + +- **WHEN** `cospec status --change ch1` runs in text mode on a `chore` change + whose root's `openspec/schemas/chore/` has been removed +- **THEN** stderr is `cospec status: ` followed by the binary's `Unknown schema` + message, stdout is empty, and the command exits 1 + +#### Scenario: The sweep carries the refusal as a failure entry + +- **WHEN** `cospec status --all` runs in text mode on the same root beside a + `feat` change `other` +- **THEN** `ch1`'s block is `ch1: ERROR — `, `other` is reported, and + the command exits 1 + +### Requirement: Status answers a change whose metadata the binary refuses as the binary does + +When the binary's `readChangeMetadata` refuses a cospec-typed change's +`.openspec.yaml` (unreadable, not YAML, naming a schema the binary does not +list, or failing its `ChangeMetadataSchema`: a `created` not `YYYY-MM-DD`, an +empty `goal`, a non-boolean `skip_specs` or `retire_capabilities`, an +`affected_areas` not a list of non-empty strings, an `initiative` not exactly a +kebab-case `{store, id}`), `cospec status` SHALL ask the binary whether the +change can be reported, through its one delegated `openspec status --json` call, +in text mode as under `--json`, for `--change` and for the `--all` sweep. The +binary's refusal SHALL be the answer: its `change_error` document under +`--json`, its message on stderr in text with nothing on stdout, and under +`--all` a failure entry carrying its message, with exit code 1. Every other +change in the sweep SHALL be reported as it is alone. + +#### Scenario: A malformed created date refuses the change in text + +- **WHEN** `cospec status --change ch1` runs in text mode on a `chore` change + whose `.openspec.yaml` sets `created: notadate` +- **THEN** stderr is `cospec status: ` followed by the binary's + `Invalid metadata` message, stdout is empty, and the command exits 1 + +#### Scenario: The sweep carries the metadata refusal as a failure entry + +- **WHEN** `cospec status --all` runs in text mode on the same root beside a + `feat` change `other` +- **THEN** `ch1`'s block is `ch1: ERROR — `, `other` is reported, and + the command exits 1 diff --git a/openspec/specs/cospec-shell-completion/spec.md b/openspec/specs/cospec-shell-completion/spec.md index 3f439ee5..d9ca6fec 100644 --- a/openspec/specs/cospec-shell-completion/spec.md +++ b/openspec/specs/cospec-shell-completion/spec.md @@ -84,13 +84,18 @@ envelope, because a shell script is not a JSON document and emitting it under ### Requirement: The dynamic completion source fails silently -`cospec __complete ` SHALL be a hidden command emitting one -tab-separated id and description pair per line on stdout, and SHALL exit 1 with -**no output on stdout or stderr** on any failure — an unresolvable root, a -wrapped-call error, an unknown source name — because a Tab press must never be -corrupted by an error message. `changes` and `specs` SHALL be sourced from the -existing typed wrapped list calls; `types` SHALL be sourced from `COSPEC_TYPES` -with no wrapped spawn at all. +`cospec __complete ` SHALL be a +hidden command emitting one tab-separated id and description pair per line on +stdout, and SHALL exit 1 with **no output on stdout or stderr** on any failure — +an unresolvable root, a wrapped-call error, an unknown source name — because a +Tab press must never be corrupted by an error message. The source name SHALL be +matched case-insensitively, as the wrapped binary matches it. `changes` and +`specs` SHALL be sourced from the existing typed wrapped list calls; `types` +SHALL be sourced from `COSPEC_TYPES` with no wrapped spawn at all; `schemas` +SHALL be sourced from `openspec schemas --json`, one line per schema name +described by its `description`; `archived-changes` SHALL list the non-dot +directories under the resolved root's `openspec/changes/archive/`, sorted, each +described `archived change`, with no wrapped spawn. #### Scenario: Change ids complete inside a repo @@ -110,3 +115,29 @@ with no wrapped spawn at all. root, or `cospec __complete nonsense` is invoked - **THEN** the command exits 1 having written nothing to stdout and nothing to stderr + +#### Scenario: Schemas complete from the wrapped listing + +- **WHEN** `cospec __complete schemas` runs in a root with a project fork +- **THEN** stdout lists the same schema names, in the same order, as + `openspec __complete schemas`, the fork included + +#### Scenario: Archived changes complete from the archive + +- **WHEN** `cospec __complete ARCHIVED-CHANGES` runs in a root with two archived + changes +- **THEN** stdout lists both directory names, as + `openspec __complete archived-changes` does + +### Requirement: Generated scripts complete schema names + +The bash, zsh and fish scripts `cospec completion` generates SHALL complete the +value of every `--schema` option a table row declares, and the first positional +of `schema which`, `schema validate` and `schema fork`, from +`cospec __complete schemas`, the positions where the wrapped binary's scripts +complete schema names. No generated script SHALL call the `openspec` binary. + +#### Scenario: A --schema value completes + +- **WHEN** the generated zsh script completes `cospec status --schema ` +- **THEN** it calls `cospec __complete schemas` diff --git a/openspec/specs/json-document-parity/spec.md b/openspec/specs/json-document-parity/spec.md new file mode 100644 index 00000000..2e59704c --- /dev/null +++ b/openspec/specs/json-document-parity/spec.md @@ -0,0 +1,125 @@ +# json-document-parity Specification + +## Purpose + +Keeps cospec's `--json` documents a strict superset of the wrapped OpenSpec +binary's own: `list`, `status` (single-change and `--all`) and `validate` (a +single item, a bulk scope, and `--report findings`) each merge the binary's own +document into cospec's by matching array entries on their identity, so every +upstream key reaches the document with the binary's value and no cospec key or +value is lost. A small named-collision list lets cospec's value win where the +two meanings genuinely differ (`version`, validate's `items[].type`); every +other key path is additive. The contract key oracle +(`test/contract/support/key-oracle.ts`) is the gate: it runs cospec and the +pinned binary on the same fixture and fails on any missing upstream key, any +differently-reported upstream value, or any unnamed collision. + +## Requirements + +### Requirement: Upstream keys are added without changing cospec's + +For `cospec list`, `cospec list --specs`, `cospec status --change`, +`cospec status --all`, `cospec validate` (a single item, a bulk scope, and +`--report findings`) under `--json`, cospec SHALL emit every key the wrapped +binary's own document for the same invocation carries, with the binary's value, +matching array entries by their identity (`name`, `changeName`, artifact `id`, +item `id` plus kind). It SHALL keep every key it emitted before, with its own +value. It SHALL keep `version: 1` on every envelope that carries one, and it +SHALL NOT take the binary's `version`. Remedy commands inside upstream values +(`nextSteps`) SHALL be spelled `cospec`. + +Where a key both tools emit would need two different values, cospec's value +SHALL win only for a key on the named collision list, which this change defines +as `version` and validate's `items[].type` (cospec's is the change's schema; the +binary's `change|spec` is cospec's `kind`). The `root` of the `status` documents +SHALL take the binary's `{path, source, store_id?}` object. + +#### Scenario: A mid-build status document carries both shapes + +- **WHEN** `cospec status --change alpha --json` runs on a `feat` change with + only `proposal.md` +- **THEN** the document carries cospec's `change`, `type`, `state`, `gate`, + `tasks`, `archiveReady`, `verification` and `next`, and the binary's + `changeName`, `schemaName`, `planningHome`, `changeRoot`, `artifactPaths`, + `isPlanningComplete`, `isComplete`, `applyRequires`, `nextSteps`, + `actionContext` and `root`, and each `artifacts[]` entry carries cospec's + `done`, `required`, `ready` and the binary's `outputPath`, `status`, + `requires` + +#### Scenario: Validate keeps its format marker and its type + +- **WHEN** `cospec validate --all --json` runs +- **THEN** `version` is `1`, each change item's `type` is its schema, each item + also carries `kind` and `durationMs`, and the document carries `root` and + `summary.totals`/`summary.byType` beside cospec's `errors`, `warnings` and + `byRule` + +### Requirement: A key oracle proves the additivity against the binary + +A contract test SHALL run each command listed in the requirement above, and +`cospec apply --json` beside +`openspec instructions apply --change --json`, against the pinned +binary on the same fixture and environment. It SHALL fail when an upstream key +path is missing from cospec's document; when a key both tools emit carries +different values and is not on the named collision list; and when a key from +cospec's own pre-existing shape is missing or changed. Timing values +(`durationMs`, `lastModified`) SHALL be compared by presence and type. +Validation verdicts (`valid`, `issues`, the summary counts) SHALL be compared by +presence and type, since each lane keeps its own findings. A key whose value is +cospec's own pre-existing one SHALL be compared by presence and type too: the +in-progress status entry's `artifacts: []`, into which the binary's artifacts +are never appended. The oracle SHALL itself be tested to fail on a synthetic +collision. + +#### Scenario: The oracle passes for every covered command + +- **WHEN** the contract suite runs the key oracle +- **THEN** every row passes and its fixture exercises at least one entry of + every array it compares + +#### Scenario: The oracle fails on an unlisted collision + +- **WHEN** the oracle is handed a cospec document whose `root` is a string where + the binary's is an object +- **THEN** it reports the collision and fails + +### Requirement: Every --json failure is one document + +Under `--json` a command SHALL answer every failure with exactly one JSON +document on stdout and nothing else there: + +- Every root-selection failure SHALL carry the binary's per-command payload: + `{changes: [], root: null}` for `list` and `status --all`, and + `{specs: [], root: null}` for `list --specs`. A selection diagnostic (such as + an unknown store) SHALL keep its own code. +- A raw resolver failure, one the binary rethrows rather than turning into a + selection diagnostic (such as an unreadable store registry), SHALL carry the + binary's per-command code: `list_error` for `list`, `change_error` for + `status` and `apply`, and `validate_error` for `validate`. +- Every early exit of `cospec apply` (no `openspec/` root, an unknown change, a + failed wrapped call) SHALL be + `{status: [{severity: "error", code, message, fix?}]}` with exit 1. +- A list-time read failure SHALL be the binary's own answer when the binary + reads that path, and otherwise a per-row `error`. + +#### Scenario: Apply names an unknown change in one document + +- **WHEN** `cospec apply nope --json` runs +- **THEN** stdout is one + `{status: [{severity: "error", code: "change_error", message}]}` document + naming `nope`, stderr carries nothing, and the exit code is 1 + +#### Scenario: A raw registry failure carries the command's code + +- **WHEN** `cospec list --json --store s1` runs with an unreadable store + registry +- **THEN** stdout is + `{changes: [], root: null, status: [{…, code: "list_error", message}]}` and + the exit code is 1, as the binary answers + +#### Scenario: An unknown store carries the command's payload + +- **WHEN** `cospec list --json --store nope` runs with stores registered +- **THEN** stdout is + `{changes: [], root: null, status: [{…, code: "unknown_store", message, target, fix}]}` + and the exit code is 1, as the binary answers diff --git a/openspec/specs/nested-change-detection/spec.md b/openspec/specs/nested-change-detection/spec.md new file mode 100644 index 00000000..38e2fc45 --- /dev/null +++ b/openspec/specs/nested-change-detection/spec.md @@ -0,0 +1,109 @@ +# nested-change-detection Specification + +## Purpose + +Defines how cospec recognizes a namespace folder under `openspec/changes/` — a +directory that wraps one or more real changes rather than being one itself — the +same way the wrapped OpenSpec binary does, so `list`, `status` and `validate` +(and, through `validateChange`, `apply` and `archive`) report it as a namespace +folder instead of treating it as an empty or malformed change. Detection reads +only what the binary reads (change-root markers, `specs/` contents, existing +artifact outputs, the directory's own files, and a bounded search of its +subdirectories) and degrades an unreadable directory to "holds nothing" so the +detector itself never fails the command around it. + +## Requirements + +### Requirement: A namespace folder is detected as the binary detects it + +cospec SHALL decide whether a directory directly under `openspec/changes/` is a +namespace folder by the wrapped binary's rule. The directory SHALL be a +namespace folder only when all of these hold: it looks like no change itself (no +change-root marker file `.openspec.yaml`, `proposal.md`, `tasks.md` or +`design.md`; no file anywhere under its `specs/` directory, dot-entries skipped; +and no existing output of any artifact of the schema the directory resolves to); +it holds no file of its own other than dot-entries; and at least one +subdirectory, searched to at most three levels below it, looks like a change. +The search SHALL not descend into a subdirectory that looks like a change, and +SHALL skip dot-directories. A directory named `archive`, or one whose name +starts with a dot, SHALL never be a namespace folder. A directory that cannot be +read SHALL count as holding nothing, so the detector never fails the command +around it. The nested ids SHALL be reported sorted, as +`/[/…]`. + +#### Scenario: A folder wrapping a change is a namespace folder + +- **WHEN** `openspec/changes/mobile/` holds only `refresh-token/` with a + `.openspec.yaml` +- **THEN** `mobile` is a namespace folder wrapping `mobile/refresh-token` + +#### Scenario: A change with a root marker is never a namespace folder + +- **WHEN** `openspec/changes/alpha/` holds `proposal.md` and a subdirectory that + itself holds a `.openspec.yaml` +- **THEN** `alpha` is reported as a change, not a namespace folder + +#### Scenario: A file of its own keeps a directory a change + +- **WHEN** a directory holds a `README.md` and a subdirectory that looks like a + change +- **THEN** it is not a namespace folder + +#### Scenario: Nesting deeper than three levels is not searched + +- **WHEN** the only change-looking directory sits four levels below the folder +- **THEN** the folder is not a namespace folder + +#### Scenario: The detector agrees with the binary + +- **WHEN** the contract suite lists a fixture covering each signal (root marker, + a delta file only under `specs/`, a schema output only, a file of its own, + depths one to four, a dot-directory, `archive`) with `cospec list --json` and + `openspec list --json` +- **THEN** every row's `nested` value is the same in both documents + +### Requirement: Status, list and validate report a namespace folder as one + +A namespace folder SHALL be reported with the binary's explanation, verbatim: +`"" is not a change: it is a folder wrapping openspec/changes//, … . … Rename each nested change to a flat name (for example "").` + +- `cospec status --change ` SHALL refuse it, on stderr in text mode and + as a `{status: [{severity: "error", code: "change_error", message}]}` document + under `--json`, and exit 1. +- `cospec status --all` SHALL report the folder as a failure entry carrying the + explanation, keep every other change's entry, and exit 1. +- `cospec list` SHALL keep the folder's row, show `not a change` in place of its + task count, set the row's `state` to `not-a-change` and `nested` to the nested + ids, print `Warning: ` after the table, and add a `warnings` + entry `{code: "nested_change_directory", name, nested, message}` under + `--json`. +- `cospec validate` SHALL report the folder, singly or in a bulk scope, as + exactly one `meta/nested-change` ERROR carrying the explanation, run no other + rule on it and delegate nothing for it. + +`cospec archive` is not covered by this requirement. + +#### Scenario: Status refuses a namespace folder + +- **WHEN** `cospec status --change mobile --json` runs on a namespace folder +- **THEN** stdout is one document whose `status[0]` has code `change_error` and + the binary's explanation, and the command exits 1 + +#### Scenario: The sweep carries the folder as a failure + +- **WHEN** `cospec status --all --json` runs in a root with a namespace folder + and two changes +- **THEN** both changes have full entries, the folder's entry carries the + explanation, and the command exits 1 + +#### Scenario: List marks the folder + +- **WHEN** `cospec list` runs in that root +- **THEN** the folder's row reads `not a change` and the binary's warning + follows the table + +#### Scenario: Validate reports only the nesting + +- **WHEN** `cospec validate mobile --json` runs +- **THEN** the item carries exactly one issue, `meta/nested-change` at ERROR, + and no `meta/openspec-yaml` issue diff --git a/openspec/specs/openspec-list-validate-extensions/spec.md b/openspec/specs/openspec-list-validate-extensions/spec.md index e2eeba29..81f96b4a 100644 --- a/openspec/specs/openspec-list-validate-extensions/spec.md +++ b/openspec/specs/openspec-list-validate-extensions/spec.md @@ -15,9 +15,12 @@ exits 1). ### Requirement: List --specs enumerates capability specs `cospec list --specs` SHALL delegate to `openspec list --specs --json` and -render the resulting capability specs as a typed table (or `--json` passthrough -of the parsed result), alongside the existing changes-only `cospec list` -behavior which SHALL be unchanged when `--specs` is absent. +render the resulting capability specs as a typed table (or, under `--json`, +cospec's `{version: 1, specs}` document carrying the delegated `root`), +alongside the changes `cospec list` lists when `--specs` is absent. Without +`--specs`, cospec's table columns and `--json` row keys SHALL be unchanged, +gaining only the rows' order under `--sort`, the binary's keys and the +namespace-folder marking. #### Scenario: List --specs renders capability specs @@ -29,17 +32,20 @@ behavior which SHALL be unchanged when `--specs` is absent. #### Scenario: Default list behavior is unchanged - **WHEN** `cospec list` runs without `--specs` -- **THEN** the command's changes-only table output is unchanged from before this - change +- **THEN** each row keeps its type, gate, task and archive-ready columns, and + each `--json` row keeps `change`, `type`, `state`, `gate`, `gateState`, + `tasks` and `archiveReady` with their values ### Requirement: Validate bulk and standalone-spec modes `cospec validate` SHALL accept `--all`, `--specs`, and `--changes` flags that delegate the bulk and standalone-spec validation paths to `openspec validate`, merging delegated spec issues into cospec's existing issue-reporting shape via -the existing delegated-issue mapping. Single-change validation (no bulk flag) -SHALL continue to run cospec's own rules unchanged. A bulk run SHALL exit 1 if -the delegated call reports any failed item. +the existing delegated-issue mapping. Any of those flags SHALL select its bulk +scope even when an item name is also given, and the name SHALL then be ignored, +as the wrapped binary ignores it. Single-change validation (an item name and no +bulk flag) SHALL continue to run cospec's own rules. A bulk run SHALL exit 1 if +any item fails. #### Scenario: Validate --specs delegates and surfaces spec issues @@ -56,9 +62,15 @@ the delegated call reports any failed item. #### Scenario: Single-change validation is unaffected -- **WHEN** `cospec validate ` runs without any bulk flag -- **THEN** the command's existing single-change rule-and-delegation behavior is - unchanged +- **WHEN** `cospec validate ` runs without any bulk flag on a name + that is only a change +- **THEN** the command's single-change rule-and-delegation behavior is as before + +#### Scenario: A bulk flag beside a name runs the bulk scope + +- **WHEN** `cospec validate alpha --all` runs +- **THEN** every change and spec is validated, as + `openspec validate alpha --all` does ### Requirement: Validate --archived delegates archived-change validation @@ -125,3 +137,212 @@ and cospec SHALL report no further issue beyond its own classification INFO. - **WHEN** a `feat` change's ADDED requirement body lacks SHALL/MUST - **THEN** cospec reports `deltas/requirement-shape` at ERROR, not the binary's WARNING + +### Requirement: List sorts as the binary sorts + +`cospec list` SHALL accept `--sort `. `name` SHALL order rows by change +name; any other value, and no flag, SHALL order them most recently modified +first, by the latest modification time of any file in the change, as the wrapped +binary orders them. The order, the rows' `name`, `completedTasks`, `totalTasks`, +`lastModified`, `status` and `nested` keys, and the document's `root` and +`warnings` SHALL come from one delegated `openspec list --json` call, merged +into cospec's rows by name. cospec's `--blocked` filter SHALL apply after the +merge. + +#### Scenario: Default order is most recent first + +- **WHEN** `cospec list --json` runs on changes whose files were last touched in + the order `alpha`, `beta`, `gamma` +- **THEN** the rows are ordered `gamma`, `beta`, `alpha`, as in + `openspec list --json` + +#### Scenario: Name order on request + +- **WHEN** `cospec list --sort name` runs +- **THEN** the rows are ordered by name + +### Requirement: List answers read failures as the binary does + +`cospec list` SHALL NOT crash on a read failure. An unreadable +`openspec/changes/archive/`, which the binary never reads when listing, SHALL +leave the listing as the binary's. cospec's gate column SHALL then be computed +from an empty archive index, and a warning naming the directory SHALL be printed +on stderr, or added to `warnings` as `{code: "archive_unreadable", message}` +under `--json`. A read failure the binary itself refuses (an unreadable change +directory, or a `tasks.md` its runtime's `realpath` refuses) SHALL be answered +with the binary's refusal: its `list_error` document under `--json`, its message +on stderr otherwise, and exit 1. An unreadable `tasks.md` the binary lists past +SHALL count as no tasks, as the binary counts it, with a warning naming the file +on stderr, or in `warnings` as `{code: "tasks_unreadable", message}` under +`--json`. A read failure only cospec's columns reach (an unreadable +`blocking-changes.md`) SHALL become that row's `error`, with the other rows +listed, and exit 1. + +#### Scenario: An unreadable archive still lists + +- **WHEN** `cospec list --json` runs with `openspec/changes/archive/` at mode + 000 +- **THEN** stdout is one document listing every change, `warnings` names the + archive, and the command exits 0, as `openspec list --json` lists them + +#### Scenario: An unreadable tasks file is the binary's list_error + +- **WHEN** `cospec list --json` runs with one change's `tasks.md` at mode 000 + where the binary's `realpath` refuses the file (Bun on macOS) +- **THEN** stdout is one + `{changes: [], root: null, status: [{…, code: "list_error"}]}` document and + the command exits 1 + +#### Scenario: An unreadable tasks file the binary lists past + +- **WHEN** `cospec list --json` runs with one change's `tasks.md` at mode 000 + where the binary lists the change (Linux) +- **THEN** the change's row counts 0 of 0 tasks with no `error`, `warnings` + names the file with `tasks_unreadable`, and the command exits 0, as + `openspec list --json` does + +### Requirement: Validate resolves one item as the binary does + +`cospec validate ` SHALL resolve the name as the wrapped binary does. + +- `--type change|spec`, matched case-insensitively, SHALL force the kind. Any + other value SHALL be ignored. +- Without a forced kind, a name that is both an active change and a living spec + SHALL be refused with + `Ambiguous item '' matches both a change and a spec.` and the fix + `Pass --type change|spec.`, exit 1. Under `--json` this is one + `ambiguous_item` document. +- A name that is neither SHALL be refused with + `Unknown item ''. Did you mean: ?`, by edit + distance over the change ids then the spec ids, duplicates kept, or + `Unknown item ''.` when there is no candidate, exit 1. Under `--json` + this is one `unknown_item` document. +- A forced kind SHALL first reject a name the binary rejects (empty, `.`/`..`, + or containing a path separator, checked per segment for a spec) with the + binary's `invalid_item` message. A forced kind naming nothing on disk SHALL be + reported as that item with one `meta/item-missing` ERROR. +- The noun-form alternative in the binary's text refusal SHALL be left out, as + cospec has no noun-form commands. + +#### Scenario: An ambiguous name is refused + +- **WHEN** `cospec validate gamma --json` runs where `gamma` is a change and a + spec +- **THEN** stdout is one `ambiguous_item` document with the binary's message and + fix, and the command exits 1 + +#### Scenario: An unknown name gets the binary's suggestions + +- **WHEN** `cospec validate gamm` runs +- **THEN** stderr carries `Unknown item 'gamm'. Did you mean: …?` naming the + same ids, in the same order, as `openspec validate gamm`, and the command + exits 1 + +#### Scenario: --type settles the ambiguity + +- **WHEN** `cospec validate gamma --type spec` runs +- **THEN** only the living spec `gamma` is validated + +### Requirement: Validate --report selects the full or the findings report + +`cospec validate` SHALL accept `--report `. Before resolving the +root it SHALL refuse, exit 1, with the fix +`Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes.`: +an unknown value (`Unknown validation report ''.`), an item name +(`A validation report cannot be combined with an item name.`), `--archived` with +a bulk flag (`A validation report cannot combine archived and active scopes.`), +and no bulk scope (`A validation report requires an explicit bulk scope.`). +These SHALL go to stderr as `Error: ` and `Fix: `, or under +`--json` as one `invalid_validation_report_request` document. `full` SHALL be +the report cospec prints today. `findings` SHALL keep only the items with at +least one issue, under the binary's `report` object +(`kind: "validation-findings"`, `scope`, `returnedItems`, `totalItems`), +`itemFindings`, `summary` and `root`, inside cospec's `version: 1` envelope. Its +exit code SHALL always be the one `full` would give. + +#### Scenario: A report without a bulk scope is refused + +- **WHEN** `cospec validate --report findings --json` runs +- **THEN** stdout is one `invalid_validation_report_request` document naming the + missing bulk scope, and the command exits 1 without resolving a root + +#### Scenario: Findings keep full's exit code + +- **WHEN** `cospec validate --all --report findings` and + `cospec validate --all --report full` run on a root with one failing change +- **THEN** both exit 1, and the findings report lists only items with issues + +### Requirement: Validate --concurrency bounds the change validations + +`cospec validate` SHALL run at most N change validations at once in a bulk +scope. N SHALL be `--concurrency ` when it parses as a positive integer, else +`OPENSPEC_CONCURRENCY` when that does, else 6. A value that is not a positive +integer SHALL be ignored, not refused, as the binary ignores it. The report's +item order SHALL NOT depend on N. + +#### Scenario: The bound holds + +- **WHEN** a bulk validation of eight changes runs with `--concurrency 2` +- **THEN** no more than two change validations are ever in flight, and the + report equals the report of the same run with `--concurrency 8` + +#### Scenario: A bad value falls back + +- **WHEN** `cospec validate --all --concurrency abc` runs with + `OPENSPEC_CONCURRENCY=3` +- **THEN** the run is bounded at three and exits as an unbounded run would + +### Requirement: An unreadable artifact is a validation error + +`cospec validate` SHALL report a change artifact it cannot read (a proposal, +blockers, tasks, verification or design file, a delta or unread spec file, or +`.openspec.yaml`) as a `meta/unreadable-artifact` ERROR naming the file and its +error code, and SHALL run no other rule on that change and delegate nothing for +it. The command SHALL never throw on such a file. Under `--json` the report +SHALL still be one document. + +An unreadable `openspec/changes/archive/` is not a change artifact: the binary's +`validate` and `instructions apply` never read it, so `cospec validate` and +`cospec apply` SHALL answer as they do with it empty, carrying an +`{code: "archive_unreadable", message}` warning naming the directory under +`--json`, or printing it on stderr. `cospec archive` SHALL still refuse. + +#### Scenario: An unreadable archive leaves validate and apply answering + +- **WHEN** `cospec validate --all --json` and `cospec apply --json` run + with `openspec/changes/archive/` at mode 000 +- **THEN** each prints one document that, bar its one `archive_unreadable` + warning, equals its answer with the archive readable, and `validate --all` + exits as the binary does + +#### Scenario: An unreadable tasks file fails the change, not the command + +- **WHEN** `cospec validate --all --json` runs with one change's `tasks.md` at + mode 000 +- **THEN** that change carries one `meta/unreadable-artifact` ERROR naming + `tasks.md` and `EACCES`, every other item is reported, and the command exits 1 + +### Requirement: Validate relays are spelled through cospec + +Every issue message `cospec validate` relays from the wrapped binary, and each +message and fix of the failure document the binary answers `--archived` with, +SHALL have each allowlisted upstream remedy spelled through cospec, with every +other byte unchanged. That failure document SHALL be the answer: under `--json` +that one document with the binary's exit code, in text `cospec: ` on +stderr. Whether the binary is too old for `--archived` SHALL be read from its +version, never inferred from its answer. + +#### Scenario: An unreadable archive's --archived failure is the binary's + +- **WHEN** `cospec validate --archived --json` runs with + `openspec/changes/archive/` at mode 000 +- **THEN** it prints the binary's one `validate_error` document and exits 1, as + the binary does, and the text form prints `cospec: ` with no "needs + OpenSpec >=1.9.0" attribution + +#### Scenario: The no-deltas tip names cospec + +- **WHEN** a delegated issue carries the binary's + `Tip: run "openspec change show --json --deltas-only"` sentence +- **THEN** cospec's report carries that sentence in its cospec spelling and no + bare `openspec` command diff --git a/openspec/specs/schema-customization/spec.md b/openspec/specs/schema-customization/spec.md index 4271402d..76118a09 100644 --- a/openspec/specs/schema-customization/spec.md +++ b/openspec/specs/schema-customization/spec.md @@ -111,3 +111,26 @@ unknown-type hint, and the `doctor` legacy-schema remedy SHALL each say - **WHEN** `cospec doctor` reports a legacy-schema change - **THEN** its remedy text says `cospec schema fork` and does not say bare `openspec schema fork` or "edit the canon" + +### Requirement: Schema classification reads the binary's user schema directory + +When cospec classifies a change's `schema:` value, the user tier SHALL be the +directory the wrapped binary reads user schemas from: +`$XDG_DATA_HOME/openspec/schemas` when `XDG_DATA_HOME` is set and non-empty, +else `%LOCALAPPDATA%\openspec\schemas` on Windows (falling back to +`~/AppData/Local/openspec/schemas`), else `~/.local/share/openspec/schemas`. It +SHALL never read `~/.config/openspec/schemas`. cospec SHALL compute that +directory in one place, and every reader of the user tier SHALL use it. + +#### Scenario: A user-level fork is a legacy schema + +- **WHEN** a change names schema `house-style` that exists only under + `$XDG_DATA_HOME/openspec/schemas/house-style/schema.yaml` +- **THEN** cospec classifies it as a legacy schema from the user tier, as the + binary resolves it, and `cospec validate` delegates it rather than reporting + an unknown schema + +#### Scenario: The config directory is not a schema tier + +- **WHEN** the schema exists only under `~/.config/openspec/schemas/` +- **THEN** cospec classifies it as unknown, as the binary does diff --git a/openspec/specs/spec-parsing-and-discovery/spec.md b/openspec/specs/spec-parsing-and-discovery/spec.md index 4e58d29f..6f1d3ca9 100644 --- a/openspec/specs/spec-parsing-and-discovery/spec.md +++ b/openspec/specs/spec-parsing-and-discovery/spec.md @@ -782,3 +782,28 @@ and one that is only a comment has no keyword. its only SHALL sits in a fenced example - **THEN** `cospec validate` reports `deltas/requirement-shape` at ERROR, as the binary warns + +### Requirement: Delegated duplicate matching is linear and covers quoted headers + +Each `DUPLICATE_CLASSES` pattern SHALL match a delegated message in time linear +in the message's length, whatever text a spec author puts in a requirement +header. A pattern that reads a list of defect lines SHALL match the fixed head +once and then each line on its own, with no repeated group around a quantified +span. The `archive/target-invalid` pairing SHALL recognise a +structurally-invalid-target message whose quoted header text itself contains +`"`, so a header such as `### Requirement: Widget "quoted" name` is reported +once, by cospec's rule. + +#### Scenario: A quoted header is reported once + +- **WHEN** `cospec validate --json` runs on a change whose living spec + duplicates `### Requirement: Widget "quoted" name` +- **THEN** the report carries cospec's `archive/target-invalid` ERROR and not + the binary's structurally-invalid INFO for the same spec + +#### Scenario: An adversarial message is matched quickly + +- **WHEN** the dedupe runs on a structurally-invalid message of two hundred + quote-heavy defect lines followed by a line that doesn't match +- **THEN** it finishes within the unit test's bound, a bound the previous + pattern exceeds on the same input diff --git a/packages/bench/src/mechanical.ts b/packages/bench/src/mechanical.ts index 1818efd1..284e55d0 100644 --- a/packages/bench/src/mechanical.ts +++ b/packages/bench/src/mechanical.ts @@ -287,39 +287,47 @@ function typeRequiresSpecs(type: CospecType): boolean { interface CospecValidateJson { items?: { issues?: { level?: string; rule?: string }[] }[] summary?: { errors?: number; warnings?: number; byRule?: Record } + status?: { severity?: string }[] } /** * Parse a `cospec validate --json` stdout body (the frozen * `{items, summary:{errors,warnings,byRule}}` shape from * `apps/cli/src/core/report.ts`'s `toJson`) into rule-id counts — the raw data - * behind the `schemaConformance` metric. Tolerant: a non-JSON body (e.g. an - * openspec-arm tree with no cospec schema stamp) or a body missing - * `summary.byRule` resolves to null/derived-from-items rather than throwing. + * behind the `schemaConformance` metric. A body that is no report is null, never + * a clean pass: non-JSON (e.g. an openspec-arm tree with no cospec schema + * stamp), a value that is not an object, a `status[]` carrying an error (a + * refusal document, or a report cospec could not finish), and a document + * without `summary` counts or an `items` array. * Falls back to counting `items[].issues[].rule` when `byRule` is * absent/empty, so an older or hand-built report shape still yields counts. * Exported (pure, no I/O) so rule-id parsing is unit-testable against fixture * JSON without spawning the real CLI. */ export function parseSchemaConformanceJson(stdout: string): ConformanceCounts | null { + let value: unknown try { - const parsed = JSON.parse(stdout) as CospecValidateJson - const byRule: Record = { ...parsed.summary?.byRule } - if (Object.keys(byRule).length === 0 && Array.isArray(parsed.items)) { - for (const item of parsed.items) { - for (const issue of item.issues ?? []) { - if (issue.rule !== undefined) byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1 - } + value = JSON.parse(stdout) + } catch (error) { + if (error instanceof SyntaxError) return null + throw error + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null + const parsed = value as CospecValidateJson + if (Array.isArray(parsed.status) && parsed.status.some((d) => d?.severity === 'error')) + return null + const summary = parsed.summary + if (summary === undefined || summary === null || !Array.isArray(parsed.items)) return null + if (typeof summary.errors !== 'number' || typeof summary.warnings !== 'number') return null + const byRule: Record = { ...summary.byRule } + if (Object.keys(byRule).length === 0) { + for (const item of parsed.items) { + for (const issue of item.issues ?? []) { + if (issue.rule !== undefined) byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1 } } - return { - errors: parsed.summary?.errors ?? 0, - warnings: parsed.summary?.warnings ?? 0, - byRule, - } - } catch { - return null } + return { errors: summary.errors, warnings: summary.warnings, byRule } } /** diff --git a/packages/bench/test/unit/mechanical.test.ts b/packages/bench/test/unit/mechanical.test.ts index 4619d566..e3f4e932 100644 --- a/packages/bench/test/unit/mechanical.test.ts +++ b/packages/bench/test/unit/mechanical.test.ts @@ -86,12 +86,43 @@ describe('parseSchemaConformanceJson', () => { }) }) - test('defaults errors/warnings/byRule to zero/empty when summary is absent', () => { - expect(parseSchemaConformanceJson(JSON.stringify({ version: 1, items: [] }))).toEqual({ - errors: 0, - warnings: 0, - byRule: {}, + test('returns null when summary is absent: no report is not a clean pass', () => { + expect(parseSchemaConformanceJson(JSON.stringify({ version: 1, items: [] }))).toBeNull() + }) + + test('returns null when items is absent', () => { + const stdout = JSON.stringify({ version: 1, summary: { errors: 0, warnings: 0, byRule: {} } }) + expect(parseSchemaConformanceJson(stdout)).toBeNull() + }) + + test('returns null on a status[] refusal document (cospec validate --json no-root)', () => { + const stdout = JSON.stringify({ + status: [ + { + severity: 'error', + code: 'no_openspec_root', + message: 'No OpenSpec root found from the current directory.', + target: 'openspec.root', + fix: 'Run cospec init to create a root here.', + }, + ], }) + expect(parseSchemaConformanceJson(stdout)).toBeNull() + }) + + test('returns null on a status[] error beside a report', () => { + const stdout = JSON.stringify({ + version: 1, + items: [], + summary: { errors: 0, warnings: 0, byRule: {} }, + status: [{ severity: 'error', code: 'validate_error', message: 'boom' }], + }) + expect(parseSchemaConformanceJson(stdout)).toBeNull() + }) + + test('returns null on a JSON value that is not an object', () => { + expect(parseSchemaConformanceJson('null')).toBeNull() + expect(parseSchemaConformanceJson('[]')).toBeNull() }) test('returns null on non-JSON stdout (e.g. an openspec-arm body)', () => {