diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d3d176873b..01501505f1 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -1,5 +1,5 @@ import { program } from 'commander'; -import { existsSync, readdirSync, readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from '../core/parsers/markdown-parser.js'; import { Validator } from '../core/validation/validator.js'; @@ -7,6 +7,7 @@ import type { Spec } from '../core/schemas/index.js'; import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; const SPECS_DIR = 'openspec/specs'; @@ -155,37 +156,32 @@ export function registerSpecCommand(rootProgram: typeof program) { .description('List all available specifications') .option('--json', 'Output as JSON') .option('--long', 'Show id and title with counts') - .action((options: { json?: boolean; long?: boolean }) => { + .action(async (options: { json?: boolean; long?: boolean }) => { try { if (!existsSync(SPECS_DIR)) { console.log('No items found'); return; } - const specs = readdirSync(SPECS_DIR, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => { - const specPath = join(SPECS_DIR, dirent.name, 'spec.md'); - if (existsSync(specPath)) { - try { - const spec = parseSpecFromFile(specPath, dirent.name); - - return { - id: dirent.name, - title: spec.name, - requirementCount: spec.requirements.length - }; - } catch { - return { - id: dirent.name, - title: dirent.name, - requirementCount: 0 - }; - } + const discovered = await discoverSpecFiles(SPECS_DIR); + const specs = discovered + .map(({ id, specFile }) => { + try { + const spec = parseSpecFromFile(specFile, id); + + return { + id, + title: spec.name, + requirementCount: spec.requirements.length + }; + } catch { + return { + id, + title: id, + requirementCount: 0 + }; } - return null; }) - .filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null) .sort((a, b) => a.id.localeCompare(b.id)); if (options.json) { diff --git a/src/core/archive.ts b/src/core/archive.ts index 850d4cd5ba..df37695b82 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -18,6 +18,7 @@ import { writeUpdatedSpec, type SpecUpdate, } from './specs-apply.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; function isMissingPathError(error: unknown): boolean { return ( @@ -266,22 +267,15 @@ export class ArchiveCommand { // Validate delta-formatted spec files under the change directory if present const changeSpecsDir = path.join(changeDir, 'specs'); let hasDeltaSpecs = false; - try { - const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true }); - for (const c of candidates) { - if (c.isDirectory()) { - try { - const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md'); - await fs.access(candidatePath); - const content = await fs.readFile(candidatePath, 'utf-8'); - if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) { - hasDeltaSpecs = true; - break; - } - } catch {} + for (const { specFile } of await discoverSpecFiles(changeSpecsDir)) { + try { + const content = await fs.readFile(specFile, 'utf-8'); + if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) { + hasDeltaSpecs = true; + break; } - } - } catch {} + } catch {} + } if (hasDeltaSpecs) { const deltaReport = await validator.validateChangeDeltaSpecs(changeDir); if (!deltaReport.valid) { @@ -390,7 +384,7 @@ export class ArchiveCommand { console.log('\nSpecs to update:'); for (const update of specUpdates) { const status = update.exists ? 'update' : 'create'; - const capability = path.basename(path.dirname(update.target)); + const capability = update.id; console.log(` ${capability}: ${status}`); } } @@ -440,7 +434,7 @@ export class ArchiveCommand { // late validation failure really does leave all targets unchanged. if (!skipValidation) { for (const p of prepared) { - const specName = path.basename(path.dirname(p.update.target)); + const specName = p.update.id; const report = await new Validator().validateSpecContent(specName, p.rebuilt); if (!report.valid) { if (json) { diff --git a/src/core/list.ts b/src/core/list.ts index 0c19048e21..f6b6faf2f8 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -2,9 +2,9 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { readFileSync, type Dirent } from 'fs'; -import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; interface ChangeInfo { name: string; @@ -177,9 +177,8 @@ export class ListCommand { return; } - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); - if (specDirs.length === 0) { + const discovered = await discoverSpecFiles(specsDir); + if (discovered.length === 0) { if (json) { console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); } else { @@ -190,10 +189,9 @@ export class ListCommand { type SpecInfo = { id: string; requirementCount: number }; const specs: SpecInfo[] = []; - for (const id of specDirs) { - const specPath = join(specsDir, id, 'spec.md'); + for (const { id, specFile } of discovered) { try { - const content = readFileSync(specPath, 'utf-8'); + const content = readFileSync(specFile, 'utf-8'); const parser = new MarkdownParser(content); const spec = parser.parseSpec(id); specs.push({ id, requirementCount: spec.requirements.length }); diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index 2473d16ace..b6eb420177 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -3,6 +3,7 @@ import { buildCodeFenceMask } from './requirement-text.js'; import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js'; import path from 'path'; import { promises as fs } from 'fs'; +import { discoverSpecFiles } from '../../utils/spec-discovery.js'; interface DeltaSection { operation: DeltaOperation; @@ -55,30 +56,22 @@ export class ChangeParser extends MarkdownParser { private async parseDeltaSpecs(specsDir: string): Promise { const deltas: Delta[] = []; - - try { - const specDirs = await fs.readdir(specsDir, { withFileTypes: true }); - - for (const dir of specDirs) { - if (!dir.isDirectory()) continue; - - const specName = dir.name; - const specFile = path.join(specsDir, specName, 'spec.md'); - - try { - const content = await fs.readFile(specFile, 'utf-8'); - const specDeltas = this.parseSpecDeltas(specName, content); - deltas.push(...specDeltas); - } catch (error) { - // Spec file might not exist, which is okay - continue; - } + + // Discover delta specs recursively so nested layouts like + // specs///spec.md are parsed too (#1353) + const specFiles = await discoverSpecFiles(specsDir); + + for (const { id, specFile } of specFiles) { + try { + const content = await fs.readFile(specFile, 'utf-8'); + const specDeltas = this.parseSpecDeltas(id, content); + deltas.push(...specDeltas); + } catch (error) { + // Spec file might not be readable, which is okay + continue; } - } catch (error) { - // Specs directory might not exist, which is okay - return []; } - + return deltas; } diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 3cf81222af..2af8bb881c 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -16,12 +16,15 @@ import { } from './parsers/requirement-blocks.js'; import { findMainSpecStructureIssues } from './parsers/spec-structure.js'; import { Validator } from './validation/validator.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- export interface SpecUpdate { + /** Capability id relative to the specs root, forward-slash separated (e.g. "web" or "platform/session-layout"). */ + id: string; source: string; target: string; exists: boolean; @@ -63,38 +66,29 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): const updates: SpecUpdate[] = []; const changeSpecsDir = path.join(changeDir, 'specs'); - try { - const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); - const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); - - try { - await fs.access(specFile); - - // Check if target exists - let exists = false; - try { - await fs.access(targetFile); - exists = true; - } catch { - exists = false; - } - - updates.push({ - source: specFile, - target: targetFile, - exists, - }); - } catch { - // Source spec doesn't exist, skip - } - } + // Discover delta specs recursively so nested layouts like + // specs///spec.md merge into the same relative path + // under the main specs directory (#1353) + const discovered = await discoverSpecFiles(changeSpecsDir); + + for (const { id, specFile } of discovered) { + const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md'); + + // Check if target exists + let exists = false; + try { + await fs.access(targetFile); + exists = true; + } catch { + exists = false; } - } catch { - // No specs directory in change + + updates.push({ + id, + source: specFile, + target: targetFile, + exists, + }); } return updates; @@ -114,7 +108,7 @@ export async function buildUpdatedSpec( // Parse deltas from the change spec file const plan = parseDeltaSpec(changeContent); - const specName = path.basename(path.dirname(update.target)); + const specName = update.id; // Pre-validate duplicates within sections const addedNames = new Set(); @@ -200,7 +194,7 @@ export async function buildUpdatedSpec( const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0; if (!hasAnyDelta) { throw new Error( - `Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` + + `Delta parsing found no operations for ${update.id}. ` + `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.` ); } @@ -376,7 +370,7 @@ export async function writeUpdatedSpec( if (options.silent) return; - const specName = path.basename(path.dirname(update.target)); + const specName = update.id; console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`); if (counts.added) console.log(` + ${counts.added} added`); if (counts.modified) console.log(` ~ ${counts.modified} modified`); @@ -485,7 +479,7 @@ export async function applySpecs( if (!options.skipValidation) { const validator = new Validator(); for (const p of prepared) { - const specName = path.basename(path.dirname(p.update.target)); + const specName = p.update.id; const report = await validator.validateSpecContent(specName, p.rebuilt); if (!report.valid) { const errors = report.issues @@ -502,7 +496,7 @@ export async function applySpecs( const totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; for (const p of prepared) { - const capability = path.basename(path.dirname(p.update.target)); + const capability = p.update.id; if (!options.dryRun) { // Write the updated spec diff --git a/src/core/view.ts b/src/core/view.ts index 343775bb5d..e79c1905a7 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; export class ViewCommand { async execute(targetPath: string = '.'): Promise { @@ -137,24 +138,17 @@ export class ViewCommand { } const specs: Array<{ name: string; requirementCount: number }> = []; - const entries = fs.readdirSync(specsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(specsDir, entry.name, 'spec.md'); - - if (fs.existsSync(specFile)) { - try { - const content = fs.readFileSync(specFile, 'utf-8'); - const parser = new MarkdownParser(content); - const spec = parser.parseSpec(entry.name); - const requirementCount = spec.requirements.length; - specs.push({ name: entry.name, requirementCount }); - } catch (error) { - // If spec cannot be parsed, include with 0 count - specs.push({ name: entry.name, requirementCount: 0 }); - } - } + + for (const { id, specFile } of await discoverSpecFiles(specsDir)) { + try { + const content = fs.readFileSync(specFile, 'utf-8'); + const parser = new MarkdownParser(content); + const spec = parser.parseSpec(id); + const requirementCount = spec.requirements.length; + specs.push({ name: id, requirementCount }); + } catch (error) { + // If spec cannot be parsed, include with 0 count + specs.push({ name: id, requirementCount: 0 }); } } diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 1a86c3aed9..7c3d547d25 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { discoverSpecFiles } from './spec-discovery.js'; export async function getActiveChangeIds(root: string = process.cwd()): Promise { const changesPath = path.join(root, 'openspec', 'changes'); @@ -24,23 +25,8 @@ export async function getActiveChangeIds(root: string = process.cwd()): Promise< export async function getSpecIds(root: string = process.cwd()): Promise { const specsPath = path.join(root, 'openspec', 'specs'); - const result: string[] = []; - try { - const entries = await fs.readdir(specsPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const specFile = path.join(specsPath, entry.name, 'spec.md'); - try { - await fs.access(specFile); - result.push(entry.name); - } catch { - // ignore - } - } - } catch { - // ignore - } - return result.sort(); + const discovered = await discoverSpecFiles(specsPath); + return discovered.map((spec) => spec.id); } export async function getArchivedChangeIds(root: string = process.cwd()): Promise { diff --git a/src/utils/spec-discovery.ts b/src/utils/spec-discovery.ts new file mode 100644 index 0000000000..9030fcca80 --- /dev/null +++ b/src/utils/spec-discovery.ts @@ -0,0 +1,48 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface DiscoveredSpec { + /** Spec id relative to the specs root, forward-slash separated on every platform (e.g. "web" or "platform/session-layout"). */ + id: string; + /** Path to the spec.md file (absolute if the specs root is absolute). */ + specFile: string; +} + +/** + * Recursively discover every `spec.md` under a specs root, so both the flat + * `specs//spec.md` layout and nested `specs///spec.md` layouts + * are found (#1353). A `spec.md` sitting directly in the root is ignored, + * matching the historical requirement that specs live in a capability folder. + * Dot-directories are skipped and symlinks are not followed. Results are + * sorted by id for deterministic output. + * + * A missing root (ENOENT) yields an empty list, but any other read failure + * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the + * archive/apply merge path, silently dropping an unreadable capability would + * recreate the exact data-loss class #1353 is closing. + */ +export async function discoverSpecFiles(specsRoot: string): Promise { + const results: DiscoveredSpec[] = []; + const walk = async (dir: string, segments: string[]): Promise => { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (entry.isDirectory()) { + await walk(path.join(dir, entry.name), [...segments, entry.name]); + } else if (entry.isFile() && entry.name === 'spec.md' && segments.length > 0) { + results.push({ id: segments.join('/'), specFile: path.join(dir, entry.name) }); + } + } + }; + await walk(specsRoot, []); + // Plain code-point comparison, not localeCompare: the latter follows the + // process's ICU locale, so ordering could vary by OS/CI. Code-point ordering + // guarantees the deterministic output the docstring promises. + return results.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 0d039f2026..1f2356aa00 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -190,6 +190,54 @@ Then expected result happens`; expect(updatedContent).toContain('#### Scenario: Basic test'); }); + it('should merge nested delta specs into the same relative path (#1353)', async () => { + const changeName = 'nested-spec-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const nestedSpecDir = path.join(changeDir, 'specs', 'platform', 'example-capability'); + await fs.mkdir(nestedSpecDir, { recursive: true }); + + const specContent = `# Nested Capability - Changes + +## ADDED Requirements + +### Requirement: Nested capability works +The system SHALL discover capabilities stored below namespace directories. + +#### Scenario: Validate nested delta +- **WHEN** the user validates the change +- **THEN** OpenSpec detects the nested capability`; + await fs.writeFile(path.join(nestedSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Delta merged into the same nested path under the main specs directory + const mainSpecPath = path.join( + tempDir, + 'openspec', + 'specs', + 'platform', + 'example-capability', + 'spec.md' + ); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain('### Requirement: Nested capability works'); + expect(updatedContent).toContain('#### Scenario: Validate nested delta'); + + // Change directory moved to archive with the nested delta preserved + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + const archivedDelta = path.join( + archiveDir, + archives[0], + 'specs', + 'platform', + 'example-capability', + 'spec.md' + ); + await expect(fs.access(archivedDelta)).resolves.toBeUndefined(); + }); + it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => { const changeName = 'new-spec-with-removed'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); diff --git a/test/core/parsers/change-parser.test.ts b/test/core/parsers/change-parser.test.ts index 595f138e35..0a9e1bb50f 100644 --- a/test/core/parsers/change-parser.test.ts +++ b/test/core/parsers/change-parser.test.ts @@ -49,4 +49,24 @@ describe('ChangeParser', () => { expect(change.deltas[0].requirement).toBeDefined(); }); }); + + it('parses nested delta specs with path-based capability ids (#1353)', async () => { + await withTempDir(async (dir) => { + const changeDir = dir; + const nestedSpecDir = path.join(changeDir, 'specs', 'platform', 'session-layout'); + await fs.mkdir(nestedSpecDir, { recursive: true }); + + const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- Add nested capability`; + const deltaSpec = `# Delta\n\n## ADDED Requirements\n\n### Requirement: Nested capability works\n\n#### Scenario: basic\nGiven X\nWhen Y\nThen Z`; + + await fs.writeFile(path.join(nestedSpecDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, changeDir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas.length).toBe(1); + expect(change.deltas[0].spec).toBe('platform/session-layout'); + expect(change.deltas[0].operation).toBe('ADDED'); + }); + }); }); diff --git a/test/utils/spec-discovery.test.ts b/test/utils/spec-discovery.test.ts new file mode 100644 index 0000000000..e8c18e75a5 --- /dev/null +++ b/test/utils/spec-discovery.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { promises as fs } from 'fs'; +import os from 'os'; +import { discoverSpecFiles } from '../../src/utils/spec-discovery.js'; + +async function withTempDir(run: (dir: string) => Promise) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-discovery-')); + try { + await run(dir); + } finally { + try { await fs.rm(dir, { recursive: true, force: true }); } catch {} + } +} + +async function writeSpec(root: string, ...segments: string[]) { + const dir = path.join(root, ...segments); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'spec.md'), '# Spec\n', 'utf8'); +} + +describe('discoverSpecFiles', () => { + it('discovers flat specs one level below the root', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'auth'); + await writeSpec(dir, 'payments'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['auth', 'payments']); + expect(found[0].specFile).toBe(path.join(dir, 'auth', 'spec.md')); + }); + }); + + it('discovers nested specs and returns forward-slash ids (#1353)', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'platform', 'platform-session-layout'); + await writeSpec(dir, 'mobile', 'mobile-session-layout'); + await writeSpec(dir, 'flat-capability'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual([ + 'flat-capability', + 'mobile/mobile-session-layout', + 'platform/platform-session-layout', + ]); + expect(found[2].specFile).toBe( + path.join(dir, 'platform', 'platform-session-layout', 'spec.md') + ); + }); + }); + + it('ignores a spec.md directly in the root, dot-directories, and non-spec files', async () => { + await withTempDir(async (dir) => { + await fs.writeFile(path.join(dir, 'spec.md'), '# Root spec\n', 'utf8'); + await writeSpec(dir, '.hidden', 'secret'); + await writeSpec(dir, 'real'); + await fs.writeFile(path.join(dir, 'real', 'design.md'), '# Design\n', 'utf8'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['real']); + }); + }); + + it('returns an empty list when the specs root does not exist', async () => { + await withTempDir(async (dir) => { + const found = await discoverSpecFiles(path.join(dir, 'missing')); + expect(found).toEqual([]); + }); + }); + + it('throws on a non-ENOENT read error instead of silently dropping specs', async () => { + await withTempDir(async (dir) => { + // A file where a directory is expected surfaces ENOTDIR from readdir. + const notADir = path.join(dir, 'not-a-dir'); + await fs.writeFile(notADir, 'not a directory\n', 'utf8'); + + await expect(discoverSpecFiles(notADir)).rejects.toMatchObject({ + code: 'ENOTDIR', + }); + }); + }); + + it('surfaces an unreadable nested directory rather than skipping it', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'platform', 'session-layout'); + const nested = path.join(dir, 'platform'); + await fs.chmod(nested, 0o000); + + // Root (and some CI/filesystems) ignore permission bits — skip if not enforced. + let enforced = false; + try { + await fs.readdir(nested); + } catch { + enforced = true; + } + if (!enforced) { + await fs.chmod(nested, 0o755); + return; + } + + try { + await expect(discoverSpecFiles(dir)).rejects.toMatchObject({ + code: 'EACCES', + }); + } finally { + await fs.chmod(nested, 0o755); + } + }); + }); + + it('does not follow symlinked directories', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'real'); + const target = path.join(dir, 'real'); + const link = path.join(dir, 'linked'); + try { + await fs.symlink(target, link, 'dir'); + } catch { + // Symlink creation can be unavailable (e.g. Windows without dev mode). + return; + } + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['real']); + }); + }); +});