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).
27 changes: 27 additions & 0 deletions docs-lab/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ Next steps:
}
}
},
"overlaps": [],
"version": "1.0",
"root": {
"path": "/Users/you/projects/my-app",
Expand All @@ -761,6 +762,32 @@ Next steps:

`issues` entries carry a `level` of `ERROR`, `WARNING`, or `INFO`.

**Cross-change overlap**

When changes are in scope (`--changes` or `--all`), validate also reports requirements that more than one active change claims. Each change is checked against the current main specs, which neither change has landed in yet, so both are valid on their own until the first one archives.

```text
⚠ 1 requirement is claimed by more than one active change:
api: Request throttling (in the main spec)
add-caching MODIFIED, add-rate-limit MODIFIED
Whichever of these archives second lands on a spec the first one changed; re-read it before archiving.
```

An entry names the spec, the requirement, and every change claiming it. Each claim carries its operation: `ADDED`, `MODIFIED`, `REMOVED`, `RENAMED_FROM`, or `RENAMED_TO`. The note after the requirement says whether the main specs hold it today, either `in the main spec` or `not in the main spec yet`.

The report is advisory. Overlap is often deliberate, such as a stacked pair of changes worked in sequence. It never changes the exit code, and it makes no claim about which change is wrong.

Under `--json` the same entries appear in an `overlaps` array:

| Field | What it is |
|---|---|
| `specId` | The spec holding the requirement. |
| `requirement` | The requirement name more than one change claims. |
| `inMainSpec` | Whether the main specs hold that requirement today. |
| `claimants` | One entry per claiming change, each with `changeId`, `operation`, and `requirement`. |

The array is present, possibly empty, whenever changes are in scope. It is absent otherwise. `--report findings` returns its own document and carries no overlaps.

**Exit codes**

- `0`: every validated item passed, including an empty bulk scope.
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,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. `--report findings` returns the separate `validation-findings` document instead, which carries no `overlaps`.

### 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
76 changes: 74 additions & 2 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { promises as fs } from 'fs';
import { getTaskProgressDetailForChange, type SchemaGlobCache } from '../utils/task-progress.js';
import { FileSystemUtils } from '../utils/file-system.js';
import { folderStyleNameProblem } from '../core/id.js';
import { detectChangeOverlaps, type RequirementOverlap } from '../core/change-overlap.js';

type ItemType = 'change' | 'spec';

Expand Down Expand Up @@ -499,7 +500,15 @@ export class ValidateCommand {
if (opts.findingsScope) {
this.printFindingsReport({ items: [], summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
} else 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 @@ -554,10 +563,29 @@ 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.
//
// `--report findings` is a distinct shape carrying item findings only, so
// the scan is skipped there rather than computed and discarded.
const overlaps = scope.changes && !opts.findingsScope
? await this.detectOverlaps(root, changeIds)
: undefined;

if (opts.findingsScope) {
this.printFindingsReport({ items: results, summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
} else 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 @@ -570,11 +598,55 @@ export class ValidateCommand {
}
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
this.printBulkDetails(results, root);
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