@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/dist/index.js
CHANGED
|
@@ -1,21 +1,220 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { execFile } from "child_process";
|
|
3
|
-
import {
|
|
4
|
-
import { dirname, join } from "path";
|
|
2
|
+
import { execFile as execFile2 } from "child_process";
|
|
3
|
+
import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
|
|
4
|
+
import { dirname, join as join3 } from "path";
|
|
5
5
|
import { parse, stringify } from "yaml";
|
|
6
|
+
|
|
7
|
+
// src/workspace.ts
|
|
8
|
+
import { access } from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
async function pathExists(path) {
|
|
11
|
+
try {
|
|
12
|
+
await access(path);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function isOpenSpecWorkspace(root) {
|
|
19
|
+
return pathExists(join(root, "openspec"));
|
|
20
|
+
}
|
|
21
|
+
function envWithLocalBin(root) {
|
|
22
|
+
const env = { ...process.env };
|
|
23
|
+
const separator = process.platform === "win32" ? ";" : ":";
|
|
24
|
+
let existing = "";
|
|
25
|
+
for (const key of Object.keys(env)) {
|
|
26
|
+
if (key.toUpperCase() === "PATH") {
|
|
27
|
+
existing = env[key] ?? "";
|
|
28
|
+
delete env[key];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const localBin = join(root, "node_modules", ".bin");
|
|
32
|
+
env["PATH"] = existing ? `${localBin}${separator}${existing}` : localBin;
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/population.ts
|
|
37
|
+
import { execFile } from "child_process";
|
|
38
|
+
import { readdir, readFile } from "fs/promises";
|
|
39
|
+
import { join as join2, relative, sep } from "path";
|
|
40
|
+
import { promisify } from "util";
|
|
41
|
+
import fg from "fast-glob";
|
|
42
|
+
import { codes } from "@prodshape/core";
|
|
43
|
+
var execFileAsync = promisify(execFile);
|
|
44
|
+
var CHANGE_ARTIFACT_FILES = [
|
|
45
|
+
{ file: "proposal.md", kind: "proposal" },
|
|
46
|
+
{ file: "design.md", kind: "design" },
|
|
47
|
+
{ file: "tasks.md", kind: "tasks" }
|
|
48
|
+
];
|
|
49
|
+
async function runOpenSpec(repoRoot, args) {
|
|
50
|
+
return execFileAsync("openspec", args, {
|
|
51
|
+
cwd: repoRoot,
|
|
52
|
+
shell: true,
|
|
53
|
+
env: envWithLocalBin(repoRoot),
|
|
54
|
+
maxBuffer: 10 * 1024 * 1024
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function openspecListChanges(repoRoot) {
|
|
58
|
+
try {
|
|
59
|
+
const { stdout } = await runOpenSpec(repoRoot, ["list", "--json"]);
|
|
60
|
+
const parsed = JSON.parse(stdout);
|
|
61
|
+
if (!Array.isArray(parsed.changes)) return null;
|
|
62
|
+
return parsed.changes.map((c) => c.name).filter((n) => typeof n === "string");
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function isOpenSpecCliAvailable(repoRoot) {
|
|
68
|
+
try {
|
|
69
|
+
const { stdout } = await execFileAsync("openspec", ["--version"], {
|
|
70
|
+
shell: true,
|
|
71
|
+
...repoRoot ? { env: envWithLocalBin(repoRoot) } : {}
|
|
72
|
+
});
|
|
73
|
+
return /\d+\.\d+\.\d+/.test(stdout);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function discoverMarkdownFiles(dir) {
|
|
79
|
+
const entries = await fg("**/*.md", {
|
|
80
|
+
cwd: dir,
|
|
81
|
+
absolute: true,
|
|
82
|
+
dot: false,
|
|
83
|
+
ignore: ["**/node_modules/**"]
|
|
84
|
+
});
|
|
85
|
+
return entries.map((e) => e.split("/").join(sep)).sort();
|
|
86
|
+
}
|
|
87
|
+
function buildConsumerDocument(absolutePath, repoRoot, opts) {
|
|
88
|
+
return {
|
|
89
|
+
path: relative(repoRoot, absolutePath).split(sep).join("/"),
|
|
90
|
+
absolutePath,
|
|
91
|
+
change: opts.change,
|
|
92
|
+
archived: opts.archived,
|
|
93
|
+
artifactKind: opts.artifactKind
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function discoverChangeDocuments(changeDir, repoRoot, change, archived) {
|
|
97
|
+
const docs = [];
|
|
98
|
+
for (const { file, kind } of CHANGE_ARTIFACT_FILES) {
|
|
99
|
+
const absolutePath = join2(changeDir, file);
|
|
100
|
+
try {
|
|
101
|
+
await readFile(absolutePath);
|
|
102
|
+
docs.push(
|
|
103
|
+
buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: kind })
|
|
104
|
+
);
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const specsDir = join2(changeDir, "specs");
|
|
109
|
+
try {
|
|
110
|
+
const specFiles = await discoverMarkdownFiles(specsDir);
|
|
111
|
+
for (const absolutePath of specFiles) {
|
|
112
|
+
docs.push(
|
|
113
|
+
buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: "specs" })
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
return docs;
|
|
119
|
+
}
|
|
120
|
+
async function discoverSpecDocuments(specsDir, repoRoot) {
|
|
121
|
+
const docs = [];
|
|
122
|
+
try {
|
|
123
|
+
const specFiles = await discoverMarkdownFiles(specsDir);
|
|
124
|
+
for (const absolutePath of specFiles) {
|
|
125
|
+
docs.push(
|
|
126
|
+
buildConsumerDocument(absolutePath, repoRoot, { archived: false, artifactKind: "spec" })
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
return docs;
|
|
132
|
+
}
|
|
133
|
+
async function discoverArchivedChanges(archiveDir) {
|
|
134
|
+
try {
|
|
135
|
+
const entries = await readdir(archiveDir, { withFileTypes: true });
|
|
136
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function enumerateOpenSpecDocuments(repoRoot, options) {
|
|
142
|
+
const includeArchived = options?.includeArchived ?? false;
|
|
143
|
+
const root = join2(repoRoot, "openspec");
|
|
144
|
+
const changesDir = join2(root, "changes");
|
|
145
|
+
const archiveDir = join2(changesDir, "archive");
|
|
146
|
+
const specsDir = join2(root, "specs");
|
|
147
|
+
const documents = [];
|
|
148
|
+
const diagnostics = [];
|
|
149
|
+
let currentChangeNames = await pathExists(changesDir) ? await openspecListChanges(repoRoot) : [];
|
|
150
|
+
if (currentChangeNames === null) {
|
|
151
|
+
diagnostics.push({
|
|
152
|
+
severity: "error",
|
|
153
|
+
code: codes.openSpecCliMissing,
|
|
154
|
+
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.`,
|
|
155
|
+
file: "openspec/"
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
const entries = await readdir(changesDir, { withFileTypes: true });
|
|
159
|
+
currentChangeNames = entries.filter((e) => e.isDirectory() && e.name !== "archive").map((e) => e.name).sort();
|
|
160
|
+
} catch {
|
|
161
|
+
currentChangeNames = [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (options?.change) {
|
|
165
|
+
const wanted = options.change;
|
|
166
|
+
if (currentChangeNames.includes(wanted)) {
|
|
167
|
+
currentChangeNames = [wanted];
|
|
168
|
+
} else {
|
|
169
|
+
currentChangeNames = [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const name of currentChangeNames) {
|
|
173
|
+
const changeDir = join2(changesDir, name);
|
|
174
|
+
documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, false));
|
|
175
|
+
}
|
|
176
|
+
if (includeArchived) {
|
|
177
|
+
const archivedNames = await discoverArchivedChanges(archiveDir);
|
|
178
|
+
for (const name of archivedNames) {
|
|
179
|
+
if (options?.change && name !== options.change) continue;
|
|
180
|
+
const changeDir = join2(archiveDir, name);
|
|
181
|
+
documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, true));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
documents.push(...await discoverSpecDocuments(specsDir, repoRoot));
|
|
185
|
+
return {
|
|
186
|
+
documents,
|
|
187
|
+
root: "openspec",
|
|
188
|
+
includeArchived,
|
|
189
|
+
diagnostics
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
var openSpecProvider = {
|
|
193
|
+
name: "openspec",
|
|
194
|
+
detectWorkspace: isOpenSpecWorkspace,
|
|
195
|
+
enumerateDocuments: enumerateOpenSpecDocuments
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// src/index.ts
|
|
6
199
|
var MIN_SUPPORTED_OPENSPEC = "1.0.0";
|
|
200
|
+
var OPENSPEC_NPX_SPEC = "@fission-ai/openspec@1";
|
|
7
201
|
var PDAC_CONTEXT_BEGIN = "<!-- pdac:context begin -->";
|
|
8
202
|
var PDAC_CONTEXT_END = "<!-- pdac:context end -->";
|
|
9
203
|
var PDAC_CONTEXT_BLOCK = `${PDAC_CONTEXT_BEGIN}
|
|
10
204
|
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.
|
|
11
205
|
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.
|
|
12
206
|
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.
|
|
13
|
-
|
|
207
|
+
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 \u2014 never fix it quietly.
|
|
208
|
+
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 \u2014 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.
|
|
209
|
+
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.
|
|
14
210
|
${PDAC_CONTEXT_END}`;
|
|
15
211
|
var PDAC_RULES = {
|
|
16
212
|
proposal: [
|
|
17
213
|
"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.",
|
|
18
|
-
"
|
|
214
|
+
"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 \u2014 the backlog item behind it, if there is one \u2014 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.",
|
|
215
|
+
'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.',
|
|
216
|
+
"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.",
|
|
217
|
+
"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."
|
|
19
218
|
],
|
|
20
219
|
specs: [
|
|
21
220
|
"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.",
|
|
@@ -27,36 +226,90 @@ var PDAC_RULES = {
|
|
|
27
226
|
"When a design decision depends on canonical product text, cite the artifact it depends on."
|
|
28
227
|
],
|
|
29
228
|
tasks: [
|
|
30
|
-
"A task that changes cited behaviour must include a follow-up task to refresh the affected citations."
|
|
229
|
+
"A task that changes cited behaviour must include a follow-up task to refresh the affected citations.",
|
|
230
|
+
"Never infer implementation, verification, release or deployment from Product Change status; record that evidence in the delivery workflow."
|
|
31
231
|
]
|
|
32
232
|
};
|
|
33
233
|
var PDAC_OPERATIONS = {
|
|
34
234
|
apply: {
|
|
35
235
|
guidance: [
|
|
36
|
-
"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."
|
|
236
|
+
"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."
|
|
37
237
|
]
|
|
38
238
|
},
|
|
39
239
|
archive: {
|
|
40
240
|
guidance: [
|
|
41
|
-
"Run `npx prodshape citations verify` one final time. Archive only when every citation is current."
|
|
241
|
+
"Run `npx prodshape citations verify --provider openspec` one final time. Archive only when every document is bound or exempt and every citation is current."
|
|
42
242
|
]
|
|
43
243
|
}
|
|
44
244
|
};
|
|
245
|
+
function currentManagedStrings() {
|
|
246
|
+
return {
|
|
247
|
+
rules: Object.fromEntries(Object.entries(PDAC_RULES).map(([key, list]) => [key, [...list]])),
|
|
248
|
+
operations: Object.fromEntries(
|
|
249
|
+
Object.entries(PDAC_OPERATIONS).map(([key, op]) => [key, [...op.guidance]])
|
|
250
|
+
)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
45
253
|
var CONFIG_RELATIVE = "openspec/config.yaml";
|
|
46
254
|
var META_RELATIVE = ".product/integrations/openspec.json";
|
|
255
|
+
var CI_EXAMPLE_RELATIVE = ".product/integrations/openspec.ci.yml";
|
|
256
|
+
var OPENSPEC_META_RELATIVE = META_RELATIVE;
|
|
257
|
+
var OPENSPEC_CI_EXAMPLE_RELATIVE = CI_EXAMPLE_RELATIVE;
|
|
258
|
+
var OPENSPEC_VERIFY_COMMAND = "npx prodshape citations verify --provider openspec";
|
|
259
|
+
function renderCiExample(options) {
|
|
260
|
+
const stalePolicy = options.warningsAsErrors ? [
|
|
261
|
+
"# - stale citations BLOCK this gate: .product/config.yaml sets",
|
|
262
|
+
"# `validation.warnings-as-errors: true`, escalating the PRODUCT061 warning to an error.",
|
|
263
|
+
"# The integration never changes that policy; relax it by setting the key to false."
|
|
264
|
+
] : [
|
|
265
|
+
"# - stale citations DO NOT block this gate: .product/config.yaml sets",
|
|
266
|
+
"# `validation.warnings-as-errors: false`, so PRODUCT061 stays a warning. To make stale",
|
|
267
|
+
"# citations block the merge, set `validation.warnings-as-errors: true` in",
|
|
268
|
+
"# .product/config.yaml. The integration never changes that policy for you."
|
|
269
|
+
];
|
|
270
|
+
return [
|
|
271
|
+
"# PDaC citation gate for OpenSpec consumer documents (CI-ready example).",
|
|
272
|
+
"# Managed by `prodshape integration add openspec`; copy it into your pipeline",
|
|
273
|
+
"# (e.g. .github/workflows/) and adapt the triggers.",
|
|
274
|
+
"#",
|
|
275
|
+
"# The gate runs ProductShape citation and scope verification only. It never invokes",
|
|
276
|
+
"# `openspec validate`; keep OpenSpec's own verdict a separate step so a native-spec defect",
|
|
277
|
+
"# and a grounding defect stay distinguishable.",
|
|
278
|
+
"#",
|
|
279
|
+
"# Blocking behaviour:",
|
|
280
|
+
"# - unresolved citations, tampered embedded projections, unclassified current documents",
|
|
281
|
+
"# and bound documents with zero citations always fail the gate;",
|
|
282
|
+
...stalePolicy,
|
|
283
|
+
"# - citations in archived changes are checked too, but their problems are reported as",
|
|
284
|
+
"# warnings: archived history cannot be edited, so a problem there is information,",
|
|
285
|
+
"# not something this gate can make anyone fix. Pass --include-archived to apply the",
|
|
286
|
+
"# full gate to archived documents as well.",
|
|
287
|
+
"name: pdac-citations",
|
|
288
|
+
"on:",
|
|
289
|
+
" pull_request:",
|
|
290
|
+
"jobs:",
|
|
291
|
+
" verify:",
|
|
292
|
+
" runs-on: ubuntu-latest",
|
|
293
|
+
" steps:",
|
|
294
|
+
" - uses: actions/checkout@v4",
|
|
295
|
+
" - uses: actions/setup-node@v4",
|
|
296
|
+
" with:",
|
|
297
|
+
" node-version: '22'",
|
|
298
|
+
" # The OpenSpec CLI ships as @fission-ai/openspec; population discovery runs",
|
|
299
|
+
" # `openspec list`, so the gate needs it installed.",
|
|
300
|
+
` - run: npm install -g ${OPENSPEC_NPX_SPEC}`,
|
|
301
|
+
` - run: ${OPENSPEC_VERIFY_COMMAND}`,
|
|
302
|
+
""
|
|
303
|
+
].join("\n");
|
|
304
|
+
}
|
|
47
305
|
function configPath(root) {
|
|
48
|
-
return
|
|
306
|
+
return join3(root, ...CONFIG_RELATIVE.split("/"));
|
|
49
307
|
}
|
|
50
308
|
function metaPath(root) {
|
|
51
|
-
return
|
|
309
|
+
return join3(root, ...META_RELATIVE.split("/"));
|
|
52
310
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
await access(path);
|
|
56
|
-
return true;
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
311
|
+
function ciExamplePath(root) {
|
|
312
|
+
return join3(root, ...CI_EXAMPLE_RELATIVE.split("/"));
|
|
60
313
|
}
|
|
61
314
|
function compareVersions(a, b) {
|
|
62
315
|
const parseParts = (v) => {
|
|
@@ -70,32 +323,77 @@ function compareVersions(a, b) {
|
|
|
70
323
|
if (aMin !== bMin) return aMin - bMin;
|
|
71
324
|
return aPatch - bPatch;
|
|
72
325
|
}
|
|
73
|
-
|
|
74
|
-
return new Promise((resolve) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
326
|
+
function runAtRoot(root, command, args) {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
execFile2(
|
|
329
|
+
command,
|
|
330
|
+
args,
|
|
331
|
+
{ cwd: root, shell: true, env: envWithLocalBin(root) },
|
|
332
|
+
(error, stdout, stderr) => {
|
|
333
|
+
if (error) {
|
|
334
|
+
reject(new Error(`${command} ${args.join(" ")} failed: ${error.message}`));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
resolve(`${stdout}${stderr}`);
|
|
81
338
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
async function detectOpenSpecVersion(root) {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
execFile2(
|
|
345
|
+
"openspec",
|
|
346
|
+
["--version"],
|
|
347
|
+
{ shell: true, ...root ? { env: envWithLocalBin(root) } : {} },
|
|
348
|
+
(error, stdout, stderr) => {
|
|
349
|
+
if (error) {
|
|
350
|
+
resolve({
|
|
351
|
+
error: `OpenSpec CLI not found on PATH (run: npm install -g ${OPENSPEC_NPX_SPEC}). ${error.message}`
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const output = `${stdout}${stderr}`.trim();
|
|
356
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
|
|
357
|
+
if (!match?.[1]) {
|
|
358
|
+
resolve({ error: `Could not parse OpenSpec version from output: ${output}` });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
resolve({ version: match[1] });
|
|
87
362
|
}
|
|
88
|
-
|
|
89
|
-
});
|
|
363
|
+
);
|
|
90
364
|
});
|
|
91
365
|
}
|
|
92
|
-
async function
|
|
93
|
-
|
|
366
|
+
async function bootstrapOpenSpecWorkspace(root) {
|
|
367
|
+
if (await isOpenSpecWorkspace(root)) {
|
|
368
|
+
throw new Error("An OpenSpec workspace already exists at this root.");
|
|
369
|
+
}
|
|
370
|
+
const detected = await detectOpenSpecVersion(root);
|
|
371
|
+
if (!("error" in detected)) {
|
|
372
|
+
if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const command2 = "openspec init --tools none";
|
|
378
|
+
await runAtRoot(root, "openspec", ["init", "--tools", "none"]);
|
|
379
|
+
return { version: detected.version, viaNpx: false, command: command2 };
|
|
380
|
+
}
|
|
381
|
+
const command = `npx -y ${OPENSPEC_NPX_SPEC} init --tools none`;
|
|
382
|
+
await runAtRoot(root, "npx", ["-y", OPENSPEC_NPX_SPEC, "init", "--tools", "none"]);
|
|
383
|
+
const output = await runAtRoot(root, "npx", ["-y", OPENSPEC_NPX_SPEC, "--version"]);
|
|
384
|
+
const match = output.trim().match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
|
|
385
|
+
if (!match?.[1]) {
|
|
386
|
+
throw new Error(`Could not parse OpenSpec version from npx output: ${output.trim()}`);
|
|
387
|
+
}
|
|
388
|
+
return { version: match[1], viaNpx: true, command };
|
|
389
|
+
}
|
|
390
|
+
async function isOpenSpecIntegrationInstalled(root) {
|
|
391
|
+
return pathExists(metaPath(root));
|
|
94
392
|
}
|
|
95
393
|
async function readOpenSpecConfig(root) {
|
|
96
394
|
const path = configPath(root);
|
|
97
|
-
if (!await
|
|
98
|
-
const text = await
|
|
395
|
+
if (!await pathExists(path)) return null;
|
|
396
|
+
const text = await readFile2(path, "utf8");
|
|
99
397
|
const parsed = parse(text);
|
|
100
398
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
101
399
|
return {};
|
|
@@ -109,9 +407,10 @@ function extractPdacContext(context) {
|
|
|
109
407
|
if (begin === -1 || end === -1 || end < begin) return null;
|
|
110
408
|
return context.slice(begin, end + PDAC_CONTEXT_END.length);
|
|
111
409
|
}
|
|
112
|
-
function mergeConfig(existing) {
|
|
410
|
+
function mergeConfig(existing, previouslyManaged) {
|
|
113
411
|
const config = { ...existing ?? {} };
|
|
114
412
|
const changes = [];
|
|
413
|
+
const staleManaged = (recorded, current) => (recorded ?? []).filter((entry) => !current.includes(entry));
|
|
115
414
|
const existingContext = typeof config["context"] === "string" ? config["context"] : void 0;
|
|
116
415
|
const existingPdacBlock = extractPdacContext(existingContext);
|
|
117
416
|
if (existingPdacBlock !== null) {
|
|
@@ -132,28 +431,55 @@ function mergeConfig(existing) {
|
|
|
132
431
|
}
|
|
133
432
|
const existingRules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? { ...config["rules"] } : {};
|
|
134
433
|
let rulesChanged = false;
|
|
135
|
-
|
|
434
|
+
let staleRulesRemoved = false;
|
|
435
|
+
const ruleArtifacts = /* @__PURE__ */ new Set([
|
|
436
|
+
...Object.keys(PDAC_RULES),
|
|
437
|
+
...Object.keys(previouslyManaged?.rules ?? {})
|
|
438
|
+
]);
|
|
439
|
+
for (const artifact of ruleArtifacts) {
|
|
440
|
+
const pdacRules = PDAC_RULES[artifact] ?? [];
|
|
441
|
+
const stale = staleManaged(previouslyManaged?.rules[artifact], pdacRules);
|
|
136
442
|
const existingList = Array.isArray(existingRules[artifact]) ? [...existingRules[artifact]] : [];
|
|
137
|
-
const merged =
|
|
443
|
+
const merged = existingList.filter((entry) => !stale.includes(entry));
|
|
444
|
+
if (merged.length !== existingList.length) {
|
|
445
|
+
staleRulesRemoved = true;
|
|
446
|
+
rulesChanged = true;
|
|
447
|
+
}
|
|
138
448
|
for (const rule of pdacRules) {
|
|
139
449
|
if (!merged.includes(rule)) {
|
|
140
450
|
merged.push(rule);
|
|
141
451
|
rulesChanged = true;
|
|
142
452
|
}
|
|
143
453
|
}
|
|
144
|
-
|
|
454
|
+
if (merged.length === 0) {
|
|
455
|
+
delete existingRules[artifact];
|
|
456
|
+
} else {
|
|
457
|
+
existingRules[artifact] = merged;
|
|
458
|
+
}
|
|
145
459
|
}
|
|
146
460
|
if (rulesChanged) {
|
|
147
|
-
|
|
148
|
-
|
|
461
|
+
if (Object.keys(existingRules).length === 0) {
|
|
462
|
+
delete config["rules"];
|
|
463
|
+
} else {
|
|
464
|
+
config["rules"] = existingRules;
|
|
465
|
+
}
|
|
466
|
+
changes.push(
|
|
467
|
+
staleRulesRemoved ? "Merged PDaC citation rules into openspec/config.yaml (rules:), replacing outdated PDaC entries." : "Merged PDaC citation rules into openspec/config.yaml (rules:)."
|
|
468
|
+
);
|
|
149
469
|
}
|
|
150
470
|
const existingOperations = config["operations"] && typeof config["operations"] === "object" && !Array.isArray(config["operations"]) ? { ...config["operations"] } : {};
|
|
151
471
|
let opsChanged = false;
|
|
472
|
+
let staleOpsRemoved = false;
|
|
152
473
|
for (const opKey of ["apply", "archive"]) {
|
|
153
474
|
const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
|
|
475
|
+
const stale = staleManaged(previouslyManaged?.operations[opKey], pdacGuidance);
|
|
154
476
|
const existingOp = existingOperations[opKey] && typeof existingOperations[opKey] === "object" && !Array.isArray(existingOperations[opKey]) ? { ...existingOperations[opKey] } : {};
|
|
155
477
|
const existingGuidance = Array.isArray(existingOp["guidance"]) ? [...existingOp["guidance"]] : [];
|
|
156
|
-
const merged =
|
|
478
|
+
const merged = existingGuidance.filter((entry) => !stale.includes(entry));
|
|
479
|
+
if (merged.length !== existingGuidance.length) {
|
|
480
|
+
staleOpsRemoved = true;
|
|
481
|
+
opsChanged = true;
|
|
482
|
+
}
|
|
157
483
|
for (const guidance of pdacGuidance) {
|
|
158
484
|
if (!merged.includes(guidance)) {
|
|
159
485
|
merged.push(guidance);
|
|
@@ -165,49 +491,63 @@ function mergeConfig(existing) {
|
|
|
165
491
|
}
|
|
166
492
|
if (opsChanged) {
|
|
167
493
|
config["operations"] = existingOperations;
|
|
168
|
-
changes.push(
|
|
494
|
+
changes.push(
|
|
495
|
+
staleOpsRemoved ? "Merged PDaC operation guidance into openspec/config.yaml (operations:), replacing outdated PDaC entries." : "Merged PDaC operation guidance into openspec/config.yaml (operations:)."
|
|
496
|
+
);
|
|
169
497
|
}
|
|
170
498
|
return { config, changes };
|
|
171
499
|
}
|
|
172
500
|
function serializeConfig(config) {
|
|
173
|
-
return `${stringify(config)}
|
|
501
|
+
return `${stringify(config).trimEnd()}
|
|
174
502
|
`;
|
|
175
503
|
}
|
|
176
504
|
async function productshapeVersion() {
|
|
177
505
|
const packageJson = JSON.parse(
|
|
178
|
-
await
|
|
506
|
+
await readFile2(new URL("../package.json", import.meta.url), "utf8")
|
|
179
507
|
);
|
|
180
508
|
return packageJson.version;
|
|
181
509
|
}
|
|
182
510
|
async function addOpenSpecIntegration(root, options = {}) {
|
|
183
|
-
const { force = false, dryRun = false } = options;
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
511
|
+
const { force = false, dryRun = false, warningsAsErrors = false } = options;
|
|
512
|
+
let openspecVersion = options.cliVersion;
|
|
513
|
+
if (!openspecVersion) {
|
|
514
|
+
const detected = await detectOpenSpecVersion(root);
|
|
515
|
+
if ("error" in detected) {
|
|
516
|
+
throw new Error(detected.error);
|
|
517
|
+
}
|
|
518
|
+
openspecVersion = detected.version;
|
|
187
519
|
}
|
|
188
|
-
if (compareVersions(
|
|
520
|
+
if (compareVersions(openspecVersion, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
189
521
|
throw new Error(
|
|
190
|
-
`OpenSpec CLI version ${
|
|
522
|
+
`OpenSpec CLI version ${openspecVersion} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`
|
|
191
523
|
);
|
|
192
524
|
}
|
|
193
525
|
if (!await isOpenSpecWorkspace(root)) {
|
|
194
526
|
throw new Error("No OpenSpec workspace found. Run `openspec init` first.");
|
|
195
527
|
}
|
|
196
528
|
const existing = await readOpenSpecConfig(root);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
529
|
+
const previousMeta = await readMeta(root);
|
|
530
|
+
const { config, changes: configChanges } = mergeConfig(existing, previousMeta?.managed);
|
|
531
|
+
const ciDesired = renderCiExample({ warningsAsErrors });
|
|
532
|
+
const ciAbsolute = ciExamplePath(root);
|
|
533
|
+
const ciExisting = await pathExists(ciAbsolute) ? await readFile2(ciAbsolute, "utf8") : null;
|
|
534
|
+
const ciChanged = ciExisting !== ciDesired;
|
|
535
|
+
const changes = [...configChanges];
|
|
536
|
+
if (ciChanged) {
|
|
537
|
+
changes.push(
|
|
538
|
+
ciExisting === null ? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.` : `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
|
|
539
|
+
);
|
|
201
540
|
}
|
|
202
|
-
const
|
|
541
|
+
const meta = {
|
|
542
|
+
provider: "openspec",
|
|
543
|
+
version: await productshapeVersion(),
|
|
544
|
+
openspecVersion,
|
|
545
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
546
|
+
configPath: CONFIG_RELATIVE,
|
|
547
|
+
ciExamplePath: CI_EXAMPLE_RELATIVE,
|
|
548
|
+
managed: currentManagedStrings()
|
|
549
|
+
};
|
|
203
550
|
if (changes.length === 0 && !force) {
|
|
204
|
-
const meta = {
|
|
205
|
-
provider: "openspec",
|
|
206
|
-
version: await productshapeVersion(),
|
|
207
|
-
openspecVersion: detected.version,
|
|
208
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
209
|
-
configPath: CONFIG_RELATIVE
|
|
210
|
-
};
|
|
211
551
|
if (!dryRun) {
|
|
212
552
|
await writeMeta(root, meta);
|
|
213
553
|
}
|
|
@@ -215,31 +555,21 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
215
555
|
}
|
|
216
556
|
const written = [];
|
|
217
557
|
if (!dryRun) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
558
|
+
if (configChanges.length > 0 || force) {
|
|
559
|
+
const serialized = serializeConfig(config);
|
|
560
|
+
await mkdir(dirname(configPath(root)), { recursive: true });
|
|
561
|
+
await writeFile(configPath(root), serialized, "utf8");
|
|
562
|
+
written.push(CONFIG_RELATIVE);
|
|
563
|
+
}
|
|
564
|
+
if (ciChanged || force) {
|
|
565
|
+
await mkdir(dirname(ciAbsolute), { recursive: true });
|
|
566
|
+
await writeFile(ciAbsolute, ciDesired, "utf8");
|
|
567
|
+
written.push(CI_EXAMPLE_RELATIVE);
|
|
568
|
+
}
|
|
229
569
|
await writeMeta(root, meta);
|
|
230
570
|
written.push(META_RELATIVE);
|
|
231
571
|
}
|
|
232
|
-
return {
|
|
233
|
-
written,
|
|
234
|
-
changes,
|
|
235
|
-
meta: {
|
|
236
|
-
provider: "openspec",
|
|
237
|
-
version: await productshapeVersion(),
|
|
238
|
-
openspecVersion: detected.version,
|
|
239
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
240
|
-
configPath: CONFIG_RELATIVE
|
|
241
|
-
}
|
|
242
|
-
};
|
|
572
|
+
return { written, changes, meta };
|
|
243
573
|
}
|
|
244
574
|
async function writeMeta(root, meta) {
|
|
245
575
|
const path = metaPath(root);
|
|
@@ -249,16 +579,20 @@ async function writeMeta(root, meta) {
|
|
|
249
579
|
}
|
|
250
580
|
async function readMeta(root) {
|
|
251
581
|
const path = metaPath(root);
|
|
252
|
-
if (!await
|
|
253
|
-
return JSON.parse(await
|
|
582
|
+
if (!await pathExists(path)) return null;
|
|
583
|
+
return JSON.parse(await readFile2(path, "utf8"));
|
|
254
584
|
}
|
|
255
585
|
async function updateOpenSpecIntegration(root, options = {}) {
|
|
256
|
-
const result = await addOpenSpecIntegration(root, {
|
|
586
|
+
const result = await addOpenSpecIntegration(root, {
|
|
587
|
+
force: true,
|
|
588
|
+
dryRun: options.dryRun,
|
|
589
|
+
warningsAsErrors: options.warningsAsErrors
|
|
590
|
+
});
|
|
257
591
|
return { written: result.written, changes: result.changes };
|
|
258
592
|
}
|
|
259
593
|
async function checkOpenSpecIntegration(root) {
|
|
260
594
|
const checks = [];
|
|
261
|
-
const detected = await detectOpenSpecVersion();
|
|
595
|
+
const detected = await detectOpenSpecVersion(root);
|
|
262
596
|
if ("error" in detected) {
|
|
263
597
|
checks.push({ name: "openspec CLI", ok: false, detail: detected.error });
|
|
264
598
|
} else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
@@ -318,6 +652,19 @@ async function checkOpenSpecIntegration(root) {
|
|
|
318
652
|
detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`
|
|
319
653
|
});
|
|
320
654
|
}
|
|
655
|
+
if (await pathExists(ciExamplePath(root))) {
|
|
656
|
+
checks.push({
|
|
657
|
+
name: "ci example",
|
|
658
|
+
ok: true,
|
|
659
|
+
detail: `CI-ready verification example present at ${CI_EXAMPLE_RELATIVE}.`
|
|
660
|
+
});
|
|
661
|
+
} else {
|
|
662
|
+
checks.push({
|
|
663
|
+
name: "ci example",
|
|
664
|
+
ok: false,
|
|
665
|
+
detail: `CI-ready verification example missing (${CI_EXAMPLE_RELATIVE}). Run: prodshape integration update`
|
|
666
|
+
});
|
|
667
|
+
}
|
|
321
668
|
if (config) {
|
|
322
669
|
const rules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? config["rules"] : {};
|
|
323
670
|
const missingRules = [];
|
|
@@ -343,6 +690,26 @@ async function checkOpenSpecIntegration(root) {
|
|
|
343
690
|
async function removeOpenSpecIntegration(root, options = {}) {
|
|
344
691
|
const { dryRun = false } = options;
|
|
345
692
|
const removed = [];
|
|
693
|
+
const recorded = (await readMeta(root))?.managed;
|
|
694
|
+
const current = currentManagedStrings();
|
|
695
|
+
const managedRules = {};
|
|
696
|
+
for (const key of /* @__PURE__ */ new Set([
|
|
697
|
+
...Object.keys(current.rules),
|
|
698
|
+
...Object.keys(recorded?.rules ?? {})
|
|
699
|
+
])) {
|
|
700
|
+
managedRules[key] = [
|
|
701
|
+
.../* @__PURE__ */ new Set([...current.rules[key] ?? [], ...recorded?.rules[key] ?? []])
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
const managedOperations = {};
|
|
705
|
+
for (const key of /* @__PURE__ */ new Set([
|
|
706
|
+
...Object.keys(current.operations),
|
|
707
|
+
...Object.keys(recorded?.operations ?? {})
|
|
708
|
+
])) {
|
|
709
|
+
managedOperations[key] = [
|
|
710
|
+
.../* @__PURE__ */ new Set([...current.operations[key] ?? [], ...recorded?.operations[key] ?? []])
|
|
711
|
+
];
|
|
712
|
+
}
|
|
346
713
|
const config = await readOpenSpecConfig(root);
|
|
347
714
|
if (config !== null) {
|
|
348
715
|
let configChanged = false;
|
|
@@ -362,7 +729,7 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
362
729
|
const rules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? config["rules"] : null;
|
|
363
730
|
if (rules) {
|
|
364
731
|
let rulesChanged = false;
|
|
365
|
-
for (const [artifact, pdacRules] of Object.entries(
|
|
732
|
+
for (const [artifact, pdacRules] of Object.entries(managedRules)) {
|
|
366
733
|
if (!Array.isArray(rules[artifact])) continue;
|
|
367
734
|
const list = rules[artifact];
|
|
368
735
|
const filtered = list.filter((r) => !pdacRules.includes(r));
|
|
@@ -386,7 +753,7 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
386
753
|
if (operations) {
|
|
387
754
|
let opsChanged = false;
|
|
388
755
|
for (const opKey of ["apply", "archive"]) {
|
|
389
|
-
const pdacGuidance =
|
|
756
|
+
const pdacGuidance = managedOperations[opKey] ?? [];
|
|
390
757
|
const op = operations[opKey] && typeof operations[opKey] === "object" && !Array.isArray(operations[opKey]) ? operations[opKey] : null;
|
|
391
758
|
if (!op || !Array.isArray(op["guidance"])) continue;
|
|
392
759
|
const list = op["guidance"];
|
|
@@ -418,7 +785,13 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
418
785
|
removed.push(CONFIG_RELATIVE);
|
|
419
786
|
}
|
|
420
787
|
}
|
|
421
|
-
if (await
|
|
788
|
+
if (await pathExists(ciExamplePath(root))) {
|
|
789
|
+
if (!dryRun) {
|
|
790
|
+
await rm(ciExamplePath(root), { force: true });
|
|
791
|
+
}
|
|
792
|
+
removed.push(CI_EXAMPLE_RELATIVE);
|
|
793
|
+
}
|
|
794
|
+
if (await pathExists(metaPath(root))) {
|
|
422
795
|
if (!dryRun) {
|
|
423
796
|
await rm(metaPath(root), { force: true });
|
|
424
797
|
}
|
|
@@ -428,16 +801,27 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
428
801
|
}
|
|
429
802
|
export {
|
|
430
803
|
MIN_SUPPORTED_OPENSPEC,
|
|
804
|
+
OPENSPEC_CI_EXAMPLE_RELATIVE,
|
|
805
|
+
OPENSPEC_META_RELATIVE,
|
|
806
|
+
OPENSPEC_NPX_SPEC,
|
|
807
|
+
OPENSPEC_VERIFY_COMMAND,
|
|
431
808
|
PDAC_CONTEXT_BLOCK,
|
|
432
809
|
PDAC_OPERATIONS,
|
|
433
810
|
PDAC_RULES,
|
|
434
811
|
addOpenSpecIntegration,
|
|
812
|
+
bootstrapOpenSpecWorkspace,
|
|
435
813
|
checkOpenSpecIntegration,
|
|
814
|
+
currentManagedStrings,
|
|
436
815
|
detectOpenSpecVersion,
|
|
816
|
+
enumerateOpenSpecDocuments,
|
|
817
|
+
isOpenSpecCliAvailable,
|
|
818
|
+
isOpenSpecIntegrationInstalled,
|
|
437
819
|
isOpenSpecWorkspace,
|
|
438
820
|
mergeConfig,
|
|
821
|
+
openSpecProvider,
|
|
439
822
|
readOpenSpecConfig,
|
|
440
823
|
removeOpenSpecIntegration,
|
|
824
|
+
renderCiExample,
|
|
441
825
|
serializeConfig,
|
|
442
826
|
updateOpenSpecIntegration
|
|
443
827
|
};
|