Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions apps/cli/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ import { join } from 'node:path'
import type { CommandContext } from '../cli.ts'
import { EXIT } from '../cli.ts'
import { parseBlockers } from '../core/blockers.ts'
import { loadSchema, schemaDir } from '../core/change-metadata.ts'
import {
changesDir,
defaultProjectSchema,
findNestedChangesIn,
isCospecType,
listChanges,
readOpenspecYaml,
} from '../core/change.ts'
import { flagValue, hasFlag } from '../core/command-table.ts'
import { artifactOutputExists } from '../core/glob.ts'
import {
OpenspecCallError,
passthroughOpenspec,
Expand Down Expand Up @@ -172,6 +175,45 @@ function nativeRow(
}
}

/**
* Whether `dir` holds a file its own declared schema's `generates` pattern
* names — the only signal cospec has for an artifact it doesn't recognize by
* name (mirrors `core/change.ts`'s `hasSchemaOutput`, scoped to the change's
* own resolved schema rather than the project's default, since a
* schema-bearing change always names its own). A schema name that resolves to
* no directory at all gives no signal, as the binary's own `list` never loads
* a schema either (its row is task-progress-only; `dist/core/list.js`) — but a
* schema that does resolve and then fails to read, parse or validate is a
* real defect, not an absence, so it is surfaced as a warning on `id`'s row
* rather than silently counted as no artifacts.
*/
function hasDeclaredArtifact(
dir: string,
schema: string,
base: string,
id: string,
warnings: ReadWarning[],
): boolean {
if (schemaDir(schema, base) === undefined) return false
let artifacts: { generates: string }[]
try {
artifacts = loadSchema(schema, base)
} catch (err) {
warnings.push({
code: 'schema_unreadable',
message: `${id}: ${err instanceof Error ? err.message : String(err)}; its artifacts are counted as none`,
})
return false
}
try {
return artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates))
} catch {
// upstream's bare `catch` on an output it cannot resolve (one leaving
// the change, a linked directory cycle): no signal.
return false
}
}

