Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/skip-specs-explicit-zero-delta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---

Add `skip_specs: true` change metadata for work with no spec-level behavior change (pure refactors, tooling, docs). `openspec validate` accepts a zero-delta change that declares the marker and errors when the marker and delta specs are both present, the artifact graph no longer blocks `tasks` on spec files for such changes, and the propose/specs guidance points to the marker instead of contradicting the validator.
9 changes: 9 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ AI: Created the change. The proposal states the goal (split the
Ready for implementation.
```

Declare the empty delta explicitly by setting `skip_specs: true` in the change's `.openspec.yaml`:

```yaml
schema: spec-driven
skip_specs: true
```

Without the marker, `openspec validate` rejects a change with zero deltas (so a forgotten specs phase still gets caught); with it, validation passes and the specs stage counts as complete.

When you archive a change that doesn't touch specs, you can tell the terminal command to skip the spec step:

```bash
Expand Down
13 changes: 13 additions & 0 deletions schemas/spec-driven/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ artifacts:
proposal and specs phases. Research existing specs before filling this in.
Each capability listed here will need a corresponding spec file.

Every change must either declare at least one capability (new or
modified) or explicitly opt out of specs: `openspec validate` rejects a
change with zero deltas unless the change's `.openspec.yaml` sets
`skip_specs: true`. Use `skip_specs: true` only when no spec-level
behavior changes (pure refactor, tooling, docs) - specs describe
behavior, so if behavior does not change, no spec should change either.
Do not invent a requirement just to satisfy validation.

Keep it concise (1-2 pages). Focus on the "why" not the "how" -
implementation details belong in design.md.

Expand Down Expand Up @@ -55,6 +63,11 @@ artifacts:
- New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md).
- Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md.

