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
5 changes: 5 additions & 0 deletions .changeset/schema-validates-apply-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Reject a schema whose `apply.requires` names an artifact that does not exist. `parseSchema` checked every artifact's `requires` but never `apply.requires`, so `openspec schema validate` passed a one-character typo there, and apply then skipped the unknown id: `apply.requires: [desgin]` turned the apply gate off and told the agent "Proceed with implementation" with only a proposal written. That is now a schema error, raised wherever the schema is loaded, exactly like an unknown artifact `requires`, and it names the bad id and the artifacts the schema declares. `openspec schema validate` also warns, without failing, when `apply.tracks` isn't exactly equal to some artifact's `generates` value, because OpenSpec finds the tracked artifact by comparing those two strings and can otherwise not tell which artifact's progress the file belongs to. That covers a typo such as `task.md` and also `tracks: tasks/main.md` against `generates: tasks/*.md`, where the glob does produce the file but the strings still differ. Apply reads that path as written either way, so schemas that track a hand-written file keep loading and working. Every built-in schema parses as before.
2 changes: 1 addition & 1 deletion docs-lab/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1410,7 +1410,7 @@ openspec schema validate spec-driven # one schema, from any source
openspec schema validate # every project-local schema
```

It verifies that `schema.yaml` exists and parses, that the structure matches the schema format, that every artifact's template file exists inside the schema's `templates/` directory, and that the dependency graph has no cycles or unknown references.
It verifies that `schema.yaml` exists and parses, that the structure matches the schema format, that every artifact's template file exists inside the schema's `templates/` directory, and that the dependency graph has no cycles or unknown references, including in `apply.requires`. An `apply.tracks` value that isn't exactly equal to some artifact's `generates` value prints a `warning:` line but does not fail validation, because OpenSpec then can't tell which artifact's progress that file belongs to.

**Options**

Expand Down
6 changes: 5 additions & 1 deletion docs-lab/reference/schemas/schema-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,16 @@ apply:
- Field types and required fields
- Relative paths
- Artifact IDs, dependencies, and cycles
- `apply.requires` IDs: each must be an artifact in the schema
- Template files

A schema with an unknown `apply.requires` ID doesn't load, so every command that uses it reports the error.

Validation warns, without failing, when `apply.tracks` isn't exactly equal to some artifact's `generates` value. OpenSpec finds the tracked artifact by comparing those two strings, so anything else leaves it unable to tell which artifact's progress the file belongs to. That includes a typo like `task.md`, and also `tracks: tasks/main.md` against `generates: tasks/*.md`, where the glob does produce the file but the strings still differ. Apply keeps reading the file either way, but `openspec list` and `openspec status` count `tasks.md` instead.

Validation doesn't catch these mistakes:

| Mistake | What happens |
|---|---|
| A field is misspelled, such as `instrution` | OpenSpec ignores it. Validation doesn't report the typo. |
| `apply.requires` names an unknown artifact ID | Validation doesn't report the unknown ID. |
| `name` differs from the schema directory | Validation passes. OpenSpec still uses the directory name for lookup. |
22 changes: 18 additions & 4 deletions src/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
isSchemaDir,
listSchemas,
} from '../core/artifact-graph/resolver.js';
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
import {
findApplyTracksWarning,
parseSchema,
SchemaValidationError,
} from '../core/artifact-graph/schema.js';
import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js';
import { resolveConfigFilePath } from '../core/project-config.js';
import { FileSystemUtils } from '../utils/file-system.js';
Expand Down Expand Up @@ -227,13 +231,20 @@ function validateSchema(
}
}

// Dependency graph validation is already done by parseSchema
// (it throws on cycles and invalid references)
// Dependency graph validation is already done by parseSchema (it throws on
// cycles, invalid references, and an unknown apply.requires id)
if (verbose) {
console.log(' Dependency graph validation passed (via parseSchema)');
}

return { valid: issues.length === 0, issues };
// An apply.tracks value that matches no generates value exactly still loads
// (apply reads the path as written), so it is a warning, not an error.
const tracksWarning = findApplyTracksWarning(schema);
if (tracksWarning) {
issues.push({ level: 'warning', path: 'apply.tracks', message: tracksWarning });
}

return { valid: !issues.some((issue) => issue.level === 'error'), issues };
}

/**
Expand Down Expand Up @@ -740,6 +751,9 @@ export function registerSchemaCommand(program: Command): void {
} else {
if (result.valid) {
console.log(`✓ Schema '${name}' is valid`);
for (const issue of result.issues) {
console.log(` ${issue.level}: ${issue.message}`);
}
} else {
console.log(`✗ Schema '${name}' has errors:`);
for (const issue of result.issues) {
Expand Down
58 changes: 58 additions & 0 deletions src/core/artifact-graph/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export function parseSchema(yamlContent: string): SchemaYaml {
// Check that all requires references are valid
validateRequiresReferences(schema.artifacts);

// Check that the apply phase names artifacts this schema declares
validateApplyReferences(schema);

// Check for cycles
validateNoCycles(schema.artifacts);

Expand Down Expand Up @@ -74,6 +77,61 @@ function validateRequiresReferences(artifacts: Artifact[]): void {
}
}

/**
* Validates that every `apply.requires` id is an artifact the schema declares.
*
* Apply skips an id that no artifact declares, so a typo silently dropped that
* artifact from the apply gate. An unknown artifact `requires` is already a
* load error, and this is the same kind of reference.
*
* `apply.tracks` is deliberately not checked here. It is a path, not an id:
* apply reads it as written, so a schema whose `tracks` value does not exactly
* match any `generates` value (a hand-written `TODO.md`, or `tasks/main.md`
* under a glob `generates: tasks/*.md` that really does produce it) works
* today, and failing the load would break every command on it.
* `openspec schema validate` reports that case as a warning instead
* (see `findApplyTracksWarning`).
*/
function validateApplyReferences(schema: SchemaYaml): void {
const apply = schema.apply;
if (!apply) return;

const validIds = schema.artifacts.map(a => a.id);
for (const req of apply.requires) {
if (!validIds.includes(req)) {
throw new SchemaValidationError(
`Invalid apply.requires reference: '${req}' does not exist (artifacts: ${validIds.join(', ')})`
);
}
}
}