function computeRow(
base: string,
id: string,
Expand All @@ -181,14 +223,30 @@ function computeRow(
): Row {
const dir = join(changesDir(base), id)
const finding = findNestedChangesIn(changesDir(base), id)
const schema = readOpenspecYaml(dir)?.schema ?? ''
// A change with no `.openspec.yaml` of its own takes its schema the way
// `cospec status`'s `gradedChange` and `core/change.ts`'s `hasSchemaOutput`
// do — the project's `config.yaml` `schema:`, else `spec-driven` — so a
// custom-named artifact under that fallback schema is never reported as no
// artifacts at all, and the row's type/completeness agree with `status`.
// A namespace folder is not a change at all (`state` below reports it as
// such), so it never takes this fallback — `status --all` discards its
// `gradedChange`-resolved schema the same way, reporting it as a failure
// entry with no `type` field rather than the project's default schema.
const bare = finding === undefined && !existsSync(join(dir, '.openspec.yaml'))
const schema = bare ? defaultProjectSchema(base) : (readOpenspecYaml(dir)?.schema ?? '')
const blockersPath = join(dir, 'blocking-changes.md')
const gate = existsSync(blockersPath)
? computeGate(parseBlockers(readFileSync(blockersPath, 'utf8')), archived, active)
: ({ state: 'clear', hard: [], soft: [] } satisfies Gate)

const empty = !hasAnyArtifact(dir)
const cospec = isCospecType(schema)
// cospec's fixed artifact filenames are the only signal for a cospec-typed
// change; a schema cospec doesn't type additionally gets its own declared
// schema's `generates` signal, so a custom-named artifact cospec doesn't
// recognize by filename is never reported as no artifacts at all (the
// misclassification task 11.5 fixed for `status`'s `state`/`next`).
const empty =
!hasAnyArtifact(dir) && (cospec || !hasDeclaredArtifact(dir, schema, base, id, warnings))

const parsedTasks = readChangeTasks(dir, warnings)
const total = parsedTasks.items.length
Expand Down
18 changes: 15 additions & 3 deletions apps/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import {
import {
archiveDir,
changesDir,
defaultProjectSchema,
describeNestedChange,
findNestedChangesIn,
isCospecType,
listChanges,
projectConfigSchema,
resolveChange,
type Change,
} from '../core/change.ts'
Expand Down Expand Up @@ -155,7 +155,19 @@ export interface TasksWarning {
message: string
}

export type ReadWarning = ArchiveWarning | TasksWarning
/**
* The warning for a change whose own declared schema resolves to a real
* schema directory but fails to load (read, parse or validate) — `list`'s
* `hasDeclaredArtifact`. A schema name that resolves to no directory at all
* gives no signal and no warning, matching the binary's own `list`, which
* never loads a schema.
*/
export interface SchemaWarning {
code: 'schema_unreadable'
message: string
}

export type ReadWarning = ArchiveWarning | TasksWarning | SchemaWarning

const NO_TASKS: ParsedTasks = { items: [], malformed: [], groups: [] }

Expand Down Expand Up @@ -369,7 +381,7 @@ export interface ChangeEntryFailure {
*/
function gradedChange(base: string, change: Change, override: string | undefined): Change {
const bare = !existsSync(join(change.dir, '.openspec.yaml'))
const schema = override ?? (bare ? (projectConfigSchema(base) ?? 'spec-driven') : change.schema)
const schema = override ?? (bare ? defaultProjectSchema(base) : change.schema)
return bare ? { ...change, schema, schemaVersion: 1 } : { ...change, schema }
}

Expand Down
42 changes: 33 additions & 9 deletions apps/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,21 @@ async function validateForcedSpec(root: Root, id: string, strict: boolean): Prom
/** The first openspec release whose `validate` takes `--archived`. */
const ARCHIVED_SINCE = '1.9.0'

/**
* `validate --archived`'s refusal when the wrapped OpenSpec is below
* `ARCHIVED_SINCE`, or `undefined` when it isn't. A pure function of the
* version string (never spawns), so it is unit-testable without a fake
* binary: the command's own `--json`/text branch (`rootSelectionDocument` or
* `cospec: <message>`) matches every other early-exit refusal in this file.
*/
export function archivedUnsupportedRefusal(version: string): RootSelectionError | undefined {
if (!openspecBelow(version, ARCHIVED_SINCE)) return undefined
return new RootSelectionError({
code: 'openspec_version_too_old',
message: `validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ${version}`,
})
}

/**
* The binary's answer to `validate --archived`: its report's items, or its
* failure document (an unreadable `changes/archive/`, say) with its exit code.
Expand Down Expand Up @@ -1482,16 +1497,26 @@ const NO_OPENSPEC_ROOT = new RootSelectionError({
fix: respellRemedies('Run openspec init to create a root here.'),
})

/**
* Test-only override for the wrapped binary's version read — lets a unit test
* drive `--archived`'s version-floor refusal (`archivedUnsupportedRefusal`)
* without a fake binary, since `wrappedOpenspecVersion` memoizes its result
* once per process. Production callers omit it and get the real read.
*/
export interface ValidateDeps {
wrappedOpenspecVersion?: () => Promise<string>
}

/**
* `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<number> {
return answeringErrno(ctx.flags.json, { code: 'validate_error' }, () => validate(ctx))
export function run(ctx: CommandContext, deps: ValidateDeps = {}): Promise<number> {
return answeringErrno(ctx.flags.json, { code: 'validate_error' }, () => validate(ctx, deps))
}

async function validate(ctx: CommandContext): Promise<number> {
async function validate(ctx: CommandContext, deps: ValidateDeps): Promise<number> {
const { flags } = ctx
const parsed = ctx.parsed!
const strict = hasFlag(parsed, '--strict')
Expand Down Expand Up @@ -1551,12 +1576,11 @@ async function validate(ctx: CommandContext): Promise<number> {
// changes/archive/, which active-change discovery deliberately excludes, and
// it must never quietly alter an ordinary invocation.
if (wantArchived) {
const version = await wrappedOpenspecVersion()
if (openspecBelow(version, ARCHIVED_SINCE)) {
process.stderr.write(
`cospec: validate --archived needs OpenSpec >=${ARCHIVED_SINCE}; the wrapped OpenSpec is ` +
`${version}\n`,
)
const version = await (deps.wrappedOpenspecVersion ?? wrappedOpenspecVersion)()
const refusal = archivedUnsupportedRefusal(version)
if (refusal !== undefined) {
if (flags.json) process.stdout.write(rootSelectionDocument(refusal))
else process.stderr.write(`cospec: ${refusal.diagnostic.message}\n`)
return 1
}
const archived = await validateArchived(root)
Expand Down
13 changes: 12 additions & 1 deletion apps/cli/src/core/change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,17 @@ export function projectConfigSchema(base: string): string | undefined {
return typeof schema === 'string' && schema.length > 0 ? schema : undefined
}

/**
* The schema a change with no (or unusable) `.openspec.yaml` of its own
* resolves to: the project's `config.yaml` `schema:`, else `spec-driven` —
* upstream's own default-schema fallback. Shared by `hasSchemaOutput` below,
* `cospec status`'s `gradedChange` and `cospec list`'s row computation, so the
* three never drift apart on what a bare change's type is.
*/
export function defaultProjectSchema(base: string): string {
return projectConfigSchema(base) ?? 'spec-driven'
}

/**
* upstream's `hasSchemaOutput`: `dir` holds a file where the schema it resolves
* to (its `.openspec.yaml`, else the root's `config.yaml`, else `spec-driven`)
Expand All @@ -375,7 +386,7 @@ function hasSchemaOutput(dir: string, projectRoot: string): boolean {
// A candidate reaching here has no regular `.openspec.yaml`; anything else at
// that path fails upstream's metadata read, which gives no signal.
if (existsSync(join(dir, '.openspec.yaml'))) return false
const name = projectConfigSchema(projectRoot) ?? 'spec-driven'
const name = defaultProjectSchema(projectRoot)
let artifacts: { generates: string }[]
try {
artifacts = loadSchema(name, projectRoot)
Expand Down
95 changes: 95 additions & 0 deletions apps/cli/test/contract/cli-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2768,6 +2768,101 @@ describe('17. round-4 review rows', () => {
})
})

// --- 18. list-status-untyped-leftovers ------------------------------------------------------

describe('18. list-status-untyped-leftovers', () => {
test("18.1 list reports building for an untyped schema's own declared artifact", async () => {
const root = cospecRoot()
rfcSchema(root)
writeChange(root, 'r-doc', { 'doc.md': '# RFC\n' }, 'rfc')
writeChange(root, 'r-empty', {}, 'rfc')
const up = await upstreamJson(['list', '--json'], root)
const cs = await oursJson(['list', '--json'], root)
expect(cs.exitCode).toBe(up.exitCode)
const row = rowsOf(cs.json).find((r) => r.change === 'r-doc')!
expect(row.state).toBe('building')
expect(row.archiveReady).toBe(false)
const empty = rowsOf(cs.json).find((r) => r.change === 'r-empty')!
expect(empty.state).toBe('in-progress')
// cospec's native `state` and the binary's own task-count-only `status`
// coexist on the same row without a key collision.
const upRow = rowsOf(up.json).find((r) => r.name === 'r-doc')!
expect(upRow.status).toBe('no-tasks')
expect(row.status).toBe('no-tasks')
// `--sort` is irrelevant here (recency order is non-deterministic across
// filesystems); find r-doc's own line, wherever the table put it.
const text = await ours(['list'], root)
const line = text.stdout.split('\n').find((l) => l.includes('r-doc'))!
expect(line).toMatch(/^\s*r-doc\s+rfc\s+clear\s+0\/0 tasks\s*$/)
})

unlessRoot('mode 000', () => {
test('18.2 status: a mode-000 artifact other than tasks.md already answers as the binary does', async () => {
const root = listFixture()
const proposal = join(root, 'openspec/changes/alpha/proposal.md')
const restore = lock(proposal)
try {
for (const argv of [
['status', '--change', 'alpha', '--json'],
['status', '--all', '--json'],
]) {
const up = await upstreamJson(argv, root)
const cs = await oursJson(argv, root)
captureStatus(`18.2 ${argv.join(' ')}`, cs)
// Ground truth is the measured binary answer, never a prediction:
// --change and --all can disagree on exit code for a reason
// unrelated to the mode-000 lock itself. `--all`'s sweep also walks
// `mobile`, the fixture's own namespace folder, which the binary
// reports as its own `change_error` ("is not a change") whether or
// not alpha's `proposal.md` is locked — confirmed on Linux/Bun,
// where the lock itself is read past in both modes (its `realpath`
// needs no read permission there) and only `mobile` drives --all's
// exit 1; on macOS/Bun the lock also refuses `--change alpha` on its
// own. Each invocation's own exit code and refusal shape decide the
// branch below, so this fixture quirk never has to be modeled.
if (argv[1] === '--all') {
const mobileEntry = rowsOf(up.json).find((e) => e.changeName === 'mobile')
expect(Array.isArray(mobileEntry?.status)).toBe(true)
}
expect({ argv, exit: cs.exitCode }).toEqual({ argv, exit: up.exitCode })
const textArgv = argv.filter((a) => a !== '--json')
const upText = await upstream(textArgv, root)
const text = await ours(textArgv, root)
captureStatus(`18.2 ${textArgv.join(' ')}`, text)
expect({ textArgv, exit: text.exitCode }).toEqual({ textArgv, exit: upText.exitCode })

const upEntry =
argv[1] === '--all'
? rowsOf(up.json).find((e) => e.changeName === 'alpha')!
: (up.json as Row)
const refusedHere = Array.isArray(upEntry.status)
if (refusedHere) {
expect(up.exitCode).toBe(1)
const d = firstStatus(upEntry)
expect(errnoShape(d.message)).toMatchObject({
code: 'EACCES',
path: join(realpathSync(dirname(proposal)), 'proposal.md'),
})
continue
}
// The binary read past it and counts `proposal` done; so does cospec.
const upDone =
(upEntry.artifacts as Row[]).find((a) => a.id === 'proposal')!.status === 'done'
const csEntry =
argv[1] === '--all'
? rowsOf(cs.json).find((e) => e.change === 'alpha')!
: (cs.json as Row)
const csDone = (csEntry.artifacts as Row[]).find((a) => a.id === 'proposal')!.done
expect(csDone).toBe(upDone)
expect(csDone).toBe(true)
}
} finally {
restore()
}
})
})
})

// --- 5.6 no status output names a bare openspec command ------------------------------------

describe('5.6 status outputs', () => {
Expand Down
16 changes: 12 additions & 4 deletions apps/cli/test/contract/upstream-spellings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,8 +991,15 @@ describe('3.7 an instructions failure is the binary answer, rendered from its do
for (const asJson of [false, true]) {
const full = [...argv, ...(asJson ? ['--json'] : [])]
test(`${full.join(' ')}: the listed change names are the binary's bytes`, async () => {
const c = await runCospec(full, remedyNamedRoot())
const u = await runUpstream(full, remedyNamedRoot())
// One shared root for both calls: the binary's own directory listing
// (dist's getAvailableChanges) is unsorted and cospec relays it
// verbatim, so two independently-created copies can land on different
// on-disk entry orders (overlayfs) even though neither side sorts —
// sharing the root removes that dependency instead of asserting an
// order either side doesn't guarantee.
const root = remedyNamedRoot()
const c = await runCospec(full, root)
const u = await runUpstream(full, root)
expect(u.exitCode, detail('openspec', u)).toBe(1)
for (const name of REMEDY_SHAPED_CHANGES)
expect(asJson ? statusMessage(json(u)) : u.stderr).toContain(`\n ${name}`)
Expand All @@ -1010,8 +1017,9 @@ describe('3.7 an instructions failure is the binary answer, rendered from its do
for (const asJson of [false, true]) {
const argv = ['instructions', 'proposal', '--change', name, ...(asJson ? ['--json'] : [])]
test(`a listed name copied back resolves: ${JSON.stringify(argv)}`, async () => {
const c = await runCospec(argv, remedyNamedRoot())
const u = await runUpstream(argv, remedyNamedRoot())
const root = remedyNamedRoot()
const c = await runCospec(argv, root)
const u = await runUpstream(argv, root)
expect(u.exitCode, detail('openspec', u)).toBe(0)
expect(c.exitCode, detail('cospec', c)).toBe(0)
if (asJson) expect(json(c)['changeName']).toBe(name)
Expand Down
Loading
Loading