@prodshape/integration-openspec 0.5.2 → 0.6.0-alpha.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/README.md +64 -0
- package/assets/openspec-product-schema/schema.yaml +120 -0
- package/assets/openspec-product-schema/scripts/product-apply.mjs +105 -0
- package/assets/openspec-product-schema/scripts/product-validate.mjs +77 -0
- package/assets/openspec-product-schema/templates/change.md +49 -0
- package/assets/openspec-product-schema/templates/proposal.md +29 -0
- package/dist/index.d.ts +192 -6
- package/dist/index.js +709 -50
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +529 -42
- package/src/product-schema.ts +73 -0
- package/src/product-workflow.ts +557 -0
package/dist/index.js
CHANGED
|
@@ -1,7 +1,49 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { mkdir, readFile as
|
|
3
|
-
import { dirname
|
|
4
|
-
import {
|
|
2
|
+
import { mkdir, readdir as readdir4, readFile as readFile4, rm, rmdir, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
import {
|
|
5
|
+
contentDigest,
|
|
6
|
+
isNotFound as isNotFound2,
|
|
7
|
+
isRepositoryRelativePath,
|
|
8
|
+
resolveInRepository
|
|
9
|
+
} from "@prodshape/core";
|
|
10
|
+
import { parse as parse2, stringify } from "yaml";
|
|
11
|
+
|
|
12
|
+
// src/product-schema.ts
|
|
13
|
+
import { readdir, readFile } from "fs/promises";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
var OPENSPEC_PRODUCT_SCHEMA_NAME = "product";
|
|
17
|
+
var OPENSPEC_PRODUCT_SCHEMA_RELATIVE = "openspec/schemas/product";
|
|
18
|
+
var PRODUCT_SCHEMA_MIN_OPENSPEC = "1.7.0";
|
|
19
|
+
function assetsDir() {
|
|
20
|
+
return fileURLToPath(new URL("../assets/openspec-product-schema/", import.meta.url));
|
|
21
|
+
}
|
|
22
|
+
async function collectFiles(dir, prefix) {
|
|
23
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
26
|
+
const relative2 = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
files.push(...await collectFiles(join(dir, entry.name), relative2));
|
|
29
|
+
} else if (entry.isFile()) {
|
|
30
|
+
files.push(relative2);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
async function loadProductSchemaAssets() {
|
|
36
|
+
const dir = assetsDir();
|
|
37
|
+
const relatives = await collectFiles(dir, "");
|
|
38
|
+
const assets = [];
|
|
39
|
+
for (const relative2 of relatives.sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
40
|
+
assets.push({
|
|
41
|
+
relative: relative2,
|
|
42
|
+
content: await readFile(join(dir, ...relative2.split("/")), "utf8")
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return assets;
|
|
46
|
+
}
|
|
5
47
|
|
|
6
48
|
// src/process.ts
|
|
7
49
|
import crossSpawn from "cross-spawn";
|
|
@@ -63,7 +105,7 @@ function runCommand(command, args, options = {}) {
|
|
|
63
105
|
|
|
64
106
|
// src/workspace.ts
|
|
65
107
|
import { access } from "fs/promises";
|
|
66
|
-
import { join } from "path";
|
|
108
|
+
import { join as join2 } from "path";
|
|
67
109
|
async function pathExists(path) {
|
|
68
110
|
try {
|
|
69
111
|
await access(path);
|
|
@@ -73,7 +115,7 @@ async function pathExists(path) {
|
|
|
73
115
|
}
|
|
74
116
|
}
|
|
75
117
|
async function isOpenSpecWorkspace(root) {
|
|
76
|
-
return pathExists(
|
|
118
|
+
return pathExists(join2(root, "openspec"));
|
|
77
119
|
}
|
|
78
120
|
function envWithLocalBin(root) {
|
|
79
121
|
const env = { ...process.env };
|
|
@@ -85,15 +127,15 @@ function envWithLocalBin(root) {
|
|
|
85
127
|
delete env[key];
|
|
86
128
|
}
|
|
87
129
|
}
|
|
88
|
-
const localBin =
|
|
130
|
+
const localBin = join2(root, "node_modules", ".bin");
|
|
89
131
|
env["PATH"] = existing ? `${localBin}${separator}${existing}` : localBin;
|
|
90
132
|
return env;
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
// src/population.ts
|
|
94
136
|
import { readFileSync } from "fs";
|
|
95
|
-
import { readdir, readFile } from "fs/promises";
|
|
96
|
-
import { join as
|
|
137
|
+
import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
|
|
138
|
+
import { join as join3, relative, sep } from "path";
|
|
97
139
|
import fg from "fast-glob";
|
|
98
140
|
import { codes } from "@prodshape/core";
|
|
99
141
|
var CHANGE_ARTIFACT_FILES = [
|
|
@@ -149,16 +191,16 @@ function buildConsumerDocument(absolutePath, repoRoot, opts) {
|
|
|
149
191
|
async function discoverChangeDocuments(changeDir, repoRoot, change, archived) {
|
|
150
192
|
const docs = [];
|
|
151
193
|
for (const { file, kind } of CHANGE_ARTIFACT_FILES) {
|
|
152
|
-
const absolutePath =
|
|
194
|
+
const absolutePath = join3(changeDir, file);
|
|
153
195
|
try {
|
|
154
|
-
await
|
|
196
|
+
await readFile2(absolutePath);
|
|
155
197
|
docs.push(
|
|
156
198
|
buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: kind })
|
|
157
199
|
);
|
|
158
200
|
} catch {
|
|
159
201
|
}
|
|
160
202
|
}
|
|
161
|
-
const specsDir =
|
|
203
|
+
const specsDir = join3(changeDir, "specs");
|
|
162
204
|
try {
|
|
163
205
|
const specFiles = await discoverMarkdownFiles(specsDir);
|
|
164
206
|
for (const absolutePath of specFiles) {
|
|
@@ -185,7 +227,7 @@ async function discoverSpecDocuments(specsDir, repoRoot) {
|
|
|
185
227
|
}
|
|
186
228
|
async function discoverArchivedChanges(archiveDir) {
|
|
187
229
|
try {
|
|
188
|
-
const entries = await
|
|
230
|
+
const entries = await readdir2(archiveDir, { withFileTypes: true });
|
|
189
231
|
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
190
232
|
} catch {
|
|
191
233
|
return [];
|
|
@@ -193,10 +235,10 @@ async function discoverArchivedChanges(archiveDir) {
|
|
|
193
235
|
}
|
|
194
236
|
async function enumerateOpenSpecDocuments(repoRoot, options) {
|
|
195
237
|
const includeArchived = options?.includeArchived ?? false;
|
|
196
|
-
const root =
|
|
197
|
-
const changesDir =
|
|
198
|
-
const archiveDir =
|
|
199
|
-
const specsDir =
|
|
238
|
+
const root = join3(repoRoot, "openspec");
|
|
239
|
+
const changesDir = join3(root, "changes");
|
|
240
|
+
const archiveDir = join3(changesDir, "archive");
|
|
241
|
+
const specsDir = join3(root, "specs");
|
|
200
242
|
const documents = [];
|
|
201
243
|
const diagnostics = [];
|
|
202
244
|
let currentChangeNames = await pathExists(changesDir) ? await openspecListChanges(repoRoot) : [];
|
|
@@ -208,7 +250,7 @@ async function enumerateOpenSpecDocuments(repoRoot, options) {
|
|
|
208
250
|
file: "openspec/"
|
|
209
251
|
});
|
|
210
252
|
try {
|
|
211
|
-
const entries = await
|
|
253
|
+
const entries = await readdir2(changesDir, { withFileTypes: true });
|
|
212
254
|
currentChangeNames = entries.filter((e) => e.isDirectory() && e.name !== "archive").map((e) => e.name).sort();
|
|
213
255
|
} catch {
|
|
214
256
|
currentChangeNames = [];
|
|
@@ -223,14 +265,14 @@ async function enumerateOpenSpecDocuments(repoRoot, options) {
|
|
|
223
265
|
}
|
|
224
266
|
}
|
|
225
267
|
for (const name of currentChangeNames) {
|
|
226
|
-
const changeDir =
|
|
268
|
+
const changeDir = join3(changesDir, name);
|
|
227
269
|
documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, false));
|
|
228
270
|
}
|
|
229
271
|
if (includeArchived) {
|
|
230
272
|
const archivedNames = await discoverArchivedChanges(archiveDir);
|
|
231
273
|
for (const name of archivedNames) {
|
|
232
274
|
if (options?.change && name !== options.change) continue;
|
|
233
|
-
const changeDir =
|
|
275
|
+
const changeDir = join3(archiveDir, name);
|
|
234
276
|
documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, true));
|
|
235
277
|
}
|
|
236
278
|
}
|
|
@@ -250,6 +292,299 @@ var openSpecProvider = {
|
|
|
250
292
|
enumerateDocuments: enumerateOpenSpecDocuments
|
|
251
293
|
};
|
|
252
294
|
|
|
295
|
+
// src/product-workflow.ts
|
|
296
|
+
import { readdir as readdir3, readFile as readFile3 } from "fs/promises";
|
|
297
|
+
import { join as join4 } from "path";
|
|
298
|
+
import { parse } from "yaml";
|
|
299
|
+
import {
|
|
300
|
+
analyzeImpact,
|
|
301
|
+
blockingDiagnostics,
|
|
302
|
+
compareCodePoints,
|
|
303
|
+
dedupeDiagnostics,
|
|
304
|
+
discoverChanges,
|
|
305
|
+
executeApply,
|
|
306
|
+
isNotFound,
|
|
307
|
+
loadChange,
|
|
308
|
+
openRepository,
|
|
309
|
+
planApply,
|
|
310
|
+
preflightApply,
|
|
311
|
+
sortDiagnostics,
|
|
312
|
+
validateBaseline,
|
|
313
|
+
validateChange
|
|
314
|
+
} from "@prodshape/core";
|
|
315
|
+
var OPENSPEC_PRODUCT_SUBDIR = "product";
|
|
316
|
+
var OPENSPEC_CHANGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
317
|
+
function assertOpenSpecChangeName(changeName) {
|
|
318
|
+
if (changeName.length > 200 || !OPENSPEC_CHANGE_NAME.test(changeName)) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`OpenSpec change '${changeName}' is not a valid OpenSpec change name; use kebab-case with lowercase letters, numbers and single hyphen separators.`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function inspectProductModel(root) {
|
|
325
|
+
const repo = await openRepository(root);
|
|
326
|
+
const baseline = await validateBaseline(repo);
|
|
327
|
+
return {
|
|
328
|
+
repo,
|
|
329
|
+
artifacts: baseline.artifacts,
|
|
330
|
+
graph: baseline.graph,
|
|
331
|
+
diagnostics: sortDiagnostics([...repo.configDiagnostics, ...baseline.diagnostics])
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
var OPEN_SPEC_KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
335
|
+
function openSpecChangeMetadataProblem(parsed) {
|
|
336
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
337
|
+
return "the document must be an object";
|
|
338
|
+
}
|
|
339
|
+
const record = parsed;
|
|
340
|
+
if (typeof record["schema"] !== "string" || record["schema"].length === 0) {
|
|
341
|
+
return "schema is required";
|
|
342
|
+
}
|
|
343
|
+
const created = record["created"];
|
|
344
|
+
if (created !== void 0 && (typeof created !== "string" || !/^\d{4}-\d{2}-\d{2}$/u.test(created))) {
|
|
345
|
+
return "created must be YYYY-MM-DD format";
|
|
346
|
+
}
|
|
347
|
+
const goal = record["goal"];
|
|
348
|
+
if (goal !== void 0 && (typeof goal !== "string" || goal.length === 0)) {
|
|
349
|
+
return "goal must be a non-empty string";
|
|
350
|
+
}
|
|
351
|
+
const affectedAreas = record["affected_areas"];
|
|
352
|
+
if (affectedAreas !== void 0 && (!Array.isArray(affectedAreas) || !affectedAreas.every((area) => typeof area === "string" && area.length > 0))) {
|
|
353
|
+
return "affected_areas must be an array of non-empty strings";
|
|
354
|
+
}
|
|
355
|
+
const initiative = record["initiative"];
|
|
356
|
+
if (initiative !== void 0) {
|
|
357
|
+
if (initiative === null || typeof initiative !== "object" || Array.isArray(initiative)) {
|
|
358
|
+
return "initiative must be an object";
|
|
359
|
+
}
|
|
360
|
+
const link = initiative;
|
|
361
|
+
if (Object.keys(link).some((key) => key !== "store" && key !== "id") || typeof link["store"] !== "string" || !OPEN_SPEC_KEBAB_ID.test(link["store"]) || typeof link["id"] !== "string" || !OPEN_SPEC_KEBAB_ID.test(link["id"])) {
|
|
362
|
+
return "initiative must contain only kebab-case store and id fields";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (record["skip_specs"] !== void 0 && typeof record["skip_specs"] !== "boolean") {
|
|
366
|
+
return "skip_specs must be a boolean";
|
|
367
|
+
}
|
|
368
|
+
if (record["retire_capabilities"] !== void 0 && typeof record["retire_capabilities"] !== "boolean") {
|
|
369
|
+
return "retire_capabilities must be a boolean";
|
|
370
|
+
}
|
|
371
|
+
return void 0;
|
|
372
|
+
}
|
|
373
|
+
async function readHostedSchemaPin(changeDir) {
|
|
374
|
+
const metadataPath = join4(changeDir, ".openspec.yaml");
|
|
375
|
+
if (!await pathExists(metadataPath)) return { problem: "missing" };
|
|
376
|
+
let parsed;
|
|
377
|
+
try {
|
|
378
|
+
parsed = parse(await readFile3(metadataPath, "utf8"));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return {
|
|
381
|
+
problem: "malformed",
|
|
382
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const problem = openSpecChangeMetadataProblem(parsed);
|
|
386
|
+
if (problem !== void 0) return { problem: "malformed", detail: problem };
|
|
387
|
+
return { schema: parsed["schema"] };
|
|
388
|
+
}
|
|
389
|
+
function hostedSchemaPinError(changeName, pin) {
|
|
390
|
+
if (pin.problem === "missing") {
|
|
391
|
+
return new Error(
|
|
392
|
+
`OpenSpec change '${changeName}' has no .openspec.yaml, so its schema pin is unknown; the hosted product rail refuses it. Create product changes with: openspec new change ${changeName} --schema ${OPENSPEC_PRODUCT_SCHEMA_NAME}.`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return new Error(
|
|
396
|
+
`OpenSpec change '${changeName}' has an unreadable .openspec.yaml (${pin.detail}); the hosted product rail refuses it until the metadata is valid under OpenSpec 1.11.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
async function listOpenSpecProductChanges(root) {
|
|
400
|
+
const changesDir = join4(root, "openspec", "changes");
|
|
401
|
+
let entries;
|
|
402
|
+
try {
|
|
403
|
+
entries = await readdir3(changesDir, { withFileTypes: true });
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (isNotFound(error)) return [];
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
const names = entries.filter((entry) => entry.isDirectory() && entry.name !== "archive").map((entry) => entry.name).sort(compareCodePoints);
|
|
409
|
+
const refs = [];
|
|
410
|
+
for (const name of names) {
|
|
411
|
+
const changeDir = join4(changesDir, name);
|
|
412
|
+
const dir = join4(changeDir, OPENSPEC_PRODUCT_SUBDIR);
|
|
413
|
+
if (!await pathExists(join4(dir, "change.md"))) continue;
|
|
414
|
+
assertOpenSpecChangeName(name);
|
|
415
|
+
const pin = await readHostedSchemaPin(changeDir);
|
|
416
|
+
if ("problem" in pin) throw hostedSchemaPinError(name, pin);
|
|
417
|
+
if (pin.schema !== OPENSPEC_PRODUCT_SCHEMA_NAME) continue;
|
|
418
|
+
refs.push({
|
|
419
|
+
name,
|
|
420
|
+
dir,
|
|
421
|
+
file: `openspec/changes/${name}/${OPENSPEC_PRODUCT_SUBDIR}/change.md`
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return refs;
|
|
425
|
+
}
|
|
426
|
+
async function loadOpenSpecProductChange(root, changeName) {
|
|
427
|
+
const repo = await openRepository(root);
|
|
428
|
+
return loadHostedChange(root, repo, changeName);
|
|
429
|
+
}
|
|
430
|
+
async function loadHostedChange(root, repo, changeName) {
|
|
431
|
+
assertOpenSpecChangeName(changeName);
|
|
432
|
+
const changesDir = join4(root, "openspec", "changes");
|
|
433
|
+
const changeDir = join4(changesDir, changeName);
|
|
434
|
+
const deltaDir = join4(changeDir, OPENSPEC_PRODUCT_SUBDIR);
|
|
435
|
+
const hasDelta = changeName !== "archive" && await pathExists(changeDir) && await pathExists(join4(deltaDir, "change.md"));
|
|
436
|
+
if (!hasDelta) {
|
|
437
|
+
const refs = await listOpenSpecProductChanges(root);
|
|
438
|
+
const known = refs.map((candidate) => candidate.name).join(", ") || "none";
|
|
439
|
+
throw new Error(
|
|
440
|
+
`No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product/change.md; found: ${known}).`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const pin = await readHostedSchemaPin(changeDir);
|
|
444
|
+
if ("problem" in pin) {
|
|
445
|
+
throw hostedSchemaPinError(changeName, pin);
|
|
446
|
+
}
|
|
447
|
+
if (pin.schema !== OPENSPEC_PRODUCT_SCHEMA_NAME) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`OpenSpec change '${changeName}' is pinned to schema '${pin.schema}', not '${OPENSPEC_PRODUCT_SCHEMA_NAME}'; the hosted product rail refuses it. Product intent travels through a change created with: openspec new change <name> --schema ${OPENSPEC_PRODUCT_SCHEMA_NAME}.`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return loadChange(deltaDir, repo.root, repo.registry);
|
|
453
|
+
}
|
|
454
|
+
var TERMINAL_CHANGE_STATUSES = /* @__PURE__ */ new Set(["applied", "rejected", "superseded"]);
|
|
455
|
+
async function loadAllLiveChanges(root, repo) {
|
|
456
|
+
const changes = [];
|
|
457
|
+
for (const ref of await listOpenSpecProductChanges(root)) {
|
|
458
|
+
const loaded = await loadChange(ref.dir, repo.root, repo.registry);
|
|
459
|
+
if (loaded.status !== void 0 && TERMINAL_CHANGE_STATUSES.has(loaded.status)) continue;
|
|
460
|
+
changes.push(loaded);
|
|
461
|
+
}
|
|
462
|
+
for (const dir of await discoverChanges(join4(repo.changesDir, "active"))) {
|
|
463
|
+
changes.push(await loadChange(dir, repo.root, repo.registry));
|
|
464
|
+
}
|
|
465
|
+
return changes;
|
|
466
|
+
}
|
|
467
|
+
async function validateOpenSpecProductChange(root, changeName) {
|
|
468
|
+
const repo = await openRepository(root);
|
|
469
|
+
const baseline = await validateBaseline(repo);
|
|
470
|
+
const all = await loadAllLiveChanges(root, repo);
|
|
471
|
+
const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
|
|
472
|
+
const validation = validateChange(change, baseline.artifacts, all);
|
|
473
|
+
const diagnostics = sortDiagnostics(
|
|
474
|
+
dedupeDiagnostics([
|
|
475
|
+
...repo.configDiagnostics,
|
|
476
|
+
...baseline.diagnostics,
|
|
477
|
+
...validation.diagnostics
|
|
478
|
+
])
|
|
479
|
+
);
|
|
480
|
+
return {
|
|
481
|
+
change,
|
|
482
|
+
baseline,
|
|
483
|
+
overlayArtifacts: validation.overlayArtifacts,
|
|
484
|
+
overlayGraph: validation.overlayGraph,
|
|
485
|
+
diagnostics,
|
|
486
|
+
blocking: blockingDiagnostics(diagnostics, repo.config.validation["warnings-as-errors"])
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function hostedName(root, change) {
|
|
490
|
+
const normalizedRoot = root.replaceAll("\\", "/");
|
|
491
|
+
const normalizedDir = change.dir.replaceAll("\\", "/");
|
|
492
|
+
const relative2 = normalizedDir.startsWith(normalizedRoot) ? normalizedDir.slice(normalizedRoot.length).replace(/^\//, "") : normalizedDir;
|
|
493
|
+
const match = relative2.match(/^openspec\/changes\/([^/]+)\/product$/);
|
|
494
|
+
return match?.[1];
|
|
495
|
+
}
|
|
496
|
+
async function applyOpenSpecProductChange(root, changeName, options = {}) {
|
|
497
|
+
const { dryRun = false } = options;
|
|
498
|
+
const repo = await openRepository(root);
|
|
499
|
+
const baseline = await validateBaseline(repo);
|
|
500
|
+
const all = await loadAllLiveChanges(root, repo);
|
|
501
|
+
const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
|
|
502
|
+
const validation = validateChange(change, baseline.artifacts, all);
|
|
503
|
+
const overlayErrors = blockingDiagnostics(
|
|
504
|
+
sortDiagnostics(
|
|
505
|
+
dedupeDiagnostics([
|
|
506
|
+
...repo.configDiagnostics,
|
|
507
|
+
...baseline.diagnostics,
|
|
508
|
+
...validation.diagnostics
|
|
509
|
+
])
|
|
510
|
+
),
|
|
511
|
+
repo.config.validation["warnings-as-errors"]
|
|
512
|
+
);
|
|
513
|
+
const rawPlan = await planApply({
|
|
514
|
+
repoRoot: repo.root,
|
|
515
|
+
modelRelative: repo.config.product.model,
|
|
516
|
+
changesRelative: repo.config.product.changes,
|
|
517
|
+
change,
|
|
518
|
+
baseline: baseline.artifacts,
|
|
519
|
+
overlayErrors
|
|
520
|
+
});
|
|
521
|
+
const plan = {
|
|
522
|
+
...rawPlan,
|
|
523
|
+
actions: rawPlan.actions.filter((action) => action.kind !== "move-change"),
|
|
524
|
+
// The hosted rail states authorisation as policy. Core's PRODUCT028 wording prescribes a
|
|
525
|
+
// human hand edit, which is the native lifecycle's own contract; here the code, severity,
|
|
526
|
+
// file and fields stay core's while the message (implementation-defined and never compared)
|
|
527
|
+
// says what the hosted contract requires. A message rewrite of an existing diagnostic, never
|
|
528
|
+
// a new emission.
|
|
529
|
+
diagnostics: rawPlan.diagnostics.map(
|
|
530
|
+
(diagnostic) => diagnostic.code === "PRODUCT028" ? {
|
|
531
|
+
...diagnostic,
|
|
532
|
+
message: `Apply requires status 'approved'; the change is '${change.status ?? "unknown"}'. The transition into the apply-authorised state belongs to the caller's authorisation policy; the integration never performs or judges it.`
|
|
533
|
+
} : diagnostic
|
|
534
|
+
)
|
|
535
|
+
};
|
|
536
|
+
if (plan.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
537
|
+
return { outcome: "refused", plan, change };
|
|
538
|
+
}
|
|
539
|
+
if (dryRun) {
|
|
540
|
+
await preflightApply(repo.root, plan);
|
|
541
|
+
return { outcome: "dry-run", plan, change };
|
|
542
|
+
}
|
|
543
|
+
await executeApply(repo.root, plan);
|
|
544
|
+
const resultingModel = await validateBaseline(repo);
|
|
545
|
+
return { outcome: "applied", plan, change, resultingModel };
|
|
546
|
+
}
|
|
547
|
+
async function deriveDeliveryContext(root, ids) {
|
|
548
|
+
const inspection = await inspectProductModel(root);
|
|
549
|
+
const byId = new Map(
|
|
550
|
+
inspection.artifacts.filter((artifact) => artifact.id !== void 0).map((artifact) => [artifact.id, artifact])
|
|
551
|
+
);
|
|
552
|
+
const entryOf = (artifact) => {
|
|
553
|
+
const entry = {
|
|
554
|
+
id: artifact.id,
|
|
555
|
+
file: artifact.file,
|
|
556
|
+
digest: artifact.digest
|
|
557
|
+
};
|
|
558
|
+
if (artifact.type !== void 0) entry.type = artifact.type;
|
|
559
|
+
if (artifact.title !== void 0) entry.title = artifact.title;
|
|
560
|
+
if (artifact.status !== void 0) entry.status = artifact.status;
|
|
561
|
+
return entry;
|
|
562
|
+
};
|
|
563
|
+
const sortedIds = [...ids].sort(compareCodePoints);
|
|
564
|
+
const artifacts = [];
|
|
565
|
+
const impacts = {};
|
|
566
|
+
const neighbourIds = /* @__PURE__ */ new Set();
|
|
567
|
+
for (const id of sortedIds) {
|
|
568
|
+
const artifact = byId.get(id);
|
|
569
|
+
if (!artifact) {
|
|
570
|
+
throw new Error(`Unknown artifact id '${id}' in the accepted model at ${root}.`);
|
|
571
|
+
}
|
|
572
|
+
artifacts.push(entryOf(artifact));
|
|
573
|
+
const report = analyzeImpact(inspection.graph, id, { direction: "both", depth: 2 });
|
|
574
|
+
impacts[id] = report;
|
|
575
|
+
for (const item of report.direct) neighbourIds.add(item.id);
|
|
576
|
+
}
|
|
577
|
+
for (const id of sortedIds) neighbourIds.delete(id);
|
|
578
|
+
const neighbours = [...neighbourIds].sort(compareCodePoints).map((id) => byId.get(id)).filter((artifact) => artifact !== void 0).map(entryOf);
|
|
579
|
+
return {
|
|
580
|
+
ids: sortedIds,
|
|
581
|
+
artifacts,
|
|
582
|
+
impacts,
|
|
583
|
+
neighbours,
|
|
584
|
+
modelErrorFree: inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error")
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
253
588
|
// src/index.ts
|
|
254
589
|
var MIN_SUPPORTED_OPENSPEC = "1.0.0";
|
|
255
590
|
var OPENSPEC_NPX_SPEC = "@fission-ai/openspec@1";
|
|
@@ -261,14 +596,14 @@ A citation binds consumer text to a canonical artifact: it records the artifact
|
|
|
261
596
|
To cite: run \`npx prodshape inspect <ID>\` to read the current digest, then \`npx prodshape cite --id <ID> --digest <digest>\` to emit the canonical payload. Wrap it in the document's native comment (\`<!-- ... -->\` in Markdown) on its own line directly under the text it grounds.
|
|
262
597
|
To find which artifacts a change impacts, 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.
|
|
263
598
|
Every current OpenSpec document (proposal, specs, design, tasks and openspec/specs/) carries exactly one explicit scope declaration: \`pdac-scope: cited\` for a bound document that carries at least one citation, or \`pdac-scope: none\` with a non-empty human-authored reason (\`pdac-scope-reason: <why>\` in frontmatter, or \`<!-- pdac-scope: none reason="<why>" -->\`) for an exempt one. Citations alone never bind: a document without a declaration is unclassified and fails verification, a bound document with zero citations fails, and an exemption without a reason or contradicted by citations fails. Binding and exemption are human declarations: never declare an exemption merely because citations are missing. Verify with \`npx prodshape citations verify --provider openspec\`: it enumerates the expected current documents, so zero discovered citations is a set of failures, never a pass. Archived changes are excluded by default; \`--include-archived\` also verifies the citations history carries, reported as warnings, because archived history cannot be edited.
|
|
264
|
-
The accepted Product Definition changes only through a Product Change
|
|
599
|
+
The accepted Product Definition changes only through a Product Change: validate it as an overlay while the baseline stays untouched, obtain human product approval (represented by status: approved; the integration verifies the state but cannot identify or judge the actor), apply explicitly on a working branch, and let a human accept the resulting baseline by merging its pull request. A Product Change is not a pull request; apply is not acceptance; neither apply nor merge attests implementation, verification, release or deployment. A Product Change lives under docs/product/changes/ or hosted inside a valid OpenSpec change created with the product schema (product/change.md plus product/proposed/**); its deterministically validated, apply-authorised apply is the only write path into docs/product/model. A spec-driven OpenSpec delivery change never edits docs/product/model directly: it may share a pull request with an applied Product Change or follow later, but it never supplies Product Change status.
|
|
265
600
|
${PDAC_CONTEXT_END}`;
|
|
266
601
|
var PDAC_RULES = {
|
|
267
602
|
proposal: [
|
|
268
603
|
"State which PDaC artifacts this change touches, each with a citation payload emitted by `npx prodshape cite --id <ID> --digest <digest>` and wrapped in the document native comment (`<!-- ... -->` in Markdown). Never write a citation record by hand.",
|
|
269
604
|
"Find the impacted artifacts as a separate step, before writing any proposal content. 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: 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.",
|
|
270
605
|
'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.',
|
|
271
|
-
"If the change implements altered product behaviour, name the Product Change (CHG id) whose applied or accepted artifacts it implements. If no overlay-validated
|
|
606
|
+
"If the change implements altered product behaviour, name the Product Change (CHG id) whose applied or accepted artifacts it implements. If no overlay-validated, apply-authorised Product Change exists yet, stop and ask for one instead of proceeding. A spec-driven OpenSpec change is not the Product Change; product intent travels through a Product Change, under docs/product/changes or hosted by a product-schema OpenSpec change.",
|
|
272
607
|
'Every document of this change must end up bound or exempt, each with an explicit declaration: declare `pdac-scope: cited` on a line of its own and cite the canonical text the document depends on, or declare `pdac-scope: none` with a non-empty reason (`pdac-scope-reason: <why>` in frontmatter, or `<!-- pdac-scope: none reason="<why>" -->`) when a human judges the document has no product-semantic dependency. Citations alone never bind, and never declare an exemption just because citations are missing.'
|
|
273
608
|
],
|
|
274
609
|
specs: [
|
|
@@ -305,6 +640,40 @@ function currentManagedStrings() {
|
|
|
305
640
|
)
|
|
306
641
|
};
|
|
307
642
|
}
|
|
643
|
+
function recordedProductSchemaFiles(meta) {
|
|
644
|
+
const record = meta?.productSchema;
|
|
645
|
+
if (!record) return void 0;
|
|
646
|
+
if (typeof record !== "object" || record === null || record.name !== OPENSPEC_PRODUCT_SCHEMA_NAME || typeof record.requiresOpenspec !== "string") {
|
|
647
|
+
throw new Error(
|
|
648
|
+
"'productSchema' must name the product schema and carry a string 'requiresOpenspec'"
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const files = record.files;
|
|
652
|
+
const validatePath = (file) => typeof file === "string" && file.startsWith(`${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) && isRepositoryRelativePath(file);
|
|
653
|
+
if (Array.isArray(files)) {
|
|
654
|
+
if (!files.every(validatePath)) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`'productSchema.files' contains a path outside ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}`
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
return Object.fromEntries(files.map((file) => [file, ""]));
|
|
660
|
+
}
|
|
661
|
+
if (files !== null && typeof files === "object") {
|
|
662
|
+
const entries = Object.entries(files);
|
|
663
|
+
if (!entries.every(([file]) => validatePath(file))) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`'productSchema.files' contains a path outside ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (!entries.every(
|
|
669
|
+
([, digest]) => typeof digest === "string" && /^sha256:[0-9a-f]{64}$/.test(digest)
|
|
670
|
+
)) {
|
|
671
|
+
throw new Error("'productSchema.files' contains an invalid content digest");
|
|
672
|
+
}
|
|
673
|
+
return Object.fromEntries(entries);
|
|
674
|
+
}
|
|
675
|
+
throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
|
|
676
|
+
}
|
|
308
677
|
var CONFIG_RELATIVE = "openspec/config.yaml";
|
|
309
678
|
var META_RELATIVE = ".product/integrations/openspec.json";
|
|
310
679
|
var CI_EXAMPLE_RELATIVE = ".product/integrations/openspec.ci.yml";
|
|
@@ -358,13 +727,13 @@ function renderCiExample(options) {
|
|
|
358
727
|
].join("\n");
|
|
359
728
|
}
|
|
360
729
|
function configPath(root) {
|
|
361
|
-
return
|
|
730
|
+
return resolveInRepository(root, CONFIG_RELATIVE, "the OpenSpec integration");
|
|
362
731
|
}
|
|
363
732
|
function metaPath(root) {
|
|
364
|
-
return
|
|
733
|
+
return resolveInRepository(root, META_RELATIVE, "the OpenSpec integration");
|
|
365
734
|
}
|
|
366
735
|
function ciExamplePath(root) {
|
|
367
|
-
return
|
|
736
|
+
return resolveInRepository(root, CI_EXAMPLE_RELATIVE, "the OpenSpec integration");
|
|
368
737
|
}
|
|
369
738
|
function compareVersions(a, b) {
|
|
370
739
|
const parseParts = (v) => {
|
|
@@ -433,8 +802,8 @@ async function isOpenSpecIntegrationInstalled(root) {
|
|
|
433
802
|
async function readOpenSpecConfig(root) {
|
|
434
803
|
const path = configPath(root);
|
|
435
804
|
if (!await pathExists(path)) return null;
|
|
436
|
-
const text = await
|
|
437
|
-
const parsed =
|
|
805
|
+
const text = await readFile4(path, "utf8");
|
|
806
|
+
const parsed = parse2(text);
|
|
438
807
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
439
808
|
return {};
|
|
440
809
|
}
|
|
@@ -543,12 +912,12 @@ function serializeConfig(config) {
|
|
|
543
912
|
}
|
|
544
913
|
async function productshapeVersion() {
|
|
545
914
|
const packageJson = JSON.parse(
|
|
546
|
-
await
|
|
915
|
+
await readFile4(new URL("../package.json", import.meta.url), "utf8")
|
|
547
916
|
);
|
|
548
917
|
return packageJson.version;
|
|
549
918
|
}
|
|
550
919
|
async function addOpenSpecIntegration(root, options = {}) {
|
|
551
|
-
const {
|
|
920
|
+
const { dryRun = false, warningsAsErrors = false } = options;
|
|
552
921
|
let openspecVersion = options.cliVersion;
|
|
553
922
|
if (!openspecVersion) {
|
|
554
923
|
const detected = await detectOpenSpecVersion(root);
|
|
@@ -570,7 +939,7 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
570
939
|
const { config, changes: configChanges } = mergeConfig(existing, previousMeta?.managed);
|
|
571
940
|
const ciDesired = renderCiExample({ warningsAsErrors });
|
|
572
941
|
const ciAbsolute = ciExamplePath(root);
|
|
573
|
-
const ciExisting = await pathExists(ciAbsolute) ? await
|
|
942
|
+
const ciExisting = await pathExists(ciAbsolute) ? await readFile4(ciAbsolute, "utf8") : null;
|
|
574
943
|
const ciChanged = ciExisting !== ciDesired;
|
|
575
944
|
const changes = [...configChanges];
|
|
576
945
|
if (ciChanged) {
|
|
@@ -578,49 +947,189 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
578
947
|
ciExisting === null ? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.` : `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
|
|
579
948
|
);
|
|
580
949
|
}
|
|
950
|
+
const schemaAssets = await loadProductSchemaAssets();
|
|
951
|
+
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
952
|
+
const currentSchemaPaths = new Set(
|
|
953
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`)
|
|
954
|
+
);
|
|
955
|
+
const schemaFileDigests = {};
|
|
956
|
+
const schemaWrites = [];
|
|
957
|
+
const schemaDeletes = [];
|
|
958
|
+
const schemaCollisions = [];
|
|
959
|
+
const schemaPreserved = [];
|
|
960
|
+
let schemaInstalledAnything = false;
|
|
961
|
+
for (const asset of schemaAssets) {
|
|
962
|
+
const relative2 = `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
963
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
964
|
+
const existing2 = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
|
|
965
|
+
const assetDigest = contentDigest(asset.content);
|
|
966
|
+
if (existing2 === null) {
|
|
967
|
+
schemaWrites.push({ relative: relative2, absolute, content: asset.content });
|
|
968
|
+
schemaFileDigests[relative2] = assetDigest;
|
|
969
|
+
schemaInstalledAnything = true;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const recordedDigest = recordedSchema?.[relative2];
|
|
973
|
+
if (recordedDigest === void 0) {
|
|
974
|
+
schemaCollisions.push(relative2);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (existing2 === asset.content) {
|
|
978
|
+
schemaFileDigests[relative2] = assetDigest;
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (recordedDigest !== "" && contentDigest(existing2) === recordedDigest) {
|
|
982
|
+
schemaWrites.push({ relative: relative2, absolute, content: asset.content });
|
|
983
|
+
schemaFileDigests[relative2] = assetDigest;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
schemaPreserved.push(relative2);
|
|
987
|
+
if (recordedDigest !== "") schemaFileDigests[relative2] = recordedDigest;
|
|
988
|
+
}
|
|
989
|
+
for (const [relative2, recordedDigest] of Object.entries(recordedSchema ?? {})) {
|
|
990
|
+
if (currentSchemaPaths.has(relative2)) continue;
|
|
991
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
992
|
+
if (!await pathExists(absolute)) continue;
|
|
993
|
+
const existing2 = await readFile4(absolute, "utf8");
|
|
994
|
+
if (recordedDigest !== "" && contentDigest(existing2) === recordedDigest) {
|
|
995
|
+
schemaDeletes.push({ relative: relative2, absolute });
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
schemaPreserved.push(relative2);
|
|
999
|
+
if (recordedDigest !== "") schemaFileDigests[relative2] = recordedDigest;
|
|
1000
|
+
}
|
|
1001
|
+
if (schemaCollisions.length > 0) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`Refusing to install the OpenSpec product schema: ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE} already contains files this integration does not manage (${schemaCollisions.join(", ")}). Move them aside or delete them, then run: prodshape integration add openspec. The integration never overwrites user-authored files.`
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
if (schemaWrites.length > 0) {
|
|
1007
|
+
changes.push(
|
|
1008
|
+
recordedSchema !== void 0 || !schemaInstalledAnything ? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? "" : "s"}).` : `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}.`
|
|
1009
|
+
);
|
|
1010
|
+
if (compareVersions(openspecVersion, PRODUCT_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
1011
|
+
changes.push(
|
|
1012
|
+
`Product workflow unavailable until OpenSpec >= ${PRODUCT_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
for (const { relative: relative2 } of schemaDeletes) {
|
|
1017
|
+
changes.push(`Removed obsolete managed file ${relative2}.`);
|
|
1018
|
+
}
|
|
1019
|
+
for (const relative2 of schemaPreserved) {
|
|
1020
|
+
changes.push(
|
|
1021
|
+
`Preserved hand-edited managed file ${relative2}; restore or delete it, then run: prodshape integration update.`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
581
1025
|
const meta = {
|
|
582
1026
|
provider: "openspec",
|
|
583
1027
|
version: await productshapeVersion(),
|
|
584
1028
|
openspecVersion,
|
|
585
|
-
installedAt:
|
|
1029
|
+
installedAt: previousMeta?.installedAt ?? now,
|
|
586
1030
|
configPath: CONFIG_RELATIVE,
|
|
587
1031
|
ciExamplePath: CI_EXAMPLE_RELATIVE,
|
|
588
|
-
managed: currentManagedStrings()
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1032
|
+
managed: currentManagedStrings(),
|
|
1033
|
+
productSchema: {
|
|
1034
|
+
name: "product",
|
|
1035
|
+
requiresOpenspec: PRODUCT_SCHEMA_MIN_OPENSPEC,
|
|
1036
|
+
files: schemaFileDigests
|
|
593
1037
|
}
|
|
594
|
-
|
|
595
|
-
|
|
1038
|
+
};
|
|
1039
|
+
const updatedAt = changes.length > 0 && previousMeta !== null ? now : previousMeta?.updatedAt ?? void 0;
|
|
1040
|
+
if (updatedAt !== void 0) meta.updatedAt = updatedAt;
|
|
596
1041
|
const written = [];
|
|
597
1042
|
if (!dryRun) {
|
|
598
|
-
if (configChanges.length > 0
|
|
1043
|
+
if (configChanges.length > 0) {
|
|
599
1044
|
const serialized = serializeConfig(config);
|
|
600
1045
|
await mkdir(dirname(configPath(root)), { recursive: true });
|
|
601
1046
|
await writeFile(configPath(root), serialized, "utf8");
|
|
602
1047
|
written.push(CONFIG_RELATIVE);
|
|
603
1048
|
}
|
|
604
|
-
if (ciChanged
|
|
1049
|
+
if (ciChanged) {
|
|
605
1050
|
await mkdir(dirname(ciAbsolute), { recursive: true });
|
|
606
1051
|
await writeFile(ciAbsolute, ciDesired, "utf8");
|
|
607
1052
|
written.push(CI_EXAMPLE_RELATIVE);
|
|
608
1053
|
}
|
|
609
|
-
|
|
610
|
-
|
|
1054
|
+
for (const deletion of schemaDeletes) {
|
|
1055
|
+
await rm(deletion.absolute, { force: true });
|
|
1056
|
+
}
|
|
1057
|
+
for (const write of schemaWrites) {
|
|
1058
|
+
await mkdir(dirname(write.absolute), { recursive: true });
|
|
1059
|
+
await writeFile(write.absolute, write.content, "utf8");
|
|
1060
|
+
written.push(write.relative);
|
|
1061
|
+
}
|
|
1062
|
+
if (await writeMetaIfChanged(root, meta)) {
|
|
1063
|
+
written.push(META_RELATIVE);
|
|
1064
|
+
}
|
|
611
1065
|
}
|
|
612
1066
|
return { written, changes, meta };
|
|
613
1067
|
}
|
|
614
|
-
async function
|
|
1068
|
+
async function writeMetaIfChanged(root, meta) {
|
|
615
1069
|
const path = metaPath(root);
|
|
1070
|
+
const desired = `${JSON.stringify(meta, null, 2)}
|
|
1071
|
+
`;
|
|
1072
|
+
let existing;
|
|
1073
|
+
try {
|
|
1074
|
+
existing = await readFile4(path, "utf8");
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
if (!isNotFound2(error)) throw error;
|
|
1077
|
+
}
|
|
1078
|
+
if (existing === desired) return false;
|
|
616
1079
|
await mkdir(dirname(path), { recursive: true });
|
|
617
|
-
await writeFile(path,
|
|
618
|
-
|
|
1080
|
+
await writeFile(path, desired, "utf8");
|
|
1081
|
+
return true;
|
|
619
1082
|
}
|
|
1083
|
+
var OpenSpecMetaError = class extends Error {
|
|
1084
|
+
constructor(detail, options) {
|
|
1085
|
+
super(
|
|
1086
|
+
`OpenSpec integration metadata ${META_RELATIVE} cannot be trusted: ${detail}
|
|
1087
|
+
Reconcile it by hand, or remove it and re-run: prodshape integration add openspec`,
|
|
1088
|
+
options
|
|
1089
|
+
);
|
|
1090
|
+
this.name = "OpenSpecMetaError";
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
620
1093
|
async function readMeta(root) {
|
|
621
1094
|
const path = metaPath(root);
|
|
622
|
-
|
|
623
|
-
|
|
1095
|
+
let content;
|
|
1096
|
+
try {
|
|
1097
|
+
content = await readFile4(path, "utf8");
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
if (isNotFound2(error)) return null;
|
|
1100
|
+
throw new OpenSpecMetaError(
|
|
1101
|
+
`it exists but could not be read (${error instanceof Error ? error.message : String(error)})`,
|
|
1102
|
+
{ cause: error }
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
let parsed;
|
|
1106
|
+
try {
|
|
1107
|
+
parsed = JSON.parse(content);
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
throw new OpenSpecMetaError(
|
|
1110
|
+
`it is not valid JSON (${error instanceof Error ? error.message : String(error)})`,
|
|
1111
|
+
{ cause: error }
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1115
|
+
throw new OpenSpecMetaError("the document is not a JSON object");
|
|
1116
|
+
}
|
|
1117
|
+
const record = parsed;
|
|
1118
|
+
if (record.provider !== "openspec" || typeof record.installedAt !== "string") {
|
|
1119
|
+
throw new OpenSpecMetaError(
|
|
1120
|
+
"it does not match the metadata schema ('provider' must be 'openspec' and 'installedAt' a string)"
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
const meta = record;
|
|
1124
|
+
try {
|
|
1125
|
+
recordedProductSchemaFiles(meta);
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
throw new OpenSpecMetaError(
|
|
1128
|
+
`it does not match the product schema ownership record (${error instanceof Error ? error.message : String(error)})`,
|
|
1129
|
+
{ cause: error }
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
return meta;
|
|
624
1133
|
}
|
|
625
1134
|
async function updateOpenSpecIntegration(root, options = {}) {
|
|
626
1135
|
const result = await addOpenSpecIntegration(root, {
|
|
@@ -633,6 +1142,7 @@ async function updateOpenSpecIntegration(root, options = {}) {
|
|
|
633
1142
|
async function checkOpenSpecIntegration(root) {
|
|
634
1143
|
const checks = [];
|
|
635
1144
|
const detected = await detectOpenSpecVersion(root);
|
|
1145
|
+
const detectedVersion = "error" in detected ? void 0 : detected.version;
|
|
636
1146
|
if ("error" in detected) {
|
|
637
1147
|
checks.push({ name: "openspec CLI", ok: false, detail: detected.error });
|
|
638
1148
|
} else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
@@ -689,7 +1199,7 @@ async function checkOpenSpecIntegration(root) {
|
|
|
689
1199
|
checks.push({
|
|
690
1200
|
name: "metadata",
|
|
691
1201
|
ok: true,
|
|
692
|
-
detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`
|
|
1202
|
+
detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}${meta.updatedAt ? `, updated ${meta.updatedAt}` : ""}).`
|
|
693
1203
|
});
|
|
694
1204
|
}
|
|
695
1205
|
if (await pathExists(ciExamplePath(root))) {
|
|
@@ -724,13 +1234,99 @@ async function checkOpenSpecIntegration(root) {
|
|
|
724
1234
|
checks.push({ name: "rules", ok: true, detail: "All PDaC citation rules present." });
|
|
725
1235
|
}
|
|
726
1236
|
}
|
|
1237
|
+
const schemaAssets = await loadProductSchemaAssets();
|
|
1238
|
+
if (meta === null || meta.productSchema === void 0) {
|
|
1239
|
+
checks.push({
|
|
1240
|
+
name: "product workflow",
|
|
1241
|
+
ok: false,
|
|
1242
|
+
detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}). Run: prodshape integration update`
|
|
1243
|
+
});
|
|
1244
|
+
} else {
|
|
1245
|
+
const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
|
|
1246
|
+
const currentSchemaPaths = new Set(
|
|
1247
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`)
|
|
1248
|
+
);
|
|
1249
|
+
const missing = [];
|
|
1250
|
+
const unrecorded = [];
|
|
1251
|
+
const outdated = [];
|
|
1252
|
+
const diverged = [];
|
|
1253
|
+
const obsolete = Object.keys(recordedSchema).filter((relative2) => !currentSchemaPaths.has(relative2)).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
1254
|
+
for (const asset of schemaAssets) {
|
|
1255
|
+
const relative2 = `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1256
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1257
|
+
const existing = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
|
|
1258
|
+
const recordedDigest = recordedSchema[relative2];
|
|
1259
|
+
if (recordedDigest === void 0) {
|
|
1260
|
+
unrecorded.push(relative2);
|
|
1261
|
+
} else if (existing === null) {
|
|
1262
|
+
missing.push(relative2);
|
|
1263
|
+
} else if (existing !== asset.content) {
|
|
1264
|
+
if (recordedDigest !== "" && contentDigest(existing) === recordedDigest) {
|
|
1265
|
+
outdated.push(relative2);
|
|
1266
|
+
} else {
|
|
1267
|
+
diverged.push(relative2);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (missing.length > 0 || unrecorded.length > 0 || outdated.length > 0 || diverged.length > 0 || obsolete.length > 0) {
|
|
1272
|
+
const parts = [];
|
|
1273
|
+
if (missing.length > 0) {
|
|
1274
|
+
parts.push(`missing: ${missing.join(", ")} (run: prodshape integration update)`);
|
|
1275
|
+
}
|
|
1276
|
+
if (unrecorded.length > 0) {
|
|
1277
|
+
parts.push(
|
|
1278
|
+
`unrecorded: ${unrecorded.join(", ")} (move user files aside, then run: prodshape integration update)`
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
if (outdated.length > 0) {
|
|
1282
|
+
parts.push(
|
|
1283
|
+
`outdated managed content: ${outdated.join(", ")} (run: prodshape integration update)`
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
if (diverged.length > 0) {
|
|
1287
|
+
parts.push(
|
|
1288
|
+
`hand-edited and preserved: ${diverged.join(", ")} (restore or delete them, then run: prodshape integration update)`
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
if (obsolete.length > 0) {
|
|
1292
|
+
parts.push(
|
|
1293
|
+
`obsolete managed content: ${obsolete.join(", ")} (run: prodshape integration update)`
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
checks.push({
|
|
1297
|
+
name: "product workflow",
|
|
1298
|
+
ok: false,
|
|
1299
|
+
detail: `Product schema files need attention. ${parts.join(" ")}`
|
|
1300
|
+
});
|
|
1301
|
+
} else if (detectedVersion === void 0) {
|
|
1302
|
+
checks.push({
|
|
1303
|
+
name: "product workflow",
|
|
1304
|
+
ok: true,
|
|
1305
|
+
detail: "Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy."
|
|
1306
|
+
});
|
|
1307
|
+
} else if (compareVersions(detectedVersion, PRODUCT_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
1308
|
+
checks.push({
|
|
1309
|
+
name: "product workflow",
|
|
1310
|
+
ok: true,
|
|
1311
|
+
detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${PRODUCT_SCHEMA_MIN_OPENSPEC}, detected ${detectedVersion}. The citation lane is unaffected; upgrade OpenSpec to use the product schema.`
|
|
1312
|
+
});
|
|
1313
|
+
} else {
|
|
1314
|
+
checks.push({
|
|
1315
|
+
name: "product workflow",
|
|
1316
|
+
ok: true,
|
|
1317
|
+
detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_SCHEMA_MIN_OPENSPEC} floor.`
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
727
1321
|
const ok = checks.every((c) => c.ok);
|
|
728
1322
|
return { ok, checks };
|
|
729
1323
|
}
|
|
730
1324
|
async function removeOpenSpecIntegration(root, options = {}) {
|
|
731
1325
|
const { dryRun = false } = options;
|
|
732
1326
|
const removed = [];
|
|
733
|
-
const
|
|
1327
|
+
const preserved = [];
|
|
1328
|
+
const previousMeta = await readMeta(root);
|
|
1329
|
+
const recorded = previousMeta?.managed;
|
|
734
1330
|
const current = currentManagedStrings();
|
|
735
1331
|
const managedRules = {};
|
|
736
1332
|
for (const key of /* @__PURE__ */ new Set([
|
|
@@ -831,38 +1427,101 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
831
1427
|
}
|
|
832
1428
|
removed.push(CI_EXAMPLE_RELATIVE);
|
|
833
1429
|
}
|
|
1430
|
+
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
1431
|
+
if (recordedSchema !== void 0) {
|
|
1432
|
+
const assetContentByRelative = new Map(
|
|
1433
|
+
(await loadProductSchemaAssets()).map((asset) => [
|
|
1434
|
+
`${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1435
|
+
asset.content
|
|
1436
|
+
])
|
|
1437
|
+
);
|
|
1438
|
+
const schemaFileSet = Object.keys(recordedSchema).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
1439
|
+
const pruneCandidates = /* @__PURE__ */ new Set();
|
|
1440
|
+
for (const relative2 of schemaFileSet) {
|
|
1441
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1442
|
+
if (!await pathExists(absolute)) continue;
|
|
1443
|
+
const existing = await readFile4(absolute, "utf8");
|
|
1444
|
+
const recordedDigest = recordedSchema[relative2];
|
|
1445
|
+
const proven = existing === assetContentByRelative.get(relative2) || recordedDigest !== void 0 && recordedDigest !== "" && contentDigest(existing) === recordedDigest;
|
|
1446
|
+
if (!proven) {
|
|
1447
|
+
preserved.push(relative2);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (!dryRun) {
|
|
1451
|
+
await rm(absolute, { force: true });
|
|
1452
|
+
let parent = dirname(relative2);
|
|
1453
|
+
while (parent.startsWith("openspec/schemas") && parent !== "openspec/schemas" && parent !== "openspec") {
|
|
1454
|
+
pruneCandidates.add(parent);
|
|
1455
|
+
parent = parent.split("/").slice(0, -1).join("/");
|
|
1456
|
+
}
|
|
1457
|
+
pruneCandidates.add("openspec/schemas");
|
|
1458
|
+
}
|
|
1459
|
+
removed.push(relative2);
|
|
1460
|
+
}
|
|
1461
|
+
for (const relative2 of [...assetContentByRelative.keys()].sort(
|
|
1462
|
+
(a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
1463
|
+
)) {
|
|
1464
|
+
if (recordedSchema[relative2] !== void 0) continue;
|
|
1465
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1466
|
+
if (await pathExists(absolute)) preserved.push(relative2);
|
|
1467
|
+
}
|
|
1468
|
+
if (!dryRun) {
|
|
1469
|
+
const ordered = [...pruneCandidates].sort((a, b) => b.length - a.length);
|
|
1470
|
+
for (const relative2 of ordered) {
|
|
1471
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1472
|
+
try {
|
|
1473
|
+
const entries = await readdir4(absolute);
|
|
1474
|
+
if (entries.length === 0) await rmdir(absolute);
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
if (!isNotFound2(error)) throw error;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
834
1481
|
if (await pathExists(metaPath(root))) {
|
|
835
1482
|
if (!dryRun) {
|
|
836
1483
|
await rm(metaPath(root), { force: true });
|
|
837
1484
|
}
|
|
838
1485
|
removed.push(META_RELATIVE);
|
|
839
1486
|
}
|
|
840
|
-
return { removed };
|
|
1487
|
+
return { removed, preserved };
|
|
841
1488
|
}
|
|
842
1489
|
export {
|
|
843
1490
|
MIN_SUPPORTED_OPENSPEC,
|
|
844
1491
|
OPENSPEC_CI_EXAMPLE_RELATIVE,
|
|
845
1492
|
OPENSPEC_META_RELATIVE,
|
|
846
1493
|
OPENSPEC_NPX_SPEC,
|
|
1494
|
+
OPENSPEC_PRODUCT_SCHEMA_NAME,
|
|
1495
|
+
OPENSPEC_PRODUCT_SCHEMA_RELATIVE,
|
|
1496
|
+
OPENSPEC_PRODUCT_SUBDIR,
|
|
847
1497
|
OPENSPEC_VERIFY_COMMAND,
|
|
1498
|
+
OpenSpecMetaError,
|
|
848
1499
|
PDAC_CONTEXT_BLOCK,
|
|
849
1500
|
PDAC_OPERATIONS,
|
|
850
1501
|
PDAC_RULES,
|
|
1502
|
+
PRODUCT_SCHEMA_MIN_OPENSPEC,
|
|
851
1503
|
addOpenSpecIntegration,
|
|
1504
|
+
applyOpenSpecProductChange,
|
|
852
1505
|
bootstrapOpenSpecWorkspace,
|
|
853
1506
|
checkOpenSpecIntegration,
|
|
854
1507
|
currentManagedStrings,
|
|
1508
|
+
deriveDeliveryContext,
|
|
855
1509
|
detectOpenSpecVersion,
|
|
856
1510
|
enumerateOpenSpecDocuments,
|
|
1511
|
+
inspectProductModel,
|
|
857
1512
|
isOpenSpecCliAvailable,
|
|
858
1513
|
isOpenSpecIntegrationInstalled,
|
|
859
1514
|
isOpenSpecWorkspace,
|
|
1515
|
+
listOpenSpecProductChanges,
|
|
1516
|
+
loadOpenSpecProductChange,
|
|
1517
|
+
loadProductSchemaAssets,
|
|
860
1518
|
mergeConfig,
|
|
861
1519
|
openSpecProvider,
|
|
862
1520
|
readOpenSpecConfig,
|
|
863
1521
|
removeOpenSpecIntegration,
|
|
864
1522
|
renderCiExample,
|
|
865
1523
|
serializeConfig,
|
|
866
|
-
updateOpenSpecIntegration
|
|
1524
|
+
updateOpenSpecIntegration,
|
|
1525
|
+
validateOpenSpecProductChange
|
|
867
1526
|
};
|
|
868
1527
|
//# sourceMappingURL=index.js.map
|