There must be at least one spec file unless the change's `.openspec.yaml`
sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`
rejects a zero-delta change without that marker. If the proposal lists no
capabilities and `skip_specs` is not set, revisit the proposal first.

Delta operations (use ## headers):
- **ADDED Requirements**: New capabilities
- **MODIFIED Requirements**: Changed behavior - MUST include full updated content
Expand Down
6 changes: 5 additions & 1 deletion schemas/spec-driven/templates/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
### Modified Capabilities
<!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation).
Only list here if spec-level behavior changes. Each needs a delta spec file.
Use existing spec names from openspec/specs/. Leave empty if no requirement changes. -->
Use existing spec names from openspec/specs/. Leave empty if no requirement
changes. A change with no capabilities at all (pure refactor, tooling, docs)
must set `skip_specs: true` in its .openspec.yaml - openspec validate rejects
a zero-delta change without that marker. Do not invent a requirement just to
satisfy validation. -->
- `<existing-name>`: <what requirement is changing>

## Impact
Expand Down
11 changes: 11 additions & 0 deletions src/core/artifact-graph/instruction-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,17 @@ export function loadChangeContext(
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, changeDir);

// A change that declares skip_specs has no spec deltas by design, so
// artifacts generating into specs/ count as complete; otherwise the graph
// would block their dependents (e.g. tasks) on files that must not exist.
if (metadata?.skip_specs) {
for (const artifact of graph.getAllArtifacts()) {
if (artifact.generates.startsWith('specs/')) {
completed.add(artifact.id);
}
}
}

return {
graph,
completed,
Expand Down
4 changes: 4 additions & 0 deletions src/core/change-metadata/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const ChangeMetadataSchema = z.object({
goal: z.string().min(1).optional(),
affected_areas: z.array(z.string().min(1)).optional(),
initiative: InitiativeLinkSchema.optional(),
// Declares that this change intentionally has no spec deltas (pure refactor,
// tooling, or docs work). Validation accepts zero deltas and the artifact
// graph treats specs-producing artifacts as complete.
skip_specs: z.boolean().optional(),
});

export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
6 changes: 5 additions & 1 deletion src/core/validation/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const VALIDATION_MESSAGES = {
CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`,
CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty',
CHANGE_NO_DELTAS: 'Change must have at least one delta',
CHANGE_SKIP_SPECS_CONFLICT:
'skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files',
CHANGE_SKIP_SPECS_ACCEPTED:
'skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted',
CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`,
DELTA_SPEC_EMPTY: 'Spec name cannot be empty',
DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',
Expand All @@ -38,7 +42,7 @@ export const VALIDATION_MESSAGES = {

// Guidance snippets (appended to primary messages for remediation)
GUIDE_NO_DELTAS:
'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
GUIDE_MISSING_SPEC_SECTIONS:
'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...',
GUIDE_MISSING_CHANGE_SECTIONS:
Expand Down
52 changes: 47 additions & 5 deletions src/core/validation/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { discoverSpecFiles } from '../../utils/spec-discovery.js';
import { METADATA_FILENAME } from '../../utils/change-metadata.js';
import * as yaml from 'yaml';

export class Validator {
private strictMode: boolean;
Expand Down Expand Up @@ -85,13 +87,19 @@ export class Validator {
const content = readFileSync(filePath, 'utf-8');
const changeDir = path.dirname(filePath);
const parser = new ChangeParser(content, changeDir);

const change = await parser.parseChangeWithDeltas(changeName);

const result = ChangeSchema.safeParse(change);

if (!result.success) {
issues.push(...this.convertZodErrors(result.error));
let zodIssues = this.convertZodErrors(result.error);
if (this.changeDeclaresSkipSpecs(changeDir)) {
zodIssues = zodIssues.filter(
issue => !issue.message.startsWith(VALIDATION_MESSAGES.CHANGE_NO_DELTAS)
);
}
issues.push(...zodIssues);
}

issues.push(...this.applyChangeRules(change, content));
Expand Down Expand Up @@ -122,6 +130,7 @@ export class Validator {
const issues: ValidationIssue[] = [];
const specsDir = path.join(changeDir, 'specs');
let totalDeltas = 0;
let discoveredSpecFileCount = 0;
let hasRootLevelSpec = false;
const missingHeaderSpecs: string[] = [];
const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];
Expand All @@ -133,6 +142,7 @@ export class Validator {
// both specs/<capability>/spec.md and the nested multi-area
// specs/<area>/<capability>/spec.md layout (#1182b).
const specFiles = (await discoverSpecFiles(specsDir)).map(spec => spec.specFile);
discoveredSpecFileCount = specFiles.length;

// A spec.md directly at the specs/ root has no capability folder, so the
// merge path drops it: without this error the change validates clean and
Expand Down Expand Up @@ -323,16 +333,48 @@ export class Validator {
});
}

// Any spec file under specs/ contradicts the marker, whether or not it
// parses to deltas: the file would be dropped or misread while the change
// claims to have none.
const skipSpecs = this.changeDeclaresSkipSpecs(changeDir);
if (skipSpecs && (discoveredSpecFileCount > 0 || hasRootLevelSpec)) {
issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT });
}

// The root-level error already names the file and the fix; adding "No
// deltas found" on top would contradict it, since the deltas are sitting in
// the file just reported.
if (totalDeltas === 0 && !hasRootLevelSpec) {
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
if (skipSpecs && discoveredSpecFileCount === 0) {
issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED });
} else if (!skipSpecs) {
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
}
}

return this.createReport(issues);
}

/**
* Reads the change's .openspec.yaml directly instead of through
* readChangeMetadata: whether specs are intentionally skipped must not
* depend on the metadata's workflow schema resolving, and malformed
* metadata means "not declared" rather than a validation crash.
*/
private changeDeclaresSkipSpecs(changeDir: string): boolean {
try {
const raw = readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8');
const parsed: unknown = yaml.parse(raw);
return (
typeof parsed === 'object' &&
parsed !== null &&
(parsed as Record<string, unknown>).skip_specs === true
);
} catch {
return false;
}
}

private convertZodErrors(error: ZodError): ValidationIssue[] {
return error.issues.map(err => {
let message = err.message;
Expand Down
29 changes: 29 additions & 0 deletions test/core/artifact-graph/instruction-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,35 @@ describe('instruction-loader', () => {

expect(context.schemaName).toBe('spec-driven');
});

it('should mark specs complete when metadata declares skip_specs', () => {
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
fs.writeFileSync(
path.join(changeDir, '.openspec.yaml'),
'schema: spec-driven\nskip_specs: true\n'
);

const context = loadChangeContext(tempDir, 'my-change');

expect(context.completed.has('specs')).toBe(true);
// Only specs-producing artifacts are synthesized; the rest still
// depend on their files existing.
expect(context.completed.has('tasks')).toBe(false);
expect(context.completed.has('design')).toBe(false);
});

it('should not mark specs complete without skip_specs', () => {
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal');
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n');

const context = loadChangeContext(tempDir, 'my-change');

expect(context.completed.has('specs')).toBe(false);
});
});

describe('generateInstructions', () => {
Expand Down
132 changes: 132 additions & 0 deletions test/core/validation.skip-specs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { Validator } from '../../src/core/validation/validator.js';

const PROPOSAL = `# Test Change

