@prodshape/integration-openspec 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * OpenSpec consumer-document enumeration: the OpenSpec implementation of the framework-neutral
3
+ * SDD integration-provider contract defined in @prodshape/core.
4
+ *
5
+ * Everything OpenSpec-specific about verification lives here — the `openspec/` layout, the
6
+ * changes/archive lifecycle split, the artifact-file conventions, and the `openspec` CLI — so
7
+ * the core package stays SDD-framework blind. The provider only enumerates the expected current
8
+ * consumer-document population; scope classification and citation verification are core
9
+ * responsibilities that operate on the enumeration.
10
+ */
11
+ import { execFile } from 'node:child_process';
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join, relative, sep } from 'node:path';
14
+ import { promisify } from 'node:util';
15
+ import fg from 'fast-glob';
16
+ import { codes } from '@prodshape/core';
17
+ import type {
18
+ ConsumerDocument,
19
+ ConsumerDocumentEnumeration,
20
+ Diagnostic,
21
+ EnumerateConsumerDocumentsOptions,
22
+ SddIntegrationProvider,
23
+ } from '@prodshape/core';
24
+ import { envWithLocalBin, isOpenSpecWorkspace } from './workspace.js';
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ /**
29
+ * The artifact files that make up an OpenSpec change, relative to the change directory.
30
+ * `specs/` is a directory containing one or more `<capability>/spec.md` files.
31
+ */
32
+ const CHANGE_ARTIFACT_FILES: Array<{ file: string; kind: string }> = [
33
+ { file: 'proposal.md', kind: 'proposal' },
34
+ { file: 'design.md', kind: 'design' },
35
+ { file: 'tasks.md', kind: 'tasks' },
36
+ ];
37
+
38
+ /** Run `openspec` with the given args at the repository root, returning stdout. */
39
+ async function runOpenSpec(
40
+ repoRoot: string,
41
+ args: string[],
42
+ ): Promise<{ stdout: string; stderr: string }> {
43
+ // `openspec` is typically a .cmd shim on Windows; `shell: true` lets execFile find it on PATH
44
+ // across platforms. The repository's node_modules/.bin is consulted first, so a
45
+ // devDependency install counts.
46
+ return execFileAsync('openspec', args, {
47
+ cwd: repoRoot,
48
+ shell: true,
49
+ env: envWithLocalBin(repoRoot),
50
+ maxBuffer: 10 * 1024 * 1024,
51
+ });
52
+ }
53
+
54
+ /** Parsed entry from `openspec list --json`. */
55
+ interface OpenSpecListEntry {
56
+ name: string;
57
+ }
58
+
59
+ /**
60
+ * Call `openspec list --json` and return the change names. Returns `null` when the CLI is
61
+ * unavailable or the output is unparseable, so the caller can fall back to filesystem scanning.
62
+ */
63
+ async function openspecListChanges(repoRoot: string): Promise<string[] | null> {
64
+ try {
65
+ const { stdout } = await runOpenSpec(repoRoot, ['list', '--json']);
66
+ const parsed = JSON.parse(stdout) as { changes?: OpenSpecListEntry[] };
67
+ if (!Array.isArray(parsed.changes)) return null;
68
+ return parsed.changes.map((c) => c.name).filter((n): n is string => typeof n === 'string');
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Check whether `openspec` is runnable (PATH or node_modules/.bin) with a parseable version. */
75
+ export async function isOpenSpecCliAvailable(repoRoot?: string): Promise<boolean> {
76
+ try {
77
+ const { stdout } = await execFileAsync('openspec', ['--version'], {
78
+ shell: true,
79
+ ...(repoRoot ? { env: envWithLocalBin(repoRoot) } : {}),
80
+ });
81
+ return /\d+\.\d+\.\d+/.test(stdout);
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** Discover all `.md` files under a directory, returning absolute paths sorted. */
88
+ async function discoverMarkdownFiles(dir: string): Promise<string[]> {
89
+ const entries = await fg('**/*.md', {
90
+ cwd: dir,
91
+ absolute: true,
92
+ dot: false,
93
+ ignore: ['**/node_modules/**'],
94
+ });
95
+ return entries.map((e) => e.split('/').join(sep)).sort();
96
+ }
97
+
98
+ /** Build a {@link ConsumerDocument} from an absolute path. */
99
+ function buildConsumerDocument(
100
+ absolutePath: string,
101
+ repoRoot: string,
102
+ opts: { change?: string; archived: boolean; artifactKind?: string },
103
+ ): ConsumerDocument {
104
+ return {
105
+ path: relative(repoRoot, absolutePath).split(sep).join('/'),
106
+ absolutePath,
107
+ change: opts.change,
108
+ archived: opts.archived,
109
+ artifactKind: opts.artifactKind,
110
+ };
111
+ }
112
+
113
+ /** Discover artifact files for a single change directory. */
114
+ async function discoverChangeDocuments(
115
+ changeDir: string,
116
+ repoRoot: string,
117
+ change: string,
118
+ archived: boolean,
119
+ ): Promise<ConsumerDocument[]> {
120
+ const docs: ConsumerDocument[] = [];
121
+
122
+ // Fixed artifact files: proposal.md, design.md, tasks.md.
123
+ for (const { file, kind } of CHANGE_ARTIFACT_FILES) {
124
+ const absolutePath = join(changeDir, file);
125
+ try {
126
+ await readFile(absolutePath);
127
+ docs.push(
128
+ buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: kind }),
129
+ );
130
+ } catch {
131
+ // File doesn't exist; skip.
132
+ }
133
+ }
134
+
135
+ // specs/ directory: one or more <capability>/spec.md files.
136
+ const specsDir = join(changeDir, 'specs');
137
+ try {
138
+ const specFiles = await discoverMarkdownFiles(specsDir);
139
+ for (const absolutePath of specFiles) {
140
+ docs.push(
141
+ buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: 'specs' }),
142
+ );
143
+ }
144
+ } catch {
145
+ // No specs/ directory; skip.
146
+ }
147
+
148
+ return docs;
149
+ }
150
+
151
+ /** Discover all `.md` files under `openspec/specs/` (the current specs, not changes). */
152
+ async function discoverSpecDocuments(
153
+ specsDir: string,
154
+ repoRoot: string,
155
+ ): Promise<ConsumerDocument[]> {
156
+ const docs: ConsumerDocument[] = [];
157
+ try {
158
+ const specFiles = await discoverMarkdownFiles(specsDir);
159
+ for (const absolutePath of specFiles) {
160
+ docs.push(
161
+ buildConsumerDocument(absolutePath, repoRoot, { archived: false, artifactKind: 'spec' }),
162
+ );
163
+ }
164
+ } catch {
165
+ // No specs/ directory; skip.
166
+ }
167
+ return docs;
168
+ }
169
+
170
+ /** Discover archived change directories under `openspec/changes/archive/`. */
171
+ async function discoverArchivedChanges(archiveDir: string): Promise<string[]> {
172
+ try {
173
+ const entries = await readdir(archiveDir, { withFileTypes: true });
174
+ return entries
175
+ .filter((e) => e.isDirectory())
176
+ .map((e) => e.name)
177
+ .sort();
178
+ } catch {
179
+ return [];
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Enumerate the consumer-document population of an OpenSpec workspace.
185
+ *
186
+ * Uses `openspec list --json` to determine the current (non-archived) changes. The OpenSpec CLI
187
+ * excludes `openspec/changes/archive/` from `list`, so the default population is current-only.
188
+ * When `includeArchived` is true, archived changes under `openspec/changes/archive/` are also
189
+ * scanned via the filesystem (the CLI cannot address archived changes by name).
190
+ *
191
+ * For each change, the artifact files are resolved by filesystem convention: `proposal.md`,
192
+ * `specs/` (one or more spec files), `design.md`, `tasks.md`. The `openspec show --json` output
193
+ * does not include artifact paths, so convention-based resolution is the stable contract.
194
+ *
195
+ * For `openspec/specs/` (the current specs, not changes), all `.md` files are discovered.
196
+ *
197
+ * If the OpenSpec CLI is not runnable, enumeration falls back to filesystem scanning of
198
+ * `openspec/changes/` (excluding `archive/` unless `includeArchived`) and reports PRODUCT069:
199
+ * the population then rests on layout convention alone, which the gate must surface rather than
200
+ * silently trust.
201
+ */
202
+ export async function enumerateOpenSpecDocuments(
203
+ repoRoot: string,
204
+ options?: EnumerateConsumerDocumentsOptions,
205
+ ): Promise<ConsumerDocumentEnumeration> {
206
+ const includeArchived = options?.includeArchived ?? false;
207
+ const root = join(repoRoot, 'openspec');
208
+ const changesDir = join(root, 'changes');
209
+ const archiveDir = join(changesDir, 'archive');
210
+ const specsDir = join(root, 'specs');
211
+
212
+ const documents: ConsumerDocument[] = [];
213
+ const diagnostics: Diagnostic[] = [];
214
+
215
+ // 1. Discover current changes. Prefer `openspec list --json`; fall back to filesystem.
216
+ let currentChangeNames: string[] | null = await openspecListChanges(repoRoot);
217
+
218
+ if (currentChangeNames === null) {
219
+ diagnostics.push({
220
+ severity: 'error',
221
+ code: codes.openSpecCliMissing,
222
+ message: `OpenSpec CLI not available; the document population was enumerated by filesystem convention instead of 'openspec list'. Install with 'npm install -g @fission-ai/openspec@1' for authoritative population discovery.`,
223
+ file: 'openspec/',
224
+ });
225
+ // CLI unavailable or unparseable: fall back to filesystem scanning of openspec/changes/,
226
+ // excluding archive/ (it's handled separately below).
227
+ try {
228
+ const entries = await readdir(changesDir, { withFileTypes: true });
229
+ currentChangeNames = entries
230
+ .filter((e) => e.isDirectory() && e.name !== 'archive')
231
+ .map((e) => e.name)
232
+ .sort();
233
+ } catch {
234
+ currentChangeNames = [];
235
+ }
236
+ }
237
+
238
+ // 2. Filter to a single change if requested.
239
+ if (options?.change) {
240
+ const wanted = options.change;
241
+ if (currentChangeNames.includes(wanted)) {
242
+ currentChangeNames = [wanted];
243
+ } else {
244
+ // The requested change isn't in the current list. Check the archive below if asked.
245
+ currentChangeNames = [];
246
+ }
247
+ }
248
+
249
+ // 3. Discover documents for each current change.
250
+ for (const name of currentChangeNames) {
251
+ const changeDir = join(changesDir, name);
252
+ documents.push(...(await discoverChangeDocuments(changeDir, repoRoot, name, false)));
253
+ }
254
+
255
+ // 4. Discover archived changes if requested.
256
+ if (includeArchived) {
257
+ const archivedNames = await discoverArchivedChanges(archiveDir);
258
+ for (const name of archivedNames) {
259
+ // If a specific change was requested and it wasn't current, check the archive.
260
+ if (options?.change && name !== options.change) continue;
261
+ const changeDir = join(archiveDir, name);
262
+ documents.push(...(await discoverChangeDocuments(changeDir, repoRoot, name, true)));
263
+ }
264
+ }
265
+
266
+ // 5. Discover current specs (openspec/specs/).
267
+ documents.push(...(await discoverSpecDocuments(specsDir, repoRoot)));
268
+
269
+ return {
270
+ documents,
271
+ root: 'openspec',
272
+ includeArchived,
273
+ diagnostics,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * The OpenSpec implementation of the SDD integration-provider contract.
279
+ * `prodshape citations verify --provider openspec` verifies the population this enumerates.
280
+ */
281
+ export const openSpecProvider: SddIntegrationProvider = {
282
+ name: 'openspec',
283
+ detectWorkspace: isOpenSpecWorkspace,
284
+ enumerateDocuments: enumerateOpenSpecDocuments,
285
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared workspace helpers for the OpenSpec integration: workspace detection and the
3
+ * child-process environment that lets a devDependency-installed OpenSpec CLI count as installed.
4
+ */
5
+ import { access } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+
8
+ /** Check whether a filesystem path exists. */
9
+ export async function pathExists(path: string): Promise<boolean> {
10
+ try {
11
+ await access(path);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ /** Check if an OpenSpec workspace exists at the given root (openspec/ directory present). */
19
+ export async function isOpenSpecWorkspace(root: string): Promise<boolean> {
20
+ return pathExists(join(root, 'openspec'));
21
+ }
22
+
23
+ /**
24
+ * Build a child-process environment whose PATH starts with the repository's node_modules/.bin,
25
+ * so an OpenSpec installed as a devDependency counts as installed. Windows spells the variable
26
+ * `Path` (and env objects lose the case-insensitive lookup once spread), so every casing is
27
+ * collapsed into a single `PATH` entry.
28
+ */
29
+ export function envWithLocalBin(root: string): NodeJS.ProcessEnv {
30
+ const env: NodeJS.ProcessEnv = { ...process.env };
31
+ const separator = process.platform === 'win32' ? ';' : ':';
32
+ let existing = '';
33
+ for (const key of Object.keys(env)) {
34
+ if (key.toUpperCase() === 'PATH') {
35
+ existing = env[key] ?? '';
36
+ delete env[key];
37
+ }
38
+ }
39
+ const localBin = join(root, 'node_modules', '.bin');
40
+ env['PATH'] = existing ? `${localBin}${separator}${existing}` : localBin;
41
+ return env;
42
+ }