/**
* Describes an `apply.tracks` value that is not exactly equal to any artifact's
* `generates` value, or returns undefined when there is nothing to report.
*
* The tracked-tasks lookups select the artifact whose `generates` string equals
* `tracks`, so this is a progress-discovery problem, not a claim that nothing
* produces the file: a glob `generates: tasks/*.md` really does generate
* `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
* Either way apply keeps working (it reads the path directly), but `openspec
* list` and `openspec status` fall back to counting the top-level `tasks.md`,
* and apply's remedy cannot name an artifact to build. A typo such as
* `task.md` is the other usual cause.
*/
export function findApplyTracksWarning(schema: SchemaYaml): string | undefined {
const tracks = schema.apply?.tracks;
if (tracks == null || schema.artifacts.some(a => a.generates === tracks)) return undefined;
return (
`apply.tracks '${tracks}' does not exactly match any artifact's generates value ` +
`(generates: ${schema.artifacts.map(a => a.generates).join(', ')}), ` +
`so OpenSpec cannot tell which artifact's progress it tracks. ` +
`Apply still reads that file as written, but list and status count tasks.md instead. ` +
`Make apply.tracks exactly equal one of those generates values, ` +
`or confirm that file is maintained outside the artifact graph.`
);
}

/**
* Validates that there are no cyclic dependencies.
* Uses DFS to detect cycles and reports the full cycle path.
Expand Down
148 changes: 148 additions & 0 deletions test/commands/schema-apply-references.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { afterAll, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { parse, stringify } from 'yaml';
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';

/**
* End to end: a project schema whose `apply` block names an artifact that does
* not exist. `schema validate` used to call it valid, and `instructions apply`
* then either skipped the unknown requirement (reporting `ready` with only a
* proposal written) or blocked forever on a file nothing generates.
*/
const temps: string[] = [];
afterAll(async () => {
await Promise.all(temps.map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
const T = 120_000;

/**
* A project on a copy of spec-driven named `myflow`, whose apply block is
* replaced. `generates` optionally overrides an artifact's `generates` value,
* keyed by artifact id, so a test can make one artifact emit a glob.
*/
async function projectWithApply(
apply: Record<string, unknown>,
generates: Record<string, string> = {}
) {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-apply-refs-'));
temps.push(base);
const home = path.join(base, 'home');
const project = path.join(base, 'project');
await fs.mkdir(home, { recursive: true });
await fs.mkdir(project, { recursive: true });
const env = {
HOME: home,
XDG_CONFIG_HOME: path.join(home, '.config'),
XDG_DATA_HOME: path.join(home, '.local', 'share'),
OPENSPEC_NO_ANIMATION: '1',
};
const cli = (args: string[]) => runCLI(args, { cwd: project, env, timeoutMs: 60_000 });
expect((await cli(['init', '--tools', 'claude'])).exitCode).toBe(0);

const schemaDir = path.join(project, 'openspec', 'schemas', 'myflow');
await fs.cp(path.join(cliProjectRoot, 'schemas', 'spec-driven'), schemaDir, { recursive: true });
const schemaFile = path.join(schemaDir, 'schema.yaml');
const schema = parse(await fs.readFile(schemaFile, 'utf-8'));
schema.name = 'myflow';
schema.apply = apply;
for (const [id, value] of Object.entries(generates)) {
const artifact = schema.artifacts.find((a: { id: string }) => a.id === id);
expect(artifact, `no artifact '${id}' to override`).toBeDefined();
artifact.generates = value;
}
await fs.writeFile(schemaFile, stringify(schema));
await fs.writeFile(path.join(project, 'openspec', 'config.yaml'), 'schema: myflow\n');

// Written by hand: `openspec new change` loads the schema, so it cannot
// create a change on a schema that no longer parses.
const changeDir = path.join(project, 'openspec', 'changes', 'c');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: myflow\n');
await fs.writeFile(
path.join(changeDir, 'proposal.md'),
'# C\n\n## Why\nWe need this for a real reason that is long enough to pass.\n\n## What Changes\n- **billing**: y\n'
);
return { cli, changeDir };
}

const output = (result: { stdout: string; stderr: string }) => result.stdout + result.stderr;

describe('schema apply references (CLI)', () => {
it('control: a correct apply.requires validates and blocks apply on the missing artifact', async () => {
const p = await projectWithApply({ requires: ['design'] });
const validated = await p.cli(['schema', 'validate', 'myflow']);
expect(validated.exitCode).toBe(0);
expect(output(validated)).toMatch(/Schema 'myflow' is valid/);

const applied = await p.cli(['instructions', 'apply', '--change', 'c', '--json']);
expect(applied.exitCode).toBe(0);
expect(JSON.parse(applied.stdout).state).toBe('blocked');
}, T);

it('schema validate rejects an apply.requires id no artifact declares', async () => {
const p = await projectWithApply({ requires: ['desgin'] });
const validated = await p.cli(['schema', 'validate', 'myflow']);
expect(validated.exitCode).not.toBe(0);
expect(output(validated)).toContain(`Invalid apply.requires reference: 'desgin' does not exist`);
expect(output(validated)).not.toMatch(/Schema 'myflow' is valid/);
}, T);

it('schema validate warns, without failing, on an apply.tracks path no artifact generates', async () => {
const p = await projectWithApply({ requires: ['tasks'], tracks: 'task.md' });
const validated = await p.cli(['schema', 'validate', 'myflow']);
expect(validated.exitCode).toBe(0);
expect(output(validated)).toMatch(/Schema 'myflow' is valid/);
expect(output(validated)).toContain(
`warning: apply.tracks 'task.md' does not exactly match any artifact's generates value`
);
expect(output(validated)).toContain(
'Make apply.tracks exactly equal one of those generates values'
);
}, T);

// A glob `generates` really does produce the tracked file, so the one warning
// must describe the string mismatch that defeats progress discovery, not
// claim that nothing generates the file.
it('schema validate describes a glob-generated tracks path as a mismatch, not as ungenerated', async () => {
const p = await projectWithApply(
{ requires: ['tasks'], tracks: 'tasks/main.md' },
{ tasks: 'tasks/*.md' }
);
const validated = await p.cli(['schema', 'validate', 'myflow', '--json']);
expect(validated.exitCode).toBe(0);
const report = JSON.parse(validated.stdout);
expect(report.valid).toBe(true);
expect(report.issues).toContainEqual({
level: 'warning',
path: 'apply.tracks',
message: expect.stringContaining(
`apply.tracks 'tasks/main.md' does not exactly match any artifact's generates value`
),
});
expect(output(validated)).not.toContain('is not generated by any artifact');
}, T);

// A hand-written tracked file no artifact generates worked before this check
// existed; the schema must keep loading so apply keeps working on it.
it('instructions apply still works on a tracked file no artifact generates', async () => {
const p = await projectWithApply({ requires: ['proposal'], tracks: 'TODO.md' });
await fs.writeFile(path.join(p.changeDir, 'TODO.md'), '- [ ] 1.1 do it\n- [x] 1.2 done\n');
const applied = await p.cli(['instructions', 'apply', '--change', 'c', '--json']);
expect(applied.exitCode).toBe(0);
const json = JSON.parse(applied.stdout);
expect(json.state).toBe('ready');
expect(json.progress).toMatchObject({ total: 2, complete: 1, remaining: 1 });
}, T);

it('instructions apply refuses the schema with a readable error instead of reporting ready', async () => {
const p = await projectWithApply({ requires: ['desgin'] });
const applied = await p.cli(['instructions', 'apply', '--change', 'c', '--json']);
expect(applied.exitCode).not.toBe(0);
expect(output(applied)).toContain(`Invalid apply.requires reference: 'desgin'`);
expect(output(applied)).not.toContain('"state": "ready"');
// A message, not a Node stack trace.
expect(output(applied)).not.toMatch(/\n\s+at .+:\d+:\d+\)?\n/);
}, T);
});
Loading
Loading