From ee64aba5f828491114fc36c06732e33ef1153247 Mon Sep 17 00:00:00 2001 From: longbowlu <8418040+longbowlu@users.noreply.github.com> Date: Thu, 21 May 2026 07:19:20 -0700 Subject: [PATCH] feat(validate): check MODIFIED/REMOVED/RENAMED-from headers against canonical base; add --accept-cross-change-base opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1112. `Validator.validateChangeDeltaSpecs` now mirrors `openspec archive`'s existing strict check: for every MODIFIED / REMOVED / RENAMED-from entry in a change delta, look up `openspec/specs//spec.md` and verify the `### Requirement: ` header exists there (whitespace- insensitive). Without this, authoring bugs only surfaced at archive time — typically days or weeks after the implementing PR shipped. Opt-in `--accept-cross-change-base` (also exposed via the `Validator({ acceptCrossChangeBase: true })` constructor option) also checks sister-pending changes at `openspec/changes//specs/ /spec.md` and passes if the header is found there. Covers the legitimate cross-change pattern (sister change in flight; you're extending its requirements before either change archives). `openspec archive` remains strict regardless of the new flag — the flag affects ONLY write-time validate semantics. Users opting in at write time commit to either archiving the sister change first OR folding this change into it before this change's own archive. Implementation notes: - New helper `Validator.checkDeltaAgainstCanonicalBase` invoked from inside the existing per-spec loop in `validateChangeDeltaSpecs`. Adds per-target ERROR-level issues with actionable guidance (which flag to use, what archive will refuse, how to switch to ADDED). - New `Validator(options: ValidatorOptions)` constructor signature. Legacy `new Validator(true)` boolean constructor preserved via type overload — backward compatible. - CLI flag `--accept-cross-change-base` added to `openspec validate`. Plumbed through `ExecuteOptions` → `validateByType` / `runBulkValidation`. Tests: - 9 new tests covering: MODIFIED-vs-missing-canonical default error, CREATE-shape (no canonical exists), MODIFIED matches canonical pass, cross-change MODIFIED default error vs flag-pass, sister-pending with non-matching name still errors (regression guard), REMOVED-not- found, RENAMED-from-not-found, legacy boolean-constructor backward compat. - Full existing suite (1511 tests) still green. --- src/cli/index.ts | 3 +- src/commands/validate.ts | 31 +++- src/core/validation/validator.ts | 180 +++++++++++++++++- test/core/validation.test.ts | 308 +++++++++++++++++++++++++++++++ 4 files changed, 510 insertions(+), 12 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index baa3e48fa..a2bc7ba52 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -307,10 +307,11 @@ program .option('--specs', 'Validate all specs') .option('--type ', 'Specify item type when ambiguous: change|spec') .option('--strict', 'Enable strict validation mode') + .option('--accept-cross-change-base', 'Allow MODIFIED/REMOVED/RENAMED-from in a change delta to reference a Requirement that lives in a sister-pending change (`openspec/changes//specs//spec.md`) rather than the canonical spec. Default: false (matches archive-time strictness). When set, the user opts into cross-change authoring; they must archive the sister change first OR fold this change into it before this change can itself be archived (archive remains strict; this flag affects ONLY write-time validate).') .option('--json', 'Output validation results as JSON') .option('--concurrency ', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)') .option('--no-interactive', 'Disable interactive prompts') - .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string }) => { + .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; acceptCrossChangeBase?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string }) => { try { const validateCommand = new ValidateCommand(); await validateCommand.execute(itemName, options); diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 9e59a4d48..20438001c 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -13,6 +13,19 @@ interface ExecuteOptions { specs?: boolean; type?: string; strict?: boolean; + /** + * Opt-in: allow `MODIFIED` / `REMOVED` / `RENAMED-from` in a delta + * spec to reference a Requirement that lives in a sister-pending + * change (`openspec/changes//specs//spec.md`) rather + * than the canonical spec (`openspec/specs//spec.md`). + * + * Default: false — validate matches archive-time semantics. + * + * Archive (`openspec archive`) remains strict regardless of this + * flag; you must archive the sister change first OR fold this + * change into it before this change's own archive succeeds. + */ + acceptCrossChangeBase?: boolean; json?: boolean; noInteractive?: boolean; interactive?: boolean; // Commander sets this to false when --no-interactive is used @@ -36,14 +49,14 @@ export class ValidateCommand { await this.runBulkValidation({ changes: !!options.all || !!options.changes, specs: !!options.all || !!options.specs, - }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) }); + }, { strict: !!options.strict, acceptCrossChangeBase: !!options.acceptCrossChangeBase, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) }); return; } // No item and no flags if (!itemName) { if (interactive) { - await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); + await this.runInteractiveSelector({ strict: !!options.strict, acceptCrossChangeBase: !!options.acceptCrossChangeBase, json: !!options.json, concurrency: options.concurrency }); return; } this.printNonInteractiveHint(); @@ -53,7 +66,7 @@ export class ValidateCommand { // Direct item validation with type detection or override const typeOverride = this.normalizeType(options.type); - await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); + await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, acceptCrossChangeBase: !!options.acceptCrossChangeBase, json: !!options.json }); } private normalizeType(value?: string): ItemType | undefined { @@ -63,7 +76,7 @@ export class ValidateCommand { return undefined; } - private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { + private async runInteractiveSelector(opts: { strict: boolean; acceptCrossChangeBase: boolean; json: boolean; concurrency?: string }): Promise { const { select } = await import('@inquirer/prompts'); const choice = await select({ message: 'What would you like to validate?', @@ -102,7 +115,7 @@ export class ValidateCommand { console.error('Or run in an interactive terminal.'); } - private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { + private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; acceptCrossChangeBase: boolean; json: boolean }): Promise { const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -127,8 +140,8 @@ export class ValidateCommand { await this.validateByType(type, itemName, opts); } - private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { - const validator = new Validator(opts.strict); + private async validateByType(type: ItemType, id: string, opts: { strict: boolean; acceptCrossChangeBase?: boolean; json: boolean }): Promise { + const validator = new Validator({ strictMode: opts.strict, acceptCrossChangeBase: !!opts.acceptCrossChangeBase }); if (type === 'change') { const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); const start = Date.now(); @@ -181,7 +194,7 @@ export class ValidateCommand { bullets.forEach(b => console.error(` ${b}`)); } - private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { + private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; acceptCrossChangeBase?: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ scope.changes ? getActiveChangeIds() : Promise.resolve([]), @@ -191,7 +204,7 @@ export class ValidateCommand { const DEFAULT_CONCURRENCY = 6; const maxSuggestions = 5; // used by nearestMatches const concurrency = normalizeConcurrency(opts.concurrency) ?? normalizeConcurrency(process.env.OPENSPEC_CONCURRENCY) ?? DEFAULT_CONCURRENCY; - const validator = new Validator(opts.strict); + const validator = new Validator({ strictMode: opts.strict, acceptCrossChangeBase: !!opts.acceptCrossChangeBase }); const queue: Array<() => Promise> = []; for (const id of changeIds) { diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 37b43a37d..3ffcdf59d 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -14,11 +14,51 @@ import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; +export interface ValidatorOptions { + /** + * Strict mode promotes structural-completeness WARNINGs to ERRORs (no + * change to MODIFIED-base cross-references — see acceptCrossChangeBase). + */ + strictMode?: boolean; + /** + * Accept a MODIFIED / REMOVED / RENAMED-from reference whose target + * Requirement is NOT present in the canonical spec (`openspec/specs/ + * /spec.md`) IF the same header appears in a sister change at + * `openspec/changes//specs//spec.md`. + * + * Default false: write-time validate matches archive-time semantics + * (the canonical spec is the only legitimate base) so authoring bugs + * surface immediately instead of at archive time. + * + * Set to true (typically via `openspec validate --accept-cross-change-base`) + * to opt into cross-change MODIFIED authoring. The user is signalling + * they will either archive the sister change first OR fold the work + * into that sister change before this change's own archive. Archive- + * time checking remains strict; this flag affects ONLY write-time + * validate. + */ + acceptCrossChangeBase?: boolean; +} + export class Validator { private strictMode: boolean; + private acceptCrossChangeBase: boolean; - constructor(strictMode: boolean = false) { - this.strictMode = strictMode; + /** + * @param strictModeOrOptions Either a boolean (legacy, equivalent to + * `{ strictMode }`) or a full `ValidatorOptions` object. The two- + * argument constructor signature is preserved for backward + * compatibility with existing callers that pass just a strictMode + * boolean. + */ + constructor(strictModeOrOptions: boolean | ValidatorOptions = false) { + if (typeof strictModeOrOptions === 'boolean') { + this.strictMode = strictModeOrOptions; + this.acceptCrossChangeBase = false; + } else { + this.strictMode = strictModeOrOptions.strictMode ?? false; + this.acceptCrossChangeBase = strictModeOrOptions.acceptCrossChangeBase ?? false; + } } async validateSpec(filePath: string): Promise { @@ -222,6 +262,29 @@ export class Validator { } } + // Canonical-base cross-reference check. + // MODIFIED / REMOVED / RENAMED-from must point at a Requirement + // that already exists in the canonical spec at + // `openspec/specs//spec.md`. Mirrors the strict check + // `specs-apply.ts` runs at archive time, but here so authoring + // bugs surface immediately rather than days later when the + // change is being archived. + // + // With `acceptCrossChangeBase` set (CLI flag + // `--accept-cross-change-base`), the check also looks in + // sister-pending changes (`openspec/changes//specs/ + // /spec.md`). The user opts into cross-change + // authoring; they must either archive the sister change first + // OR fold this change into it before this change's own archive + // succeeds (archive remains strict, no override). + await this.checkDeltaAgainstCanonicalBase({ + changeDir, + specName, + entryPath, + plan, + issues, + }); + // Cross-section conflicts (within the same spec file) for (const n of modifiedNames) { if (removedNames.has(n)) { @@ -456,4 +519,117 @@ export class Validator { const last = sections[sections.length - 1]; return `${head.join(', ')} and ${last}`; } + + /** + * Walk a spec file's content and collect normalized `### Requirement: + * ` header names. Used to build the cross-reference set for + * MODIFIED / REMOVED / RENAMED-from checks. Works on both canonical + * specs and delta specs since both use the same `### Requirement:` + * header syntax. + */ + private extractRequirementHeaderNames(content: string): Set { + const names = new Set(); + const re = /^###\s+Requirement:\s+(.+?)\s*$/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + names.add(normalizeRequirementName(m[1])); + } + return names; + } + + /** + * Cross-reference MODIFIED / REMOVED / RENAMED-from delta operations + * against the canonical spec (and optionally sister-pending changes + * when `acceptCrossChangeBase` is set). Pushes ERROR-level issues + * into the provided `issues` array. + */ + private async checkDeltaAgainstCanonicalBase(args: { + changeDir: string; + specName: string; + entryPath: string; + plan: ReturnType; + issues: ValidationIssue[]; + }): Promise { + const { changeDir, specName, entryPath, plan, issues } = args; + + // Targets that must reference an existing-in-base Requirement. + type Target = { kind: 'MODIFIED' | 'REMOVED' | 'RENAMED'; raw: string; normalized: string }; + const targets: Target[] = [ + ...plan.modified.map(b => ({ kind: 'MODIFIED' as const, raw: b.name, normalized: normalizeRequirementName(b.name) })), + ...plan.removed.map(n => ({ kind: 'REMOVED' as const, raw: n, normalized: normalizeRequirementName(n) })), + ...plan.renamed.map(r => ({ kind: 'RENAMED' as const, raw: r.from, normalized: normalizeRequirementName(r.from) })), + ]; + if (targets.length === 0) return; + + // Canonical lookup. + const openspecRoot = path.resolve(changeDir, '..', '..'); + const canonicalSpecPath = path.join(openspecRoot, 'specs', specName, 'spec.md'); + let canonicalNames: Set | null = null; + try { + const c = await fs.readFile(canonicalSpecPath, 'utf-8'); + canonicalNames = this.extractRequirementHeaderNames(c); + } catch { + canonicalNames = null; + } + + // Sister-pending lookup (lazy — only built if needed by acceptCrossChangeBase). + let sisterNames: Set | null = null; + const buildSisterNames = async (): Promise> => { + if (sisterNames !== null) return sisterNames; + const acc = new Set(); + const selfChange = path.basename(changeDir); + const changesDir = path.dirname(changeDir); + try { + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === selfChange) continue; + if (entry.name === 'archive') continue; + const p = path.join(changesDir, entry.name, 'specs', specName, 'spec.md'); + try { + const c = await fs.readFile(p, 'utf-8'); + for (const n of this.extractRequirementHeaderNames(c)) acc.add(n); + } catch { /* sister doesn't touch this capability */ } + } + } catch { /* no changes dir (unlikely from within validateChangeDeltaSpecs) */ } + sisterNames = acc; + return sisterNames; + }; + + // CREATE shape: canonical spec does not exist. + if (canonicalNames === null) { + for (const t of targets) { + if (this.acceptCrossChangeBase) { + const sis = await buildSisterNames(); + if (sis.has(t.normalized)) continue; + } + const fix = this.acceptCrossChangeBase + ? 'No canonical spec, and no sister-pending change defines this Requirement either. Either change this to ADDED, or author the sister change first.' + : `${t.kind} requires an existing canonical spec at \`openspec/specs/${specName}/spec.md\`. Either change this to ADDED, or — if a sister change is creating this capability — re-run with \`--accept-cross-change-base\` (note: archive remains strict; you must archive the sister change first OR fold this change into it before archive succeeds).`; + issues.push({ + level: 'ERROR', + path: entryPath, + message: `${t.kind} "${t.raw}": canonical spec \`openspec/specs/${specName}/spec.md\` does not exist. ${fix}`, + }); + } + return; + } + + // Canonical exists: each target must be present by exact header match. + for (const t of targets) { + if (canonicalNames.has(t.normalized)) continue; + if (this.acceptCrossChangeBase) { + const sis = await buildSisterNames(); + if (sis.has(t.normalized)) continue; + } + const fix = this.acceptCrossChangeBase + ? 'Not found in canonical AND not found in any sister-pending change. Did you mean ADDED with a new name? Or did you typo the header?' + : `Not found in canonical \`openspec/specs/${specName}/spec.md\`. Common causes: (a) the Requirement is genuinely new — use ADDED instead; (b) you typo'd the header (whitespace-insensitive match); (c) a sister change in \`openspec/changes//\` defines it and hasn't been archived — re-run with \`--accept-cross-change-base\` to opt into cross-change MODIFIED (archive remains strict, no override).`; + issues.push({ + level: 'ERROR', + path: entryPath, + message: `${t.kind} "${t.raw}": header not found in base. ${fix}`, + }); + } + } } diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index 972815e51..0b9ccdc97 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -591,4 +591,312 @@ The system MUST support mixed case delta headers. expect(report.summary.info).toBe(0); }); }); + + // MODIFIED/REMOVED/RENAMED canonical-base cross-reference check. + // Previously this check existed only inside `openspec archive`, so + // authoring bugs (MODIFIED title that doesn't appear in the base + // spec) didn't surface until archive time — often days after the + // implementation had shipped. These tests pin that the same check + // now runs inside `Validator.validateChangeDeltaSpecs`, with an + // opt-in `acceptCrossChangeBase` for cross-change MODIFIED that + // covers a known legitimate use case (sister change creating the + // capability). + describe('validateChangeDeltaSpecs canonical-base cross-reference', () => { + async function writeChange(testDir: string, name: string, specName: string, delta: string): Promise { + const changeDir = path.join(testDir, 'openspec', 'changes', name); + const specsDir = path.join(changeDir, 'specs', specName); + await fs.mkdir(specsDir, { recursive: true }); + await fs.writeFile(path.join(specsDir, 'spec.md'), delta); + return changeDir; + } + + async function writeCanonical(testDir: string, specName: string, body: string): Promise { + const dir = path.join(testDir, 'openspec', 'specs', specName); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'spec.md'), body); + } + + it('errors when MODIFIED header is absent from canonical spec', async () => { + const changeDir = await writeChange(testDir, 'session-text-drafts', 'reply-marks', ` +## MODIFIED Requirements + +### Requirement: Mark inline editor — no Save button, auto-save every keystroke + +The system SHALL render the editor without a Save button. + +#### Scenario: foo +- **WHEN** user types +- **THEN** auto-save fires +`); + await writeCanonical(testDir, 'reply-marks', `# Reply Marks + +## Purpose +The marks system. + +## Requirements + +### Requirement: Selection toolbar on agent messages + +The system SHALL show a toolbar. + +#### Scenario: foo +- **WHEN** user selects +- **THEN** toolbar appears +`); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => + i.message.includes('Mark inline editor') && i.message.includes('header not found in base') + )).toBe(true); + }); + + it('errors when canonical spec does not exist and delta uses MODIFIED', async () => { + const changeDir = await writeChange(testDir, 'composer-session-isolation', 'composer-drafts', ` +## MODIFIED Requirements + +### Requirement: No active sessionId — composer text is ephemeral within the current draft shape + +The system SHALL clear local state on transition. + +#### Scenario: foo +- **WHEN** user clicks new chat +- **THEN** composer clears +`); + // No canonical openspec/specs/composer-drafts/spec.md exists. + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => + i.message.includes('canonical spec') && i.message.includes('does not exist') + )).toBe(true); + }); + + it('passes when MODIFIED header matches canonical spec exactly', async () => { + const changeDir = await writeChange(testDir, 'my-change', 'foo', ` +## MODIFIED Requirements + +### Requirement: Existing thing + +The system SHALL do an updated thing. + +#### Scenario: foo +- **WHEN** user does X +- **THEN** Y happens +`); + await writeCanonical(testDir, 'foo', `# Foo + +## Purpose +Foo capability. + +## Requirements + +### Requirement: Existing thing + +The system SHALL do a thing. + +#### Scenario: bar +- **WHEN** something +- **THEN** something else +`); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + + it('errors by default when MODIFIED header lives in a sister-pending change', async () => { + // Sister change "session-text-drafts" CREATES the composer-drafts + // capability with `Per-session composer draft persistence`. + await writeChange(testDir, 'session-text-drafts', 'composer-drafts', ` +## ADDED Requirements + +### Requirement: Per-session composer draft persistence + +The system SHALL persist drafts per session. + +#### Scenario: foo +- **WHEN** user types +- **THEN** draft saved +`); + // My change MODIFIES the sister-pending requirement. + const changeDir = await writeChange(testDir, 'composer-session-isolation', 'composer-drafts', ` +## MODIFIED Requirements + +### Requirement: Per-session composer draft persistence + +The system SHALL persist drafts per session, and ALSO clear on Queue. + +#### Scenario: foo +- **WHEN** user queues +- **THEN** draft deleted +`); + // No canonical composer-drafts spec yet. + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => + i.message.includes('canonical spec') && i.message.includes('does not exist') + )).toBe(true); + }); + + it('accepts MODIFIED against a sister-pending change when acceptCrossChangeBase=true', async () => { + // Same setup as the prior test. + await writeChange(testDir, 'session-text-drafts', 'composer-drafts', ` +## ADDED Requirements + +### Requirement: Per-session composer draft persistence + +The system SHALL persist drafts per session. + +#### Scenario: foo +- **WHEN** user types +- **THEN** draft saved +`); + const changeDir = await writeChange(testDir, 'composer-session-isolation', 'composer-drafts', ` +## MODIFIED Requirements + +### Requirement: Per-session composer draft persistence + +The system SHALL persist drafts per session, and ALSO clear on Queue. + +#### Scenario: foo +- **WHEN** user queues +- **THEN** draft deleted +`); + + const validator = new Validator({ acceptCrossChangeBase: true }); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + + it('still errors with acceptCrossChangeBase=true when no canonical AND no sister defines the header', async () => { + // Sister change exists but for a DIFFERENT requirement name. + await writeChange(testDir, 'session-text-drafts', 'composer-drafts', ` +## ADDED Requirements + +### Requirement: Per-session composer draft persistence + +The system SHALL persist drafts. + +#### Scenario: foo +- **WHEN** user types +- **THEN** draft saved +`); + // My change MODIFIES a NAME that exists nowhere. + const changeDir = await writeChange(testDir, 'composer-session-isolation', 'composer-drafts', ` +## MODIFIED Requirements + +### Requirement: Some completely new requirement that no one defined + +The system SHALL do new things. + +#### Scenario: foo +- **WHEN** user does X +- **THEN** Y +`); + + const validator = new Validator({ acceptCrossChangeBase: true }); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => + i.message.includes('canonical spec') && i.message.includes('does not exist') + )).toBe(true); + }); + + it('errors on REMOVED header not found in canonical', async () => { + const changeDir = await writeChange(testDir, 'my-change', 'foo', ` +## REMOVED Requirements + +- ### Requirement: Nonexistent thing +`); + await writeCanonical(testDir, 'foo', `# Foo + +## Purpose +Foo. + +## Requirements + +### Requirement: Existing thing + +The system SHALL do a thing. + +#### Scenario: foo +- **WHEN** X +- **THEN** Y +`); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => + i.message.includes('REMOVED') && i.message.includes('header not found in base') + )).toBe(true); + }); + + it('errors on RENAMED-from header not found in canonical', async () => { + const changeDir = await writeChange(testDir, 'my-change', 'foo', ` +## RENAMED Requirements + +- FROM: \`### Requirement: Old name that does not exist\` + TO: \`### Requirement: New name\` +`); + await writeCanonical(testDir, 'foo', `# Foo + +## Purpose +Foo. + +## Requirements + +### Requirement: Something else + +The system SHALL do a thing. + +#### Scenario: foo +- **WHEN** X +- **THEN** Y +`); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => + i.message.includes('RENAMED') && i.message.includes('header not found in base') + )).toBe(true); + }); + + it('backward compat: legacy `new Validator(true)` constructor still works (strict, no acceptCrossChangeBase)', async () => { + const changeDir = await writeChange(testDir, 'my-change', 'foo', ` +## MODIFIED Requirements + +### Requirement: Bogus header + +The system SHALL do nothing. + +#### Scenario: foo +- **WHEN** X +- **THEN** Y +`); + // No canonical. + + const validator = new Validator(true); // legacy boolean constructor + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + expect(report.issues.some(i => i.message.includes('canonical spec') && i.message.includes('does not exist'))).toBe(true); + }); + }); });