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
13 changes: 6 additions & 7 deletions src/core/artifact-graph/instruction-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import * as path from 'node:path';
import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
import {
isSpecsArtifactPath,
resolveArtifactOutputPath,
resolveArtifactOutputs,
} from './outputs.js';
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import {
Expand Down Expand Up @@ -285,12 +289,7 @@ export function loadChangeContext(
const skippedArtifacts = new Set<string>();
if (metadata?.skip_specs) {
for (const artifact of graph.getAllArtifacts()) {
// A schema may write generates as './specs/...' - the globs treat that
// identically to 'specs/...', so the skip set must too, or validate
// would honor the marker while instructions tell the agent to create
// the very files the conflict gate polices.
const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
if (isSpecsArtifactPath(artifact.generates) && !completed.has(artifact.id)) {
completed.add(artifact.id);
skippedArtifacts.add(artifact.id);
}
Expand Down
8 changes: 8 additions & 0 deletions src/core/artifact-graph/outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}

/**
* Returns whether an artifact generates files under the change's specs/ tree.
*/
export function isSpecsArtifactPath(generates: string): boolean {
const normalized = path.posix.normalize(FileSystemUtils.toPosixPath(generates));
return normalized.startsWith('specs/');
}

export function resolveArtifactOutputPath(changeDir: string, generates: string): string {
const outputPath = path.join(changeDir, generates);
FileSystemUtils.assertPathWithin(changeDir, outputPath);
Expand Down
8 changes: 8 additions & 0 deletions src/utils/change-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
import { formatLocalDate } from './date.js';
import { readProjectConfig } from '../core/project-config.js';
import { isKebabId } from '../core/id.js';
import { resolveSchema } from '../core/artifact-graph/resolver.js';
import { isSpecsArtifactPath } from '../core/artifact-graph/outputs.js';
import type { ChangeMetadata } from '../core/change-metadata/index.js';

const DEFAULT_SCHEMA = 'spec-driven';
Expand Down Expand Up @@ -165,6 +167,11 @@ export async function createChange(
throw new Error(`Change '${name}' already exists at ${changeDir}`);
}

const schema = resolveSchema(schemaName, projectRoot);
const skipsSpecs = !schema.artifacts.some(artifact =>
isSpecsArtifactPath(artifact.generates)
);

// Creating a change may scaffold or complete the root itself (an
// implicit root, or a config-only/incomplete clone). Never leave a
// half-root behind that doctor immediately calls unhealthy: ensure
Expand All @@ -190,6 +197,7 @@ export async function createChange(
writeChangeMetadata(changeDir, {
schema: schemaName,
created: formatLocalDate(),
...(skipsSpecs ? { skip_specs: true } : {}),
...options.metadata,
}, projectRoot);

Expand Down
106 changes: 106 additions & 0 deletions test/commands/artifact-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,112 @@ describe('artifact-workflow CLI commands', () => {
const changeDir = path.join(changesDir, 'my-new-feature');
const stat = await fs.stat(changeDir);
expect(stat.isDirectory()).toBe(true);

const metadata = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8');
expect(metadata).not.toContain('skip_specs');
});

it('marks changes as skip_specs when their schema cannot generate specs', async () => {
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'no-specs');
await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true });
await fs.writeFile(
path.join(schemaDir, 'schema.yaml'),
`name: no-specs
version: 1
artifacts:
- id: proposal
generates: proposal.md
description: Proposal
template: proposal.md
requires: []
- id: tasks
generates: tasks.md
description: Tasks
template: tasks.md
requires: [proposal]
apply:
requires: [tasks]
tracks: tasks.md
`
);
await fs.writeFile(path.join(schemaDir, 'templates', 'proposal.md'), '# Proposal\n');
await fs.writeFile(path.join(schemaDir, 'templates', 'tasks.md'), '# Tasks\n');
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
'schema: no-specs\n'
);

const result = await runCLI(['new', 'change', 'no-spec-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);

const metadata = await fs.readFile(
path.join(changesDir, 'no-spec-change', '.openspec.yaml'),
'utf-8'
);
expect(metadata).toContain('skip_specs: true');

const validation = await runCLI(
['validate', 'no-spec-change', '--type', 'change'],
{ cwd: tempDir }
);
expect(validation.exitCode).toBe(0);
});

it('does not mark spec-producing schemas that use Windows separators', async () => {
const schemaName = 'windows-specs';
const generates = String.raw`specs\**\*.md`;
const schemaDir = path.join(tempDir, 'openspec', 'schemas', schemaName);
await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true });
await fs.writeFile(
path.join(schemaDir, 'schema.yaml'),
`name: ${schemaName}
version: 1
artifacts:
- id: specs
generates: '${generates}'
description: Specs
template: spec.md
requires: []
`
);
await fs.writeFile(path.join(schemaDir, 'templates', 'spec.md'), '# Spec\n');
await fs.writeFile(
path.join(tempDir, 'openspec', 'config.yaml'),
`schema: ${schemaName}\n`
);

const changeName = `${schemaName}-change`;
const result = await runCLI(['new', 'change', changeName], { cwd: tempDir });
expect(result.exitCode).toBe(0);

const changeDir = path.join(changesDir, changeName);
const metadata = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8');
expect(metadata).not.toContain('skip_specs');

const specDir = path.join(changeDir, 'specs', 'example');
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
`## ADDED Requirements
### Requirement: Example behavior
The system SHALL support the example behavior.

#### Scenario: Example succeeds
- **WHEN** the example runs
- **THEN** it succeeds
`
);

const status = await runCLI(['status', '--change', changeName, '--json'], {
cwd: tempDir,
});
expect(status.exitCode).toBe(0);
expect(JSON.parse(status.stdout).artifacts[0].status).toBe('done');

const validation = await runCLI(['validate', changeName, '--type', 'change'], {
cwd: tempDir,
});
expect(validation.exitCode).toBe(0);
});

it('rejects --initiative and writes no change', async () => {
Expand Down
18 changes: 17 additions & 1 deletion test/core/artifact-graph/outputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileSystemUtils } from '../../../src/utils/file-system.js';
import { artifactOutputExists, resolveArtifactOutputs } from '../../../src/core/artifact-graph/outputs.js';
import {
artifactOutputExists,
isSpecsArtifactPath,
resolveArtifactOutputs,
} from '../../../src/core/artifact-graph/outputs.js';

describe('artifact-graph/outputs', () => {
let tempDir: string;
Expand All @@ -18,6 +22,18 @@ describe('artifact-graph/outputs', () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it.each([
['specs/**/*.md', true],
['./specs/**/*.md', true],
['.//specs/**/*.md', true],
[String.raw`specs\**\*.md`, true],
[String.raw`.\specs\**\*.md`, true],
['docs/specs/**/*.md', false],
['specs-note.md', false],
])('classifies specs artifact path %s', (generates, expected) => {
expect(isSpecsArtifactPath(generates)).toBe(expected);
});

it('resolves a direct file path when it exists', () => {
const filePath = path.join(tempDir, 'proposal.md');
fs.writeFileSync(filePath, 'content');
Expand Down
Loading