Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/validate-cross-change-overlap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@fission-ai/openspec": minor
---

`openspec validate --changes` (and `--all`) now reports requirements that more than one active change claims. Every existing check compares a single change against the *current* main spec, so two changes converging on one requirement are each individually valid — the collision only surfaces when the first one archives and the second starts failing, by which point its author has already implemented against a base that moved.

Each entry names the claiming changes and the operation each one applies (`ADDED`, `MODIFIED`, `REMOVED`, `RENAMED_FROM`, `RENAMED_TO`), and whether the main spec holds that requirement today — two changes editing shared text is a different situation from two changes each proposing it. Rename deltas are reported at both ends, since the old name collides with anyone editing it and the new name collides with anyone adding it.

The report is informational: overlap is often deliberate for stacked or sequenced work, so it never changes the exit code and makes no claim about which change is wrong. Under `--json` the entries appear in an `overlaps` array. Addresses [#1669](https://github.com/Fission-AI/OpenSpec/issues/1669) and [#1387](https://github.com/Fission-AI/OpenSpec/issues/1387).
2 changes: 1 addition & 1 deletion docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ deliberately remains the compatibility bare array documented in §4.13:
Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`.

### 4.3 `validate --json`
`{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "version": "1.0", "root" }`. Exit 1 when any item fails.
`{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "overlaps"?: [ { "specId", "requirement", "inMainSpec", "claimants": [{changeId, operation, requirement}] } ], "version": "1.0", "root" }`. Exit 1 when any item fails. `overlaps` is present (possibly empty) whenever changes are in scope (`--changes`/`--all`) and absent otherwise; each entry is a requirement more than one active change claims, with `operation` one of `ADDED`/`MODIFIED`/`REMOVED`/`RENAMED_FROM`/`RENAMED_TO` and `inMainSpec` saying whether the main spec holds it today. It is informational — overlap is often deliberate — and never affects the exit code.

### 4.4 `status --json`
`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "<id>": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isPlanningComplete", "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. `isPlanningComplete` means every non-skipped planning artifact exists; skipped artifacts count as satisfied without being created. It does not mean implementation tasks are complete. `isComplete` is retained as a compatibility alias with the same value. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. The `artifacts` array is in dependency order, with the schema's `artifacts:` declaration order breaking ties between artifacts that become ready at the same time (never alphabetical), so the first `ready` entry is the artifact to write next; `missingDeps` uses that same order. `"skipped"` marks an artifact whose `generates` path is under `specs/` in a change whose `.openspec.yaml` declares `skip_specs: true`; it satisfies dependencies but must not be created. No active changes: `{ "changes": [], "message", "root" }`, exit 0.
Expand Down
13 changes: 13 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,19 @@ A change with zero spec deltas fails validation unless its `.openspec.yaml` decl

`--archived` is its own scope: it does not validate spec deltas (already applied at archive time), it verifies that every change under `changes/archive/` has all of its `tasks.md` checkboxes ticked, exiting non-zero if any are unchecked. This catches changes that were archived with unfinished work — handy in a pre-commit hook.

When changes are in scope (`--changes` or `--all`), validation also reports requirements that more than one active change claims. Each change is validated against the current main spec, so two changes converging on one requirement are both valid until the first archives — this surfaces that collision before it lands.

Each entry names the claiming changes and what each one does to the requirement (`ADDED`, `MODIFIED`, `REMOVED`, `RENAMED_FROM`, `RENAMED_TO`), and whether the main spec holds that requirement today — two changes editing shared text is a different situation from two changes each proposing it. Overlap is often deliberate (a stacked pair, sequenced work), so the report is informational: it never changes the exit code and makes no claim about which change is wrong.

```text
⚠ 1 requirement is claimed by more than one active change:
tools: Slash Command Configuration (in the main spec)
add-kilocode-workflows MODIFIED, add-windsurf-workflows MODIFIED
Whichever of these archives second lands on a spec the first one changed; re-read it before archiving.
```

Under `--json` the same entries appear in an `overlaps` array alongside `items` and `summary`, present (possibly empty) whenever changes are in scope.

**Examples:**

```bash
Expand Down
71 changes: 69 additions & 2 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { nearestMatches } from '../utils/match.js';
import { promises as fs } from 'fs';
import { getTaskProgressDetailForChange, type SchemaGlobCache } from '../utils/task-progress.js';
import { FileSystemUtils } from '../utils/file-system.js';
import { detectChangeOverlaps, type RequirementOverlap } from '../core/change-overlap.js';

type ItemType = 'change' | 'spec';

Expand Down Expand Up @@ -332,7 +333,15 @@ export class ValidateCommand {
} as const;

if (opts.json) {
const out = { items: [] as BulkItemResult[], summary, version: '1.0', root: toRootOutput(root) };
const out = {
items: [] as BulkItemResult[],
summary,
// Present whenever changes are in scope, so a consumer sees the same
// shape here as on the path that actually validated something.
...(scope.changes ? { overlaps: [] as RequirementOverlap[] } : {}),
version: '1.0',
root: toRootOutput(root),
};
console.log(JSON.stringify(out, null, 2));
} else {
console.log('No items found to validate.');
Expand Down Expand Up @@ -387,8 +396,22 @@ export class ValidateCommand {
},
} as const;

// Every check above compares one change against the *current* main spec, so
// none of them can see two open changes converging on the same requirement:
// each is individually consistent with a spec neither has landed in yet.
// Report that here, only when changes are in scope, and only as
// information — overlap is often deliberate (a stacked pair, sequenced
// work), so it never fails the run or moves the exit code.
const overlaps = scope.changes ? await this.detectOverlaps(root, changeIds) : undefined;

if (opts.json) {
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
const out = {
items: results,
summary,
...(overlaps ? { overlaps } : {}),
version: '1.0',
root: toRootOutput(root),
};
console.log(JSON.stringify(out, null, 2));
} else {
for (const res of results) {
Expand All @@ -403,11 +426,55 @@ export class ValidateCommand {
`Details: openspec validate ${firstFailure.id} --type ${firstFailure.type}${storeFlag}`
);
}
this.printOverlaps(overlaps ?? []);
}

process.exitCode = failed > 0 ? 1 : 0;
}

/**
* Cross-change overlap for the changes this run already resolved.
*
* Scoped to `root.changesDir` rather than a path rebuilt from the project
* root, so a `--store` run scans the store it selected. A single change can
* never overlap anything, so the scan is skipped entirely below two.
*/
private async detectOverlaps(
root: ResolvedOpenSpecRoot,
changeIds: string[]
): Promise<RequirementOverlap[]> {
if (changeIds.length < 2) return [];
try {
return await detectChangeOverlaps({
changesDir: root.changesDir,
specsDir: root.specsDir,
changeIds,
});
} catch {
// Advisory output must never be the thing that fails a validate run:
// every delta this reads is also read by the per-change validation
// above, which reports its own errors on its own path.
return [];
}
}

private printOverlaps(overlaps: RequirementOverlap[]): void {
if (overlaps.length === 0) return;
const label = overlaps.length === 1 ? 'requirement is' : 'requirements are';
console.log('');
console.log(`⚠ ${overlaps.length} ${label} claimed by more than one active change:`);
for (const overlap of overlaps) {
const base = overlap.inMainSpec ? 'in the main spec' : 'not in the main spec yet';
console.log(` ${overlap.specId}: ${overlap.requirement} (${base})`);
console.log(
` ${overlap.claimants.map((c) => `${c.changeId} ${c.operation}`).join(', ')}`
);
}
console.log(
'Whichever of these archives second lands on a spec the first one changed; re-read it before archiving.'
);
}

/**
* Lists archived change ids from the resolved root's archive directory,
* mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
Expand Down
Loading
Loading