@prodshape/integration-openspec 0.1.0 → 0.4.0
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.
- package/LICENSE +201 -0
- package/dist/index.d.ts +114 -10
- package/dist/index.js +477 -93
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/index.ts +383 -100
- package/src/population.ts +290 -0
- package/src/workspace.ts +42 -0
package/src/index.ts
CHANGED
|
@@ -13,13 +13,28 @@
|
|
|
13
13
|
* guidance, deduplicating identical entries so the operation is idempotent.
|
|
14
14
|
*/
|
|
15
15
|
import { execFile } from 'node:child_process';
|
|
16
|
-
import {
|
|
16
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { parse, stringify } from 'yaml';
|
|
19
|
+
import { envWithLocalBin, isOpenSpecWorkspace, pathExists } from './workspace.js';
|
|
20
|
+
|
|
21
|
+
export { isOpenSpecWorkspace } from './workspace.js';
|
|
22
|
+
export {
|
|
23
|
+
enumerateOpenSpecDocuments,
|
|
24
|
+
isOpenSpecCliAvailable,
|
|
25
|
+
openSpecProvider,
|
|
26
|
+
} from './population.js';
|
|
19
27
|
|
|
20
28
|
/** The minimum supported OpenSpec CLI version. */
|
|
21
29
|
export const MIN_SUPPORTED_OPENSPEC = '1.0.0';
|
|
22
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The npm spec used when bootstrapping OpenSpec through npx. Pinned to the supported major:
|
|
33
|
+
* MIN_SUPPORTED_OPENSPEC guards the floor, the pin guards the ceiling, so a future OpenSpec 2.x
|
|
34
|
+
* cannot silently change what `init --tools none` does for new adopters.
|
|
35
|
+
*/
|
|
36
|
+
export const OPENSPEC_NPX_SPEC = '@fission-ai/openspec@1';
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* Sentinel markers that bracket the PDaC-injected context block. They let `mergeConfig` find
|
|
25
40
|
* and replace exactly the PDaC portion without touching user-authored context lines.
|
|
@@ -32,14 +47,19 @@ export const PDAC_CONTEXT_BLOCK = `${PDAC_CONTEXT_BEGIN}
|
|
|
32
47
|
This repository uses Product Definition as Code (PDaC). The canonical product definition lives in docs/product/model (actors, journeys, use cases, business rules, domain terms, requirements) and is the single source of truth for product behaviour. OpenSpec artifacts are downstream consumers of it, never a second source of truth.
|
|
33
48
|
Consumers bind to canonical text through the PDaC Citation Contract: a citation records the target artifact id, a sha256 content digest, and an optional verification scenario anchor. Verification reports one status per citation: current, stale, tampered or unresolved.
|
|
34
49
|
To cite: run \`npx prodshape inspect <ID>\` to read the current digest, then \`npx prodshape cite --id <ID> --digest <digest>\` to emit the citation record. Place inline citations on their own line directly under the text they ground.
|
|
35
|
-
|
|
50
|
+
Finding which artifacts a change impacts is understanding work: compare the change's intent with the whole product definition first, then widen the result with \`npx prodshape impact <ID>\`. When the intent contradicts or goes beyond the definition, record that drift in the proposal with the marker \`<!-- pdac-drift ids="..." summary="..." -->\` (listed by \`npx prodshape drift\`) and let humans decide — never fix it quietly.
|
|
51
|
+
Every current OpenSpec document (proposal, specs, design, tasks and openspec/specs/) has exactly one effective scope state: bound (it carries at least one citation, or declares \`pdac-scope: cited\`), exempt (a human declared \`pdac-scope: none\` because the document has no product-semantic dependency), or unclassified (neither declared — a verification failure). Binding and exemption are human declarations: never declare \`pdac-scope: none\` merely because citations are missing, and a document declared \`pdac-scope: cited\` with zero citations fails. Verify with \`npx prodshape citations verify --provider openspec\`: it enumerates the expected document population, so zero discovered citations is a set of failures, never a pass. The scope gate applies to current documents; citations in archived changes are still checked, but their problems are reported as warnings, because archived history cannot be edited and its drift is information for the reader.
|
|
52
|
+
The accepted Product Definition changes only through this lifecycle: propose a Product Change under docs/product/changes/, validate its overlay while the baseline remains untouched, obtain human product approval, apply explicitly on a working branch, review the applied result in a pull request, and accept the resulting baseline by human merge. A Product Change is not a pull request. Apply is not acceptance, and neither apply nor merge attests implementation, verification, release or deployment. OpenSpec changes never edit docs/product/model directly. Product-definition work and implementation work have independent cadence: an OpenSpec change may share a pull request with an applied Product Change or follow an accepted definition later, but it remains a downstream implementation concern and never supplies Product Change status.
|
|
36
53
|
${PDAC_CONTEXT_END}`;
|
|
37
54
|
|
|
38
55
|
/** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */
|
|
39
56
|
export const PDAC_RULES: Record<string, string[]> = {
|
|
40
57
|
proposal: [
|
|
41
58
|
'State which PDaC artifacts this change touches, each with an inline citation emitted by `npx prodshape cite --id <ID> --digest <digest>`. Never write a citation record by hand.',
|
|
42
|
-
'
|
|
59
|
+
'Find the impacted artifacts as a separate step before writing any proposal content. This is understanding work, not lookup: read the whole product definition (docs/product/model by default), compare it with what this change wants — the backlog item behind it, if there is one — and list every artifact the change depends on, alters or contradicts. Then widen the list with the graph: run `npx prodshape impact <ID>` on each artifact you found and check its direct and indirect neighbours. Put the resulting list in the proposal, cite every impacted artifact from each document that uses it (proposal, specs, design, tasks), and name any neighbour you checked and left out, so nothing is dropped silently.',
|
|
60
|
+
'If this change\'s goals contradict the product definition, or need behaviour it does not describe, that is product-definition drift. Record it in the proposal under a \'Product definition drift\' note naming the artifacts involved, with the marker `<!-- pdac-drift ids="<ID>[, <ID>...]" summary="<one line>" -->` on its own line so `npx prodshape drift` can list it. The decision is human: the developer and the product owner agree whether to propose a Product Change or adjust this change. Never fix drift quietly, drop or weaken a citation to hide it, or write around the conflict.',
|
|
61
|
+
'If the change implements altered product behaviour, name the Product Change (CHG id) whose applied or accepted artifacts it implements. If no overlay-validated and human-approved Product Change exists yet, stop and ask for one instead of proceeding. The OpenSpec change is not the Product Change.',
|
|
62
|
+
'Every document of this change must end up bound or exempt: bind by citing the canonical text it depends on, or declare `pdac-scope: none` on a line of its own (frontmatter or `<!-- pdac-scope: none -->`) when a human judges the document has no product-semantic dependency. Never declare an exemption just because citations are missing.',
|
|
43
63
|
],
|
|
44
64
|
specs: [
|
|
45
65
|
'Every requirement derived from canonical product text MUST carry a citation to every PDaC artifact it derives from, one citation per line, placed after the requirement text and before the first scenario. A requirement often derives from more than one artifact; citing only the closest one silently hides the other derivations. Never place citations between the requirement heading and the requirement text; OpenSpec reads the first paragraph as the requirement.',
|
|
@@ -52,6 +72,7 @@ export const PDAC_RULES: Record<string, string[]> = {
|
|
|
52
72
|
],
|
|
53
73
|
tasks: [
|
|
54
74
|
'A task that changes cited behaviour must include a follow-up task to refresh the affected citations.',
|
|
75
|
+
'Never infer implementation, verification, release or deployment from Product Change status; record that evidence in the delivery workflow.',
|
|
55
76
|
],
|
|
56
77
|
};
|
|
57
78
|
|
|
@@ -62,16 +83,36 @@ export const PDAC_OPERATIONS: {
|
|
|
62
83
|
} = {
|
|
63
84
|
apply: {
|
|
64
85
|
guidance: [
|
|
65
|
-
'Before finishing, run `npx prodshape citations verify` and fix every stale, tampered or unresolved citation. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation to silence a diagnostic.',
|
|
86
|
+
'Before finishing, run `npx prodshape citations verify --provider openspec` and fix every stale, tampered or unresolved citation, every unclassified document and every bound document without citations. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation or declare `pdac-scope: none` to silence a diagnostic.',
|
|
66
87
|
],
|
|
67
88
|
},
|
|
68
89
|
archive: {
|
|
69
90
|
guidance: [
|
|
70
|
-
'Run `npx prodshape citations verify` one final time. Archive only when every citation is current.',
|
|
91
|
+
'Run `npx prodshape citations verify --provider openspec` one final time. Archive only when every document is bound or exempt and every citation is current.',
|
|
71
92
|
],
|
|
72
93
|
},
|
|
73
94
|
};
|
|
74
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The exact strings PDaC injected into openspec/config.yaml, recorded so a later update can
|
|
98
|
+
* remove entries whose wording has since changed instead of appending duplicates alongside them.
|
|
99
|
+
* The context block needs no record: its sentinels already identify it in place.
|
|
100
|
+
*/
|
|
101
|
+
export interface ManagedStrings {
|
|
102
|
+
rules: Record<string, string[]>;
|
|
103
|
+
operations: Record<string, string[]>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The managed strings the current PDaC guidance injects. */
|
|
107
|
+
export function currentManagedStrings(): ManagedStrings {
|
|
108
|
+
return {
|
|
109
|
+
rules: Object.fromEntries(Object.entries(PDAC_RULES).map(([key, list]) => [key, [...list]])),
|
|
110
|
+
operations: Object.fromEntries(
|
|
111
|
+
Object.entries(PDAC_OPERATIONS).map(([key, op]) => [key, [...op.guidance]]),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
/** Metadata recorded under .product/integrations/openspec.json. */
|
|
76
117
|
export interface OpenSpecIntegrationMeta {
|
|
77
118
|
provider: 'openspec';
|
|
@@ -79,11 +120,84 @@ export interface OpenSpecIntegrationMeta {
|
|
|
79
120
|
openspecVersion: string;
|
|
80
121
|
installedAt: string;
|
|
81
122
|
configPath: string;
|
|
123
|
+
/** Absent in metadata written before the CI example existed; `integration update` adds it. */
|
|
124
|
+
ciExamplePath?: string;
|
|
125
|
+
/** Absent in metadata written before the field existed; treated as the current guidance. */
|
|
126
|
+
managed?: ManagedStrings;
|
|
82
127
|
}
|
|
83
128
|
|
|
84
129
|
/** Repository-relative paths used by this integration. */
|
|
85
130
|
const CONFIG_RELATIVE = 'openspec/config.yaml';
|
|
86
131
|
const META_RELATIVE = '.product/integrations/openspec.json';
|
|
132
|
+
const CI_EXAMPLE_RELATIVE = '.product/integrations/openspec.ci.yml';
|
|
133
|
+
|
|
134
|
+
/** The repository-relative path of the integration metadata file, for callers that probe it. */
|
|
135
|
+
export const OPENSPEC_META_RELATIVE = META_RELATIVE;
|
|
136
|
+
|
|
137
|
+
/** The repository-relative path of the installed CI-ready example workflow. */
|
|
138
|
+
export const OPENSPEC_CI_EXAMPLE_RELATIVE = CI_EXAMPLE_RELATIVE;
|
|
139
|
+
|
|
140
|
+
/** The exact provider-aware verification command the integration stands behind. */
|
|
141
|
+
export const OPENSPEC_VERIFY_COMMAND = 'npx prodshape citations verify --provider openspec';
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render the CI-ready example workflow installed at {@link OPENSPEC_CI_EXAMPLE_RELATIVE}.
|
|
145
|
+
*
|
|
146
|
+
* The gate always blocks unresolved citations, tampered embedded projections, unclassified
|
|
147
|
+
* current documents and bound documents with zero citations. Stale citations follow the
|
|
148
|
+
* repository's configured warning policy (`validation.warnings-as-errors` in
|
|
149
|
+
* `.product/config.yaml`); the example never changes that policy, but it states explicitly what
|
|
150
|
+
* the repository's current choice means and how to escalate it, so the blocking behaviour is a
|
|
151
|
+
* deliberate decision rather than a silent default.
|
|
152
|
+
*/
|
|
153
|
+
export function renderCiExample(options: { warningsAsErrors: boolean }): string {
|
|
154
|
+
const stalePolicy = options.warningsAsErrors
|
|
155
|
+
? [
|
|
156
|
+
'# - stale citations BLOCK this gate: .product/config.yaml sets',
|
|
157
|
+
'# `validation.warnings-as-errors: true`, escalating the PRODUCT061 warning to an error.',
|
|
158
|
+
'# The integration never changes that policy; relax it by setting the key to false.',
|
|
159
|
+
]
|
|
160
|
+
: [
|
|
161
|
+
'# - stale citations DO NOT block this gate: .product/config.yaml sets',
|
|
162
|
+
'# `validation.warnings-as-errors: false`, so PRODUCT061 stays a warning. To make stale',
|
|
163
|
+
'# citations block the merge, set `validation.warnings-as-errors: true` in',
|
|
164
|
+
'# .product/config.yaml. The integration never changes that policy for you.',
|
|
165
|
+
];
|
|
166
|
+
return [
|
|
167
|
+
'# PDaC citation gate for OpenSpec consumer documents (CI-ready example).',
|
|
168
|
+
'# Managed by `prodshape integration add openspec`; copy it into your pipeline',
|
|
169
|
+
'# (e.g. .github/workflows/) and adapt the triggers.',
|
|
170
|
+
'#',
|
|
171
|
+
'# The gate runs ProductShape citation and scope verification only. It never invokes',
|
|
172
|
+
"# `openspec validate`; keep OpenSpec's own verdict a separate step so a native-spec defect",
|
|
173
|
+
'# and a grounding defect stay distinguishable.',
|
|
174
|
+
'#',
|
|
175
|
+
'# Blocking behaviour:',
|
|
176
|
+
'# - unresolved citations, tampered embedded projections, unclassified current documents',
|
|
177
|
+
'# and bound documents with zero citations always fail the gate;',
|
|
178
|
+
...stalePolicy,
|
|
179
|
+
'# - citations in archived changes are checked too, but their problems are reported as',
|
|
180
|
+
'# warnings: archived history cannot be edited, so a problem there is information,',
|
|
181
|
+
'# not something this gate can make anyone fix. Pass --include-archived to apply the',
|
|
182
|
+
'# full gate to archived documents as well.',
|
|
183
|
+
'name: pdac-citations',
|
|
184
|
+
'on:',
|
|
185
|
+
' pull_request:',
|
|
186
|
+
'jobs:',
|
|
187
|
+
' verify:',
|
|
188
|
+
' runs-on: ubuntu-latest',
|
|
189
|
+
' steps:',
|
|
190
|
+
' - uses: actions/checkout@v4',
|
|
191
|
+
' - uses: actions/setup-node@v4',
|
|
192
|
+
' with:',
|
|
193
|
+
" node-version: '22'",
|
|
194
|
+
' # The OpenSpec CLI ships as @fission-ai/openspec; population discovery runs',
|
|
195
|
+
' # `openspec list`, so the gate needs it installed.',
|
|
196
|
+
` - run: npm install -g ${OPENSPEC_NPX_SPEC}`,
|
|
197
|
+
` - run: ${OPENSPEC_VERIFY_COMMAND}`,
|
|
198
|
+
'',
|
|
199
|
+
].join('\n');
|
|
200
|
+
}
|
|
87
201
|
|
|
88
202
|
function configPath(root: string): string {
|
|
89
203
|
return join(root, ...CONFIG_RELATIVE.split('/'));
|
|
@@ -93,13 +207,8 @@ function metaPath(root: string): string {
|
|
|
93
207
|
return join(root, ...META_RELATIVE.split('/'));
|
|
94
208
|
}
|
|
95
209
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await access(path);
|
|
99
|
-
return true;
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
210
|
+
function ciExamplePath(root: string): string {
|
|
211
|
+
return join(root, ...CI_EXAMPLE_RELATIVE.split('/'));
|
|
103
212
|
}
|
|
104
213
|
|
|
105
214
|
/**
|
|
@@ -119,39 +228,107 @@ function compareVersions(a: string, b: string): number {
|
|
|
119
228
|
return aPatch - bPatch;
|
|
120
229
|
}
|
|
121
230
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
231
|
+
/** Run a command at the repository root, resolving stdout+stderr or rejecting on failure. */
|
|
232
|
+
function runAtRoot(root: string, command: string, args: string[]): Promise<string> {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
// Commands are typically .cmd shims on Windows; `shell: true` lets execFile find them on PATH
|
|
235
|
+
// across platforms without callers needing to know the extension.
|
|
236
|
+
execFile(
|
|
237
|
+
command,
|
|
238
|
+
args,
|
|
239
|
+
{ cwd: root, shell: true, env: envWithLocalBin(root) },
|
|
240
|
+
(error, stdout, stderr) => {
|
|
241
|
+
if (error) {
|
|
242
|
+
reject(new Error(`${command} ${args.join(' ')} failed: ${error.message}`));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
resolve(`${stdout}${stderr}`);
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Detect the installed OpenSpec CLI version by running `openspec --version`. When a repository
|
|
253
|
+
* root is given, its node_modules/.bin is consulted first, so a devDependency install counts.
|
|
254
|
+
*/
|
|
255
|
+
export async function detectOpenSpecVersion(
|
|
256
|
+
root?: string,
|
|
257
|
+
): Promise<{ version: string } | { error: string }> {
|
|
124
258
|
return new Promise((resolve) => {
|
|
125
259
|
// `openspec` is typically a .cmd shim on Windows; `shell: true` lets execFile find it on PATH
|
|
126
260
|
// across platforms without callers needing to know the extension.
|
|
127
|
-
execFile(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
261
|
+
execFile(
|
|
262
|
+
'openspec',
|
|
263
|
+
['--version'],
|
|
264
|
+
{ shell: true, ...(root ? { env: envWithLocalBin(root) } : {}) },
|
|
265
|
+
(error, stdout, stderr) => {
|
|
266
|
+
if (error) {
|
|
267
|
+
resolve({
|
|
268
|
+
error: `OpenSpec CLI not found on PATH (run: npm install -g ${OPENSPEC_NPX_SPEC}). ${error.message}`,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const output = `${stdout}${stderr}`.trim();
|
|
273
|
+
// `openspec --version` prints something like "1.3.0" or "openspec/1.3.0 ...".
|
|
274
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
|
|
275
|
+
if (!match?.[1]) {
|
|
276
|
+
resolve({ error: `Could not parse OpenSpec version from output: ${output}` });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
resolve({ version: match[1] });
|
|
280
|
+
},
|
|
281
|
+
);
|
|
143
282
|
});
|
|
144
283
|
}
|
|
145
284
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Create an OpenSpec workspace at the root. Uses the installed CLI when one is on PATH (or in
|
|
287
|
+
* node_modules/.bin); otherwise runs the pinned OpenSpec major through npx, which downloads
|
|
288
|
+
* nothing permanent and never mutates the global environment. `--tools none` keeps the
|
|
289
|
+
* framework's own initializer non-interactive.
|
|
290
|
+
*
|
|
291
|
+
* Returns the CLI version that ran and whether it came through npx, so callers can pass the
|
|
292
|
+
* version on to `addOpenSpecIntegration` (nothing may be on PATH afterwards) and recommend a
|
|
293
|
+
* permanent install.
|
|
294
|
+
*/
|
|
295
|
+
export async function bootstrapOpenSpecWorkspace(
|
|
296
|
+
root: string,
|
|
297
|
+
): Promise<{ version: string; viaNpx: boolean; command: string }> {
|
|
298
|
+
if (await isOpenSpecWorkspace(root)) {
|
|
299
|
+
throw new Error('An OpenSpec workspace already exists at this root.');
|
|
300
|
+
}
|
|
301
|
+
const detected = await detectOpenSpecVersion(root);
|
|
302
|
+
if (!('error' in detected)) {
|
|
303
|
+
if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const command = 'openspec init --tools none';
|
|
309
|
+
await runAtRoot(root, 'openspec', ['init', '--tools', 'none']);
|
|
310
|
+
return { version: detected.version, viaNpx: false, command };
|
|
311
|
+
}
|
|
312
|
+
const command = `npx -y ${OPENSPEC_NPX_SPEC} init --tools none`;
|
|
313
|
+
await runAtRoot(root, 'npx', ['-y', OPENSPEC_NPX_SPEC, 'init', '--tools', 'none']);
|
|
314
|
+
// A second npx call resolves from the cache the first one populated.
|
|
315
|
+
const output = await runAtRoot(root, 'npx', ['-y', OPENSPEC_NPX_SPEC, '--version']);
|
|
316
|
+
const match = output.trim().match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
|
|
317
|
+
if (!match?.[1]) {
|
|
318
|
+
throw new Error(`Could not parse OpenSpec version from npx output: ${output.trim()}`);
|
|
319
|
+
}
|
|
320
|
+
return { version: match[1], viaNpx: true, command };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Check if the OpenSpec integration is installed (its metadata file is present). */
|
|
324
|
+
export async function isOpenSpecIntegrationInstalled(root: string): Promise<boolean> {
|
|
325
|
+
return pathExists(metaPath(root));
|
|
149
326
|
}
|
|
150
327
|
|
|
151
328
|
/** Read and parse the existing openspec/config.yaml, or null if it does not exist. */
|
|
152
329
|
export async function readOpenSpecConfig(root: string): Promise<Record<string, unknown> | null> {
|
|
153
330
|
const path = configPath(root);
|
|
154
|
-
if (!(await
|
|
331
|
+
if (!(await pathExists(path))) return null;
|
|
155
332
|
const text = await readFile(path, 'utf8');
|
|
156
333
|
const parsed = parse(text);
|
|
157
334
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
@@ -180,19 +357,29 @@ function extractPdacContext(context: string | undefined): string | null {
|
|
|
180
357
|
* (idempotent); otherwise the PDaC block is appended after a separator. User-authored context
|
|
181
358
|
* lines are never lost.
|
|
182
359
|
* - `rules`: for each PDaC artifact (proposal, specs, design, tasks), PDaC rules are appended to
|
|
183
|
-
* existing rules, deduplicating identical entries.
|
|
184
|
-
*
|
|
360
|
+
* existing rules, deduplicating identical entries. Entries recorded as previously managed whose
|
|
361
|
+
* wording is no longer current are removed first, so a guidance change replaces the old string
|
|
362
|
+
* instead of accumulating next to it. User-authored entries are never removed.
|
|
363
|
+
* - `operations`: PDaC apply/archive guidance is appended to existing guidance, deduplicating,
|
|
364
|
+
* with the same previously-managed replacement.
|
|
185
365
|
* - Any other fields (githubCopilot, etc.) are preserved untouched.
|
|
186
366
|
*
|
|
187
367
|
* Returns the merged config and a list of human-readable change descriptions.
|
|
188
368
|
*/
|
|
189
|
-
export function mergeConfig(
|
|
369
|
+
export function mergeConfig(
|
|
370
|
+
existing: Record<string, unknown> | null,
|
|
371
|
+
previouslyManaged?: ManagedStrings,
|
|
372
|
+
): {
|
|
190
373
|
config: Record<string, unknown>;
|
|
191
374
|
changes: string[];
|
|
192
375
|
} {
|
|
193
376
|
const config: Record<string, unknown> = { ...(existing ?? {}) };
|
|
194
377
|
const changes: string[] = [];
|
|
195
378
|
|
|
379
|
+
// Strings PDaC injected in the past but no longer injects verbatim: remove before appending.
|
|
380
|
+
const staleManaged = (recorded: string[] | undefined, current: string[]): string[] =>
|
|
381
|
+
(recorded ?? []).filter((entry) => !current.includes(entry));
|
|
382
|
+
|
|
196
383
|
// --- context ---
|
|
197
384
|
const existingContext =
|
|
198
385
|
typeof config['context'] === 'string' ? (config['context'] as string) : undefined;
|
|
@@ -223,22 +410,45 @@ export function mergeConfig(existing: Record<string, unknown> | null): {
|
|
|
223
410
|
? { ...(config['rules'] as Record<string, unknown>) }
|
|
224
411
|
: {};
|
|
225
412
|
let rulesChanged = false;
|
|
226
|
-
|
|
413
|
+
let staleRulesRemoved = false;
|
|
414
|
+
const ruleArtifacts = new Set([
|
|
415
|
+
...Object.keys(PDAC_RULES),
|
|
416
|
+
...Object.keys(previouslyManaged?.rules ?? {}),
|
|
417
|
+
]);
|
|
418
|
+
for (const artifact of ruleArtifacts) {
|
|
419
|
+
const pdacRules = PDAC_RULES[artifact] ?? [];
|
|
420
|
+
const stale = staleManaged(previouslyManaged?.rules[artifact], pdacRules);
|
|
227
421
|
const existingList = Array.isArray(existingRules[artifact])
|
|
228
422
|
? [...(existingRules[artifact] as string[])]
|
|
229
423
|
: [];
|
|
230
|
-
const merged =
|
|
424
|
+
const merged = existingList.filter((entry) => !stale.includes(entry));
|
|
425
|
+
if (merged.length !== existingList.length) {
|
|
426
|
+
staleRulesRemoved = true;
|
|
427
|
+
rulesChanged = true;
|
|
428
|
+
}
|
|
231
429
|
for (const rule of pdacRules) {
|
|
232
430
|
if (!merged.includes(rule)) {
|
|
233
431
|
merged.push(rule);
|
|
234
432
|
rulesChanged = true;
|
|
235
433
|
}
|
|
236
434
|
}
|
|
237
|
-
|
|
435
|
+
if (merged.length === 0) {
|
|
436
|
+
delete existingRules[artifact];
|
|
437
|
+
} else {
|
|
438
|
+
existingRules[artifact] = merged;
|
|
439
|
+
}
|
|
238
440
|
}
|
|
239
441
|
if (rulesChanged) {
|
|
240
|
-
|
|
241
|
-
|
|
442
|
+
if (Object.keys(existingRules).length === 0) {
|
|
443
|
+
delete config['rules'];
|
|
444
|
+
} else {
|
|
445
|
+
config['rules'] = existingRules;
|
|
446
|
+
}
|
|
447
|
+
changes.push(
|
|
448
|
+
staleRulesRemoved
|
|
449
|
+
? 'Merged PDaC citation rules into openspec/config.yaml (rules:), replacing outdated PDaC entries.'
|
|
450
|
+
: 'Merged PDaC citation rules into openspec/config.yaml (rules:).',
|
|
451
|
+
);
|
|
242
452
|
}
|
|
243
453
|
|
|
244
454
|
// --- operations ---
|
|
@@ -249,8 +459,10 @@ export function mergeConfig(existing: Record<string, unknown> | null): {
|
|
|
249
459
|
? { ...(config['operations'] as Record<string, unknown>) }
|
|
250
460
|
: {};
|
|
251
461
|
let opsChanged = false;
|
|
462
|
+
let staleOpsRemoved = false;
|
|
252
463
|
for (const opKey of ['apply', 'archive'] as const) {
|
|
253
464
|
const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
|
|
465
|
+
const stale = staleManaged(previouslyManaged?.operations[opKey], pdacGuidance);
|
|
254
466
|
const existingOp =
|
|
255
467
|
existingOperations[opKey] &&
|
|
256
468
|
typeof existingOperations[opKey] === 'object' &&
|
|
@@ -260,7 +472,11 @@ export function mergeConfig(existing: Record<string, unknown> | null): {
|
|
|
260
472
|
const existingGuidance = Array.isArray(existingOp['guidance'])
|
|
261
473
|
? [...(existingOp['guidance'] as string[])]
|
|
262
474
|
: [];
|
|
263
|
-
const merged =
|
|
475
|
+
const merged = existingGuidance.filter((entry) => !stale.includes(entry));
|
|
476
|
+
if (merged.length !== existingGuidance.length) {
|
|
477
|
+
staleOpsRemoved = true;
|
|
478
|
+
opsChanged = true;
|
|
479
|
+
}
|
|
264
480
|
for (const guidance of pdacGuidance) {
|
|
265
481
|
if (!merged.includes(guidance)) {
|
|
266
482
|
merged.push(guidance);
|
|
@@ -272,7 +488,11 @@ export function mergeConfig(existing: Record<string, unknown> | null): {
|
|
|
272
488
|
}
|
|
273
489
|
if (opsChanged) {
|
|
274
490
|
config['operations'] = existingOperations;
|
|
275
|
-
changes.push(
|
|
491
|
+
changes.push(
|
|
492
|
+
staleOpsRemoved
|
|
493
|
+
? 'Merged PDaC operation guidance into openspec/config.yaml (operations:), replacing outdated PDaC entries.'
|
|
494
|
+
: 'Merged PDaC operation guidance into openspec/config.yaml (operations:).',
|
|
495
|
+
);
|
|
276
496
|
}
|
|
277
497
|
|
|
278
498
|
return { config, changes };
|
|
@@ -280,7 +500,7 @@ export function mergeConfig(existing: Record<string, unknown> | null): {
|
|
|
280
500
|
|
|
281
501
|
/** Serialize config back to YAML with a trailing newline. */
|
|
282
502
|
export function serializeConfig(config: Record<string, unknown>): string {
|
|
283
|
-
return `${stringify(config)}\n`;
|
|
503
|
+
return `${stringify(config).trimEnd()}\n`;
|
|
284
504
|
}
|
|
285
505
|
|
|
286
506
|
/** Read the ProductShape version from the integration-openspec package.json. */
|
|
@@ -295,26 +515,42 @@ async function productshapeVersion(): Promise<string> {
|
|
|
295
515
|
* Add the OpenSpec integration to a repository.
|
|
296
516
|
*
|
|
297
517
|
* - Detects the OpenSpec CLI version; rejects if not found or below MIN_SUPPORTED_OPENSPEC.
|
|
518
|
+
* A caller that already ran the CLI (the init bootstrap) can pass `cliVersion` to skip the
|
|
519
|
+
* probe: after an npx bootstrap nothing is on PATH, yet the version is known.
|
|
298
520
|
* - Requires an existing `openspec/` workspace (run `openspec init` first).
|
|
299
521
|
* - Merges PDaC guidance into `openspec/config.yaml`, preserving existing user configuration.
|
|
300
|
-
* - Records metadata under `.product/integrations/openspec.json
|
|
522
|
+
* - Records metadata under `.product/integrations/openspec.json`, including the exact strings
|
|
523
|
+
* injected, so a later update replaces outdated PDaC entries instead of accumulating them.
|
|
301
524
|
* - Idempotent: running twice produces the same result.
|
|
302
525
|
* - `--dry-run` reports what would change without writing.
|
|
303
526
|
* - `--force` re-merges even if the PDaC block is already present (useful after a PDaC upgrade).
|
|
304
527
|
*/
|
|
305
528
|
export async function addOpenSpecIntegration(
|
|
306
529
|
root: string,
|
|
307
|
-
options: {
|
|
530
|
+
options: {
|
|
531
|
+
force?: boolean;
|
|
532
|
+
dryRun?: boolean;
|
|
533
|
+
cliVersion?: string;
|
|
534
|
+
/**
|
|
535
|
+
* The repository's configured `validation.warnings-as-errors` policy, reflected verbatim in
|
|
536
|
+
* the installed CI example so its blocking behaviour is stated, never silently changed.
|
|
537
|
+
*/
|
|
538
|
+
warningsAsErrors?: boolean;
|
|
539
|
+
} = {},
|
|
308
540
|
): Promise<{ written: string[]; changes: string[]; meta: OpenSpecIntegrationMeta }> {
|
|
309
|
-
const { force = false, dryRun = false } = options;
|
|
541
|
+
const { force = false, dryRun = false, warningsAsErrors = false } = options;
|
|
310
542
|
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
543
|
+
let openspecVersion = options.cliVersion;
|
|
544
|
+
if (!openspecVersion) {
|
|
545
|
+
const detected = await detectOpenSpecVersion(root);
|
|
546
|
+
if ('error' in detected) {
|
|
547
|
+
throw new Error(detected.error);
|
|
548
|
+
}
|
|
549
|
+
openspecVersion = detected.version;
|
|
314
550
|
}
|
|
315
|
-
if (compareVersions(
|
|
551
|
+
if (compareVersions(openspecVersion, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
316
552
|
throw new Error(
|
|
317
|
-
`OpenSpec CLI version ${
|
|
553
|
+
`OpenSpec CLI version ${openspecVersion} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`,
|
|
318
554
|
);
|
|
319
555
|
}
|
|
320
556
|
|
|
@@ -323,29 +559,36 @@ export async function addOpenSpecIntegration(
|
|
|
323
559
|
}
|
|
324
560
|
|
|
325
561
|
const existing = await readOpenSpecConfig(root);
|
|
562
|
+
const previousMeta = await readMeta(root);
|
|
326
563
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
564
|
+
const { config, changes: configChanges } = mergeConfig(existing, previousMeta?.managed);
|
|
565
|
+
|
|
566
|
+
const ciDesired = renderCiExample({ warningsAsErrors });
|
|
567
|
+
const ciAbsolute = ciExamplePath(root);
|
|
568
|
+
const ciExisting = (await pathExists(ciAbsolute)) ? await readFile(ciAbsolute, 'utf8') : null;
|
|
569
|
+
const ciChanged = ciExisting !== ciDesired;
|
|
570
|
+
|
|
571
|
+
const changes = [...configChanges];
|
|
572
|
+
if (ciChanged) {
|
|
573
|
+
changes.push(
|
|
574
|
+
ciExisting === null
|
|
575
|
+
? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
|
|
576
|
+
: `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`,
|
|
577
|
+
);
|
|
336
578
|
}
|
|
337
579
|
|
|
338
|
-
const
|
|
580
|
+
const meta: OpenSpecIntegrationMeta = {
|
|
581
|
+
provider: 'openspec',
|
|
582
|
+
version: await productshapeVersion(),
|
|
583
|
+
openspecVersion,
|
|
584
|
+
installedAt: new Date().toISOString(),
|
|
585
|
+
configPath: CONFIG_RELATIVE,
|
|
586
|
+
ciExamplePath: CI_EXAMPLE_RELATIVE,
|
|
587
|
+
managed: currentManagedStrings(),
|
|
588
|
+
};
|
|
339
589
|
|
|
340
590
|
if (changes.length === 0 && !force) {
|
|
341
591
|
// Already up to date. Still record/refresh metadata.
|
|
342
|
-
const meta: OpenSpecIntegrationMeta = {
|
|
343
|
-
provider: 'openspec',
|
|
344
|
-
version: await productshapeVersion(),
|
|
345
|
-
openspecVersion: detected.version,
|
|
346
|
-
installedAt: new Date().toISOString(),
|
|
347
|
-
configPath: CONFIG_RELATIVE,
|
|
348
|
-
};
|
|
349
592
|
if (!dryRun) {
|
|
350
593
|
await writeMeta(root, meta);
|
|
351
594
|
}
|
|
@@ -354,33 +597,22 @@ export async function addOpenSpecIntegration(
|
|
|
354
597
|
|
|
355
598
|
const written: string[] = [];
|
|
356
599
|
if (!dryRun) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
};
|
|
600
|
+
if (configChanges.length > 0 || force) {
|
|
601
|
+
const serialized = serializeConfig(config);
|
|
602
|
+
await mkdir(dirname(configPath(root)), { recursive: true });
|
|
603
|
+
await writeFile(configPath(root), serialized, 'utf8');
|
|
604
|
+
written.push(CONFIG_RELATIVE);
|
|
605
|
+
}
|
|
606
|
+
if (ciChanged || force) {
|
|
607
|
+
await mkdir(dirname(ciAbsolute), { recursive: true });
|
|
608
|
+
await writeFile(ciAbsolute, ciDesired, 'utf8');
|
|
609
|
+
written.push(CI_EXAMPLE_RELATIVE);
|
|
610
|
+
}
|
|
369
611
|
await writeMeta(root, meta);
|
|
370
612
|
written.push(META_RELATIVE);
|
|
371
613
|
}
|
|
372
614
|
|
|
373
|
-
return {
|
|
374
|
-
written,
|
|
375
|
-
changes,
|
|
376
|
-
meta: {
|
|
377
|
-
provider: 'openspec',
|
|
378
|
-
version: await productshapeVersion(),
|
|
379
|
-
openspecVersion: detected.version,
|
|
380
|
-
installedAt: new Date().toISOString(),
|
|
381
|
-
configPath: CONFIG_RELATIVE,
|
|
382
|
-
},
|
|
383
|
-
};
|
|
615
|
+
return { written, changes, meta };
|
|
384
616
|
}
|
|
385
617
|
|
|
386
618
|
/** Write the integration metadata file. */
|
|
@@ -393,7 +625,7 @@ async function writeMeta(root: string, meta: OpenSpecIntegrationMeta): Promise<v
|
|
|
393
625
|
/** Read the integration metadata file, or null if absent. */
|
|
394
626
|
async function readMeta(root: string): Promise<OpenSpecIntegrationMeta | null> {
|
|
395
627
|
const path = metaPath(root);
|
|
396
|
-
if (!(await
|
|
628
|
+
if (!(await pathExists(path))) return null;
|
|
397
629
|
return JSON.parse(await readFile(path, 'utf8')) as OpenSpecIntegrationMeta;
|
|
398
630
|
}
|
|
399
631
|
|
|
@@ -403,11 +635,15 @@ async function readMeta(root: string): Promise<OpenSpecIntegrationMeta | null> {
|
|
|
403
635
|
*/
|
|
404
636
|
export async function updateOpenSpecIntegration(
|
|
405
637
|
root: string,
|
|
406
|
-
options: { force?: boolean; dryRun?: boolean } = {},
|
|
638
|
+
options: { force?: boolean; dryRun?: boolean; warningsAsErrors?: boolean } = {},
|
|
407
639
|
): Promise<{ written: string[]; changes: string[] }> {
|
|
408
640
|
// `add` already re-merges idempotently; `force` ensures the metadata is refreshed even when
|
|
409
641
|
// the config content did not change.
|
|
410
|
-
const result = await addOpenSpecIntegration(root, {
|
|
642
|
+
const result = await addOpenSpecIntegration(root, {
|
|
643
|
+
force: true,
|
|
644
|
+
dryRun: options.dryRun,
|
|
645
|
+
warningsAsErrors: options.warningsAsErrors,
|
|
646
|
+
});
|
|
411
647
|
return { written: result.written, changes: result.changes };
|
|
412
648
|
}
|
|
413
649
|
|
|
@@ -425,7 +661,7 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
425
661
|
const checks: { name: string; ok: boolean; detail: string }[] = [];
|
|
426
662
|
|
|
427
663
|
// 1. OpenSpec CLI installed and supported.
|
|
428
|
-
const detected = await detectOpenSpecVersion();
|
|
664
|
+
const detected = await detectOpenSpecVersion(root);
|
|
429
665
|
if ('error' in detected) {
|
|
430
666
|
checks.push({ name: 'openspec CLI', ok: false, detail: detected.error });
|
|
431
667
|
} else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
@@ -494,6 +730,21 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
494
730
|
});
|
|
495
731
|
}
|
|
496
732
|
|
|
733
|
+
// 3b. CI-ready verification example present.
|
|
734
|
+
if (await pathExists(ciExamplePath(root))) {
|
|
735
|
+
checks.push({
|
|
736
|
+
name: 'ci example',
|
|
737
|
+
ok: true,
|
|
738
|
+
detail: `CI-ready verification example present at ${CI_EXAMPLE_RELATIVE}.`,
|
|
739
|
+
});
|
|
740
|
+
} else {
|
|
741
|
+
checks.push({
|
|
742
|
+
name: 'ci example',
|
|
743
|
+
ok: false,
|
|
744
|
+
detail: `CI-ready verification example missing (${CI_EXAMPLE_RELATIVE}). Run: prodshape integration update`,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
497
748
|
// 4. PDaC rules present.
|
|
498
749
|
if (config) {
|
|
499
750
|
const rules =
|
|
@@ -539,6 +790,30 @@ export async function removeOpenSpecIntegration(
|
|
|
539
790
|
const { dryRun = false } = options;
|
|
540
791
|
const removed: string[] = [];
|
|
541
792
|
|
|
793
|
+
// Remove both what the metadata recorded as injected and what the current guidance would
|
|
794
|
+
// inject: the union covers metadata written before the record existed as well as entries left
|
|
795
|
+
// behind by an older ProductShape.
|
|
796
|
+
const recorded = (await readMeta(root))?.managed;
|
|
797
|
+
const current = currentManagedStrings();
|
|
798
|
+
const managedRules: Record<string, string[]> = {};
|
|
799
|
+
for (const key of new Set([
|
|
800
|
+
...Object.keys(current.rules),
|
|
801
|
+
...Object.keys(recorded?.rules ?? {}),
|
|
802
|
+
])) {
|
|
803
|
+
managedRules[key] = [
|
|
804
|
+
...new Set([...(current.rules[key] ?? []), ...(recorded?.rules[key] ?? [])]),
|
|
805
|
+
];
|
|
806
|
+
}
|
|
807
|
+
const managedOperations: Record<string, string[]> = {};
|
|
808
|
+
for (const key of new Set([
|
|
809
|
+
...Object.keys(current.operations),
|
|
810
|
+
...Object.keys(recorded?.operations ?? {}),
|
|
811
|
+
])) {
|
|
812
|
+
managedOperations[key] = [
|
|
813
|
+
...new Set([...(current.operations[key] ?? []), ...(recorded?.operations[key] ?? [])]),
|
|
814
|
+
];
|
|
815
|
+
}
|
|
816
|
+
|
|
542
817
|
const config = await readOpenSpecConfig(root);
|
|
543
818
|
if (config !== null) {
|
|
544
819
|
let configChanged = false;
|
|
@@ -569,7 +844,7 @@ export async function removeOpenSpecIntegration(
|
|
|
569
844
|
: null;
|
|
570
845
|
if (rules) {
|
|
571
846
|
let rulesChanged = false;
|
|
572
|
-
for (const [artifact, pdacRules] of Object.entries(
|
|
847
|
+
for (const [artifact, pdacRules] of Object.entries(managedRules)) {
|
|
573
848
|
if (!Array.isArray(rules[artifact])) continue;
|
|
574
849
|
const list = rules[artifact] as string[];
|
|
575
850
|
const filtered = list.filter((r) => !pdacRules.includes(r));
|
|
@@ -600,7 +875,7 @@ export async function removeOpenSpecIntegration(
|
|
|
600
875
|
if (operations) {
|
|
601
876
|
let opsChanged = false;
|
|
602
877
|
for (const opKey of ['apply', 'archive'] as const) {
|
|
603
|
-
const pdacGuidance =
|
|
878
|
+
const pdacGuidance = managedOperations[opKey] ?? [];
|
|
604
879
|
const op =
|
|
605
880
|
operations[opKey] &&
|
|
606
881
|
typeof operations[opKey] === 'object' &&
|
|
@@ -639,8 +914,16 @@ export async function removeOpenSpecIntegration(
|
|
|
639
914
|
}
|
|
640
915
|
}
|
|
641
916
|
|
|
917
|
+
// Remove the installed CI example.
|
|
918
|
+
if (await pathExists(ciExamplePath(root))) {
|
|
919
|
+
if (!dryRun) {
|
|
920
|
+
await rm(ciExamplePath(root), { force: true });
|
|
921
|
+
}
|
|
922
|
+
removed.push(CI_EXAMPLE_RELATIVE);
|
|
923
|
+
}
|
|
924
|
+
|
|
642
925
|
// Remove metadata file.
|
|
643
|
-
if (await
|
|
926
|
+
if (await pathExists(metaPath(root))) {
|
|
644
927
|
if (!dryRun) {
|
|
645
928
|
await rm(metaPath(root), { force: true });
|
|
646
929
|
}
|