From 8980c42d51db2750dc237bf5afced0faebc14391 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 14:09:06 -0500 Subject: [PATCH 1/2] fix(workflow): scaffold valid no-spec changes --- src/utils/change-utils.ts | 6 +++ test/commands/artifact-workflow.test.ts | 49 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index f73ba61bce..a77bf54f56 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -4,6 +4,7 @@ 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 type { ChangeMetadata } from '../core/change-metadata/index.js'; const DEFAULT_SCHEMA = 'spec-driven'; @@ -156,6 +157,10 @@ export async function createChange( // Validate the resolved schema validateSchemaName(schemaName, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + const skipsSpecs = !schema.artifacts.some(artifact => + artifact.generates.replace(/^(?:\.\/)+/, '').startsWith('specs/') + ); // Build the change directory path const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); @@ -190,6 +195,7 @@ export async function createChange( writeChangeMetadata(changeDir, { schema: schemaName, created: formatLocalDate(), + ...(skipsSpecs ? { skip_specs: true } : {}), ...options.metadata, }, projectRoot); diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 1d5000c7f6..b7796ba2f6 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -444,6 +444,55 @@ 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('rejects --initiative and writes no change', async () => { From 230d6ad5b75b4c5861b6f3814923b739a1ee9491 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 14:26:17 -0500 Subject: [PATCH 2/2] fix(workflow): normalize specs artifact paths --- src/core/artifact-graph/instruction-loader.ts | 13 ++--- src/core/artifact-graph/outputs.ts | 8 +++ src/utils/change-utils.ts | 10 ++-- test/commands/artifact-workflow.test.ts | 57 +++++++++++++++++++ test/core/artifact-graph/outputs.test.ts | 18 +++++- 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 3f12670016..e1363daaab 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -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 { @@ -285,12 +289,7 @@ export function loadChangeContext( const skippedArtifacts = new Set(); 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); } diff --git a/src/core/artifact-graph/outputs.ts b/src/core/artifact-graph/outputs.ts index 51f1b71f23..a4c2c54efa 100644 --- a/src/core/artifact-graph/outputs.ts +++ b/src/core/artifact-graph/outputs.ts @@ -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); diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index a77bf54f56..803405953e 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -5,6 +5,7 @@ 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'; @@ -157,10 +158,6 @@ export async function createChange( // Validate the resolved schema validateSchemaName(schemaName, projectRoot); - const schema = resolveSchema(schemaName, projectRoot); - const skipsSpecs = !schema.artifacts.some(artifact => - artifact.generates.replace(/^(?:\.\/)+/, '').startsWith('specs/') - ); // Build the change directory path const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); @@ -170,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 diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index b7796ba2f6..82d50e5feb 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -495,6 +495,63 @@ apply: 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 () => { const result = await runCLI( ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], diff --git a/test/core/artifact-graph/outputs.test.ts b/test/core/artifact-graph/outputs.test.ts index 6c6eb558de..cfe030b815 100644 --- a/test/core/artifact-graph/outputs.test.ts +++ b/test/core/artifact-graph/outputs.test.ts @@ -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; @@ -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');