## Why
This is a sufficiently long explanation to pass the why length requirement for validation purposes.

## What Changes
Pure internal refactor with no spec-level behavior change.`;

const DELTA_SPEC = `## ADDED Requirements

### Requirement: User can export data
The system SHALL allow users to export their data in CSV format.

#### Scenario: Successful export
- **WHEN** user clicks "Export"
- **THEN** system downloads a CSV file
`;

describe('Validator skip_specs handling', () => {
const testDir = path.join(process.cwd(), 'test-validation-skip-specs-tmp');

beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true });
});

afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});

it('rejects a zero-delta change without the marker', async () => {
const validator = new Validator();
const report = await validator.validateChangeDeltaSpecs(testDir);

expect(report.valid).toBe(false);
const msg = report.issues.map(i => i.message).join('\n');
expect(msg).toContain('Change must have at least one delta');
expect(msg).toContain('set "skip_specs: true"');
});

it('accepts a zero-delta change that declares skip_specs', async () => {
await fs.writeFile(
path.join(testDir, '.openspec.yaml'),
'schema: spec-driven\nskip_specs: true\n'
);

const validator = new Validator();
const report = await validator.validateChangeDeltaSpecs(testDir);

expect(report.valid).toBe(true);
expect(report.issues.some(i => i.level === 'ERROR')).toBe(false);
const info = report.issues.find(i => i.level === 'INFO');
expect(info?.message).toContain('skip_specs');
});

it('rejects skip_specs combined with delta specs', async () => {
await fs.writeFile(
path.join(testDir, '.openspec.yaml'),
'schema: spec-driven\nskip_specs: true\n'
);
const capDir = path.join(testDir, 'specs', 'data-export');
await fs.mkdir(capDir, { recursive: true });
await fs.writeFile(path.join(capDir, 'spec.md'), DELTA_SPEC);

const validator = new Validator();
const report = await validator.validateChangeDeltaSpecs(testDir);

expect(report.valid).toBe(false);
const msg = report.issues.map(i => i.message).join('\n');
expect(msg).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/');
});

it('treats skip_specs plus a delta file with no parseable deltas as a conflict, not acceptance', async () => {
await fs.writeFile(
path.join(testDir, '.openspec.yaml'),
'schema: spec-driven\nskip_specs: true\n'
);
const capDir = path.join(testDir, 'specs', 'data-export');
await fs.mkdir(capDir, { recursive: true });
await fs.writeFile(path.join(capDir, 'spec.md'), '# Notes without delta headers\n');

const validator = new Validator();
const report = await validator.validateChangeDeltaSpecs(testDir);

expect(report.valid).toBe(false);
const messages = report.issues.map(i => i.message).join('\n');
expect(messages).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/');
expect(report.issues.some(i => i.level === 'INFO')).toBe(false);
});

it('still rejects zero deltas when metadata is malformed', async () => {
await fs.writeFile(path.join(testDir, '.openspec.yaml'), '{invalid yaml: [');

const validator = new Validator();
const report = await validator.validateChangeDeltaSpecs(testDir);

expect(report.valid).toBe(false);
const msg = report.issues.map(i => i.message).join('\n');
expect(msg).toContain('Change must have at least one delta');
});

it('skip_specs must be exactly true, not a truthy string', async () => {
await fs.writeFile(
path.join(testDir, '.openspec.yaml'),
'schema: spec-driven\nskip_specs: "yes"\n'
);

const validator = new Validator();
const report = await validator.validateChangeDeltaSpecs(testDir);

expect(report.valid).toBe(false);
});

it('validateChange drops the no-deltas error when skip_specs is declared', async () => {
await fs.writeFile(path.join(testDir, 'proposal.md'), PROPOSAL);
await fs.writeFile(
path.join(testDir, '.openspec.yaml'),
'schema: spec-driven\nskip_specs: true\n'
);

const validator = new Validator();
const report = await validator.validateChange(path.join(testDir, 'proposal.md'));

expect(report.valid).toBe(true);
const msg = report.issues.map(i => i.message).join('\n');
expect(msg).not.toContain('Change must have at least one delta');
});
});
Loading
Loading