@prodshape/integration-openspec 0.6.0-alpha.0 → 0.6.0-alpha.3
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 +12 -8
- package/assets/{openspec-product-schema → openspec-product-change-schema}/schema.yaml +41 -10
- package/assets/{openspec-product-schema → openspec-product-change-schema}/scripts/product-apply.mjs +2 -2
- package/assets/{openspec-product-schema → openspec-product-change-schema}/scripts/product-validate.mjs +2 -2
- package/assets/openspec-product-change-schema/templates/change.md +51 -0
- package/assets/{openspec-product-schema → openspec-product-change-schema}/templates/proposal.md +12 -0
- package/assets/openspec-product-recovery-schema/schema.yaml +40 -0
- package/assets/openspec-product-recovery-schema/templates/brief.md +22 -0
- package/assets/openspec-product-recovery-schema/templates/report.md +15 -0
- package/dist/index.d.ts +21 -11
- package/dist/index.js +210 -64
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +207 -40
- package/src/{product-schema.ts → product-change-schema.ts} +29 -9
- package/src/product-workflow.ts +33 -38
- package/assets/openspec-product-schema/templates/change.md +0 -49
package/dist/index.js
CHANGED
|
@@ -9,15 +9,17 @@ import {
|
|
|
9
9
|
} from "@prodshape/core";
|
|
10
10
|
import { parse as parse2, stringify } from "yaml";
|
|
11
11
|
|
|
12
|
-
// src/product-schema.ts
|
|
12
|
+
// src/product-change-schema.ts
|
|
13
13
|
import { readdir, readFile } from "fs/promises";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { join } from "path";
|
|
16
|
-
var
|
|
17
|
-
var
|
|
18
|
-
var
|
|
16
|
+
var OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME = "product-change";
|
|
17
|
+
var OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE = "openspec/schemas/product-change";
|
|
18
|
+
var PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC = "1.7.0";
|
|
19
|
+
var OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME = "product-recovery";
|
|
20
|
+
var OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE = "openspec/schemas/product-recovery";
|
|
19
21
|
function assetsDir() {
|
|
20
|
-
return fileURLToPath(new URL("../assets/openspec-product-schema/", import.meta.url));
|
|
22
|
+
return fileURLToPath(new URL("../assets/openspec-product-change-schema/", import.meta.url));
|
|
21
23
|
}
|
|
22
24
|
async function collectFiles(dir, prefix) {
|
|
23
25
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -32,7 +34,7 @@ async function collectFiles(dir, prefix) {
|
|
|
32
34
|
}
|
|
33
35
|
return files;
|
|
34
36
|
}
|
|
35
|
-
async function
|
|
37
|
+
async function loadProductChangeSchemaAssets() {
|
|
36
38
|
const dir = assetsDir();
|
|
37
39
|
const relatives = await collectFiles(dir, "");
|
|
38
40
|
const assets = [];
|
|
@@ -44,6 +46,18 @@ async function loadProductSchemaAssets() {
|
|
|
44
46
|
}
|
|
45
47
|
return assets;
|
|
46
48
|
}
|
|
49
|
+
async function loadProductRecoverySchemaAssets() {
|
|
50
|
+
const dir = fileURLToPath(
|
|
51
|
+
new URL("../assets/openspec-product-recovery-schema/", import.meta.url)
|
|
52
|
+
);
|
|
53
|
+
const relatives = await collectFiles(dir, "");
|
|
54
|
+
return Promise.all(
|
|
55
|
+
relatives.sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map(async (relative2) => ({
|
|
56
|
+
relative: relative2,
|
|
57
|
+
content: await readFile(join(dir, ...relative2.split("/")), "utf8")
|
|
58
|
+
}))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
47
61
|
|
|
48
62
|
// src/process.ts
|
|
49
63
|
import crossSpawn from "cross-spawn";
|
|
@@ -298,21 +312,20 @@ import { join as join4 } from "path";
|
|
|
298
312
|
import { parse } from "yaml";
|
|
299
313
|
import {
|
|
300
314
|
analyzeImpact,
|
|
301
|
-
blockingDiagnostics,
|
|
302
315
|
compareCodePoints,
|
|
303
|
-
dedupeDiagnostics,
|
|
304
316
|
discoverChanges,
|
|
305
317
|
executeApply,
|
|
306
318
|
isNotFound,
|
|
307
319
|
loadChange,
|
|
308
320
|
openRepository,
|
|
309
|
-
|
|
321
|
+
planHostedProductChange,
|
|
310
322
|
preflightApply,
|
|
311
323
|
sortDiagnostics,
|
|
312
324
|
validateBaseline,
|
|
325
|
+
validateHostedProductChange,
|
|
313
326
|
validateChange
|
|
314
327
|
} from "@prodshape/core";
|
|
315
|
-
var OPENSPEC_PRODUCT_SUBDIR = "product";
|
|
328
|
+
var OPENSPEC_PRODUCT_SUBDIR = "product-change";
|
|
316
329
|
var OPENSPEC_CHANGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
317
330
|
function assertOpenSpecChangeName(changeName) {
|
|
318
331
|
if (changeName.length > 200 || !OPENSPEC_CHANGE_NAME.test(changeName)) {
|
|
@@ -389,7 +402,7 @@ async function readHostedSchemaPin(changeDir) {
|
|
|
389
402
|
function hostedSchemaPinError(changeName, pin) {
|
|
390
403
|
if (pin.problem === "missing") {
|
|
391
404
|
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 ${
|
|
405
|
+
`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_CHANGE_SCHEMA_NAME}.`
|
|
393
406
|
);
|
|
394
407
|
}
|
|
395
408
|
return new Error(
|
|
@@ -414,7 +427,7 @@ async function listOpenSpecProductChanges(root) {
|
|
|
414
427
|
assertOpenSpecChangeName(name);
|
|
415
428
|
const pin = await readHostedSchemaPin(changeDir);
|
|
416
429
|
if ("problem" in pin) throw hostedSchemaPinError(name, pin);
|
|
417
|
-
if (pin.schema !==
|
|
430
|
+
if (pin.schema !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME) continue;
|
|
418
431
|
refs.push({
|
|
419
432
|
name,
|
|
420
433
|
dir,
|
|
@@ -437,16 +450,16 @@ async function loadHostedChange(root, repo, changeName) {
|
|
|
437
450
|
const refs = await listOpenSpecProductChanges(root);
|
|
438
451
|
const known = refs.map((candidate) => candidate.name).join(", ") || "none";
|
|
439
452
|
throw new Error(
|
|
440
|
-
`No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product/change.md; found: ${known}).`
|
|
453
|
+
`No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product-change/change.md; found: ${known}).`
|
|
441
454
|
);
|
|
442
455
|
}
|
|
443
456
|
const pin = await readHostedSchemaPin(changeDir);
|
|
444
457
|
if ("problem" in pin) {
|
|
445
458
|
throw hostedSchemaPinError(changeName, pin);
|
|
446
459
|
}
|
|
447
|
-
if (pin.schema !==
|
|
460
|
+
if (pin.schema !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME) {
|
|
448
461
|
throw new Error(
|
|
449
|
-
`OpenSpec change '${changeName}' is pinned to schema '${pin.schema}', not '${
|
|
462
|
+
`OpenSpec change '${changeName}' is pinned to schema '${pin.schema}', not '${OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME}'; the hosted product rail refuses it. Product intent travels through a change created with: openspec new change <name> --schema ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME}.`
|
|
450
463
|
);
|
|
451
464
|
}
|
|
452
465
|
return loadChange(deltaDir, repo.root, repo.registry);
|
|
@@ -470,27 +483,28 @@ async function validateOpenSpecProductChange(root, changeName) {
|
|
|
470
483
|
const all = await loadAllLiveChanges(root, repo);
|
|
471
484
|
const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
|
|
472
485
|
const validation = validateChange(change, baseline.artifacts, all);
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
486
|
+
const hosted = validateHostedProductChange(
|
|
487
|
+
repo,
|
|
488
|
+
baseline.artifacts,
|
|
489
|
+
change,
|
|
490
|
+
all,
|
|
491
|
+
baseline.diagnostics
|
|
479
492
|
);
|
|
493
|
+
const diagnostics = hosted.diagnostics;
|
|
480
494
|
return {
|
|
481
495
|
change,
|
|
482
496
|
baseline,
|
|
483
497
|
overlayArtifacts: validation.overlayArtifacts,
|
|
484
498
|
overlayGraph: validation.overlayGraph,
|
|
485
499
|
diagnostics,
|
|
486
|
-
blocking:
|
|
500
|
+
blocking: hosted.blocking
|
|
487
501
|
};
|
|
488
502
|
}
|
|
489
503
|
function hostedName(root, change) {
|
|
490
504
|
const normalizedRoot = root.replaceAll("\\", "/");
|
|
491
505
|
const normalizedDir = change.dir.replaceAll("\\", "/");
|
|
492
506
|
const relative2 = normalizedDir.startsWith(normalizedRoot) ? normalizedDir.slice(normalizedRoot.length).replace(/^\//, "") : normalizedDir;
|
|
493
|
-
const match = relative2.match(/^openspec\/changes\/([^/]+)\/product$/);
|
|
507
|
+
const match = relative2.match(/^openspec\/changes\/([^/]+)\/product-change$/);
|
|
494
508
|
return match?.[1];
|
|
495
509
|
}
|
|
496
510
|
async function applyOpenSpecProductChange(root, changeName, options = {}) {
|
|
@@ -499,24 +513,20 @@ async function applyOpenSpecProductChange(root, changeName, options = {}) {
|
|
|
499
513
|
const baseline = await validateBaseline(repo);
|
|
500
514
|
const all = await loadAllLiveChanges(root, repo);
|
|
501
515
|
const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
...validation.diagnostics
|
|
509
|
-
])
|
|
510
|
-
),
|
|
511
|
-
repo.config.validation["warnings-as-errors"]
|
|
516
|
+
const hosted = validateHostedProductChange(
|
|
517
|
+
repo,
|
|
518
|
+
baseline.artifacts,
|
|
519
|
+
change,
|
|
520
|
+
all,
|
|
521
|
+
baseline.diagnostics
|
|
512
522
|
);
|
|
513
|
-
const rawPlan = await
|
|
523
|
+
const rawPlan = await planHostedProductChange({
|
|
514
524
|
repoRoot: repo.root,
|
|
515
525
|
modelRelative: repo.config.product.model,
|
|
516
526
|
changesRelative: repo.config.product.changes,
|
|
517
527
|
change,
|
|
518
528
|
baseline: baseline.artifacts,
|
|
519
|
-
overlayErrors
|
|
529
|
+
overlayErrors: hosted.blocking
|
|
520
530
|
});
|
|
521
531
|
const plan = {
|
|
522
532
|
...rawPlan,
|
|
@@ -596,14 +606,14 @@ A citation binds consumer text to a canonical artifact: it records the artifact
|
|
|
596
606
|
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.
|
|
597
607
|
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.
|
|
598
608
|
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.
|
|
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.
|
|
609
|
+
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/change.md plus product-change/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.
|
|
600
610
|
${PDAC_CONTEXT_END}`;
|
|
601
611
|
var PDAC_RULES = {
|
|
602
612
|
proposal: [
|
|
603
613
|
"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.",
|
|
604
614
|
"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.",
|
|
605
615
|
'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.',
|
|
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-
|
|
616
|
+
"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-change OpenSpec change.",
|
|
607
617
|
'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.'
|
|
608
618
|
],
|
|
609
619
|
specs: [
|
|
@@ -640,20 +650,22 @@ function currentManagedStrings() {
|
|
|
640
650
|
)
|
|
641
651
|
};
|
|
642
652
|
}
|
|
653
|
+
var LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME = "product";
|
|
654
|
+
var LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE = "openspec/schemas/product";
|
|
643
655
|
function recordedProductSchemaFiles(meta) {
|
|
644
656
|
const record = meta?.productSchema;
|
|
645
657
|
if (!record) return void 0;
|
|
646
|
-
if (typeof record !== "object" || record === null || record.name !==
|
|
658
|
+
if (typeof record !== "object" || record === null || record.name !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME && record.name !== LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME && record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME || typeof record.requiresOpenspec !== "string") {
|
|
647
659
|
throw new Error(
|
|
648
660
|
"'productSchema' must name the product schema and carry a string 'requiresOpenspec'"
|
|
649
661
|
);
|
|
650
662
|
}
|
|
651
663
|
const files = record.files;
|
|
652
|
-
const validatePath = (file) => typeof file === "string" && file.startsWith(`${
|
|
664
|
+
const validatePath = (file) => typeof file === "string" && (file.startsWith(`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/`) || file.startsWith(`${LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) || file.startsWith(`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/`)) && isRepositoryRelativePath(file);
|
|
653
665
|
if (Array.isArray(files)) {
|
|
654
666
|
if (!files.every(validatePath)) {
|
|
655
667
|
throw new Error(
|
|
656
|
-
`'productSchema.files' contains a path outside
|
|
668
|
+
`'productSchema.files' contains a path outside the managed OpenSpec schema locations`
|
|
657
669
|
);
|
|
658
670
|
}
|
|
659
671
|
return Object.fromEntries(files.map((file) => [file, ""]));
|
|
@@ -662,7 +674,7 @@ function recordedProductSchemaFiles(meta) {
|
|
|
662
674
|
const entries = Object.entries(files);
|
|
663
675
|
if (!entries.every(([file]) => validatePath(file))) {
|
|
664
676
|
throw new Error(
|
|
665
|
-
`'productSchema.files' contains a path outside
|
|
677
|
+
`'productSchema.files' contains a path outside the managed OpenSpec schema locations`
|
|
666
678
|
);
|
|
667
679
|
}
|
|
668
680
|
if (!entries.every(
|
|
@@ -674,6 +686,19 @@ function recordedProductSchemaFiles(meta) {
|
|
|
674
686
|
}
|
|
675
687
|
throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
|
|
676
688
|
}
|
|
689
|
+
function recordedRecoverySchemaFiles(meta) {
|
|
690
|
+
const record = meta?.recoverySchema;
|
|
691
|
+
if (!record) return void 0;
|
|
692
|
+
if (record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME || typeof record.requiresOpenspec !== "string") {
|
|
693
|
+
throw new Error(
|
|
694
|
+
"'recoverySchema' must name the product-recovery schema and carry a string 'requiresOpenspec'"
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (!record.files || typeof record.files !== "object" || Array.isArray(record.files)) {
|
|
698
|
+
throw new Error("'recoverySchema.files' must be a path-to-digest object");
|
|
699
|
+
}
|
|
700
|
+
return record.files;
|
|
701
|
+
}
|
|
677
702
|
var CONFIG_RELATIVE = "openspec/config.yaml";
|
|
678
703
|
var META_RELATIVE = ".product/integrations/openspec.json";
|
|
679
704
|
var CI_EXAMPLE_RELATIVE = ".product/integrations/openspec.ci.yml";
|
|
@@ -947,10 +972,10 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
947
972
|
ciExisting === null ? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.` : `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
|
|
948
973
|
);
|
|
949
974
|
}
|
|
950
|
-
const schemaAssets = await
|
|
975
|
+
const schemaAssets = await loadProductChangeSchemaAssets();
|
|
951
976
|
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
952
977
|
const currentSchemaPaths = new Set(
|
|
953
|
-
schemaAssets.map((asset) => `${
|
|
978
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`)
|
|
954
979
|
);
|
|
955
980
|
const schemaFileDigests = {};
|
|
956
981
|
const schemaWrites = [];
|
|
@@ -959,7 +984,7 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
959
984
|
const schemaPreserved = [];
|
|
960
985
|
let schemaInstalledAnything = false;
|
|
961
986
|
for (const asset of schemaAssets) {
|
|
962
|
-
const relative2 = `${
|
|
987
|
+
const relative2 = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
963
988
|
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
964
989
|
const existing2 = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
|
|
965
990
|
const assetDigest = contentDigest(asset.content);
|
|
@@ -1000,16 +1025,16 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
1000
1025
|
}
|
|
1001
1026
|
if (schemaCollisions.length > 0) {
|
|
1002
1027
|
throw new Error(
|
|
1003
|
-
`Refusing to install the OpenSpec product schema: ${
|
|
1028
|
+
`Refusing to install the OpenSpec product schema: ${OPENSPEC_PRODUCT_CHANGE_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
1029
|
);
|
|
1005
1030
|
}
|
|
1006
1031
|
if (schemaWrites.length > 0) {
|
|
1007
1032
|
changes.push(
|
|
1008
|
-
recordedSchema !== void 0 || !schemaInstalledAnything ? `Updated the OpenSpec product schema at ${
|
|
1033
|
+
recordedSchema !== void 0 || !schemaInstalledAnything ? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? "" : "s"}).` : `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}.`
|
|
1009
1034
|
);
|
|
1010
|
-
if (compareVersions(openspecVersion,
|
|
1035
|
+
if (compareVersions(openspecVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
1011
1036
|
changes.push(
|
|
1012
|
-
`Product workflow unavailable until OpenSpec >= ${
|
|
1037
|
+
`Product workflow unavailable until OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`
|
|
1013
1038
|
);
|
|
1014
1039
|
}
|
|
1015
1040
|
}
|
|
@@ -1021,6 +1046,44 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
1021
1046
|
`Preserved hand-edited managed file ${relative2}; restore or delete it, then run: prodshape integration update.`
|
|
1022
1047
|
);
|
|
1023
1048
|
}
|
|
1049
|
+
const recoveryAssets = await loadProductRecoverySchemaAssets();
|
|
1050
|
+
const previousRecovery = recordedRecoverySchemaFiles(previousMeta);
|
|
1051
|
+
const recoveryDigests = {};
|
|
1052
|
+
const recoveryWrites = [];
|
|
1053
|
+
const recoveryCollisions = [];
|
|
1054
|
+
for (const asset of recoveryAssets) {
|
|
1055
|
+
const relative2 = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1056
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1057
|
+
const existing2 = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
|
|
1058
|
+
const digest = contentDigest(asset.content);
|
|
1059
|
+
const recorded = previousRecovery?.[relative2];
|
|
1060
|
+
if (existing2 === null) {
|
|
1061
|
+
recoveryWrites.push({ relative: relative2, absolute, content: asset.content });
|
|
1062
|
+
recoveryDigests[relative2] = digest;
|
|
1063
|
+
} else if (recorded === void 0) {
|
|
1064
|
+
recoveryCollisions.push(relative2);
|
|
1065
|
+
} else if (existing2 === asset.content) {
|
|
1066
|
+
recoveryDigests[relative2] = digest;
|
|
1067
|
+
} else if (recorded !== "" && contentDigest(existing2) === recorded) {
|
|
1068
|
+
recoveryWrites.push({ relative: relative2, absolute, content: asset.content });
|
|
1069
|
+
recoveryDigests[relative2] = digest;
|
|
1070
|
+
} else {
|
|
1071
|
+
recoveryDigests[relative2] = recorded;
|
|
1072
|
+
changes.push(
|
|
1073
|
+
`Preserved hand-edited managed file ${relative2}; restore or delete it, then run: prodshape integration update.`
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (recoveryCollisions.length > 0) {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`Refusing to install the OpenSpec product-recovery schema: unrecorded files exist (${recoveryCollisions.join(", ")}). The integration never overwrites user-authored files.`
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
if (recoveryWrites.length > 0) {
|
|
1083
|
+
changes.push(
|
|
1084
|
+
previousRecovery === void 0 ? `Installed the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.` : `Updated the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE} (${recoveryWrites.length} files).`
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1024
1087
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1025
1088
|
const meta = {
|
|
1026
1089
|
provider: "openspec",
|
|
@@ -1031,9 +1094,14 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
1031
1094
|
ciExamplePath: CI_EXAMPLE_RELATIVE,
|
|
1032
1095
|
managed: currentManagedStrings(),
|
|
1033
1096
|
productSchema: {
|
|
1034
|
-
name:
|
|
1035
|
-
requiresOpenspec:
|
|
1097
|
+
name: OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
1098
|
+
requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
1036
1099
|
files: schemaFileDigests
|
|
1100
|
+
},
|
|
1101
|
+
recoverySchema: {
|
|
1102
|
+
name: OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
1103
|
+
requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
1104
|
+
files: recoveryDigests
|
|
1037
1105
|
}
|
|
1038
1106
|
};
|
|
1039
1107
|
const updatedAt = changes.length > 0 && previousMeta !== null ? now : previousMeta?.updatedAt ?? void 0;
|
|
@@ -1059,6 +1127,11 @@ async function addOpenSpecIntegration(root, options = {}) {
|
|
|
1059
1127
|
await writeFile(write.absolute, write.content, "utf8");
|
|
1060
1128
|
written.push(write.relative);
|
|
1061
1129
|
}
|
|
1130
|
+
for (const write of recoveryWrites) {
|
|
1131
|
+
await mkdir(dirname(write.absolute), { recursive: true });
|
|
1132
|
+
await writeFile(write.absolute, write.content, "utf8");
|
|
1133
|
+
written.push(write.relative);
|
|
1134
|
+
}
|
|
1062
1135
|
if (await writeMetaIfChanged(root, meta)) {
|
|
1063
1136
|
written.push(META_RELATIVE);
|
|
1064
1137
|
}
|
|
@@ -1132,10 +1205,12 @@ async function readMeta(root) {
|
|
|
1132
1205
|
return meta;
|
|
1133
1206
|
}
|
|
1134
1207
|
async function updateOpenSpecIntegration(root, options = {}) {
|
|
1208
|
+
const previousMeta = await readMeta(root);
|
|
1135
1209
|
const result = await addOpenSpecIntegration(root, {
|
|
1136
1210
|
force: true,
|
|
1137
1211
|
dryRun: options.dryRun,
|
|
1138
|
-
warningsAsErrors: options.warningsAsErrors
|
|
1212
|
+
warningsAsErrors: options.warningsAsErrors,
|
|
1213
|
+
...previousMeta?.openspecVersion ? { cliVersion: previousMeta.openspecVersion } : {}
|
|
1139
1214
|
});
|
|
1140
1215
|
return { written: result.written, changes: result.changes };
|
|
1141
1216
|
}
|
|
@@ -1234,17 +1309,17 @@ async function checkOpenSpecIntegration(root) {
|
|
|
1234
1309
|
checks.push({ name: "rules", ok: true, detail: "All PDaC citation rules present." });
|
|
1235
1310
|
}
|
|
1236
1311
|
}
|
|
1237
|
-
const schemaAssets = await
|
|
1312
|
+
const schemaAssets = await loadProductChangeSchemaAssets();
|
|
1238
1313
|
if (meta === null || meta.productSchema === void 0) {
|
|
1239
1314
|
checks.push({
|
|
1240
1315
|
name: "product workflow",
|
|
1241
1316
|
ok: false,
|
|
1242
|
-
detail: `OpenSpec product schema not installed (${
|
|
1317
|
+
detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}). Run: prodshape integration update`
|
|
1243
1318
|
});
|
|
1244
1319
|
} else {
|
|
1245
1320
|
const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
|
|
1246
1321
|
const currentSchemaPaths = new Set(
|
|
1247
|
-
schemaAssets.map((asset) => `${
|
|
1322
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`)
|
|
1248
1323
|
);
|
|
1249
1324
|
const missing = [];
|
|
1250
1325
|
const unrecorded = [];
|
|
@@ -1252,7 +1327,7 @@ async function checkOpenSpecIntegration(root) {
|
|
|
1252
1327
|
const diverged = [];
|
|
1253
1328
|
const obsolete = Object.keys(recordedSchema).filter((relative2) => !currentSchemaPaths.has(relative2)).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
1254
1329
|
for (const asset of schemaAssets) {
|
|
1255
|
-
const relative2 = `${
|
|
1330
|
+
const relative2 = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1256
1331
|
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1257
1332
|
const existing = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
|
|
1258
1333
|
const recordedDigest = recordedSchema[relative2];
|
|
@@ -1304,20 +1379,52 @@ async function checkOpenSpecIntegration(root) {
|
|
|
1304
1379
|
ok: true,
|
|
1305
1380
|
detail: "Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy."
|
|
1306
1381
|
});
|
|
1307
|
-
} else if (compareVersions(detectedVersion,
|
|
1382
|
+
} else if (compareVersions(detectedVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
1308
1383
|
checks.push({
|
|
1309
1384
|
name: "product workflow",
|
|
1310
1385
|
ok: true,
|
|
1311
|
-
detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${
|
|
1386
|
+
detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC}, detected ${detectedVersion}. The citation lane is unaffected; upgrade OpenSpec to use the product schema.`
|
|
1312
1387
|
});
|
|
1313
1388
|
} else {
|
|
1314
1389
|
checks.push({
|
|
1315
1390
|
name: "product workflow",
|
|
1316
1391
|
ok: true,
|
|
1317
|
-
detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${
|
|
1392
|
+
detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} floor.`
|
|
1318
1393
|
});
|
|
1319
1394
|
}
|
|
1320
1395
|
}
|
|
1396
|
+
const recoveryAssets = await loadProductRecoverySchemaAssets();
|
|
1397
|
+
const recoveryRecord = recordedRecoverySchemaFiles(meta);
|
|
1398
|
+
if (recoveryRecord === void 0) {
|
|
1399
|
+
checks.push({
|
|
1400
|
+
name: "product recovery workflow",
|
|
1401
|
+
// Existing installations predate #229. Keep the citation and existing product-change
|
|
1402
|
+
// lanes healthy while advertising the opt-in migration; a fresh add/update records the
|
|
1403
|
+
// recovery schema and is checked strictly below.
|
|
1404
|
+
ok: true,
|
|
1405
|
+
detail: `Product recovery workflow UNAVAILABLE: schema not installed (${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}). Run: prodshape integration update to enable it.`
|
|
1406
|
+
});
|
|
1407
|
+
} else {
|
|
1408
|
+
const defects = [];
|
|
1409
|
+
for (const asset of recoveryAssets) {
|
|
1410
|
+
const relative2 = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1411
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1412
|
+
const expected = recoveryRecord[relative2];
|
|
1413
|
+
if (expected === void 0) defects.push(`unrecorded: ${relative2}`);
|
|
1414
|
+
else if (!await pathExists(absolute)) defects.push(`missing: ${relative2}`);
|
|
1415
|
+
else {
|
|
1416
|
+
const actual = await readFile4(absolute, "utf8");
|
|
1417
|
+
if (actual !== asset.content && contentDigest(actual) !== expected) {
|
|
1418
|
+
defects.push(`hand-edited and preserved: ${relative2}`);
|
|
1419
|
+
} else if (actual !== asset.content) defects.push(`outdated managed content: ${relative2}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
checks.push({
|
|
1423
|
+
name: "product recovery workflow",
|
|
1424
|
+
ok: defects.length === 0,
|
|
1425
|
+
detail: defects.length === 0 ? `Product recovery workflow available: schema installed at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.` : `Product-recovery schema files need attention. ${defects.join(" ")}`
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1321
1428
|
const ok = checks.every((c) => c.ok);
|
|
1322
1429
|
return { ok, checks };
|
|
1323
1430
|
}
|
|
@@ -1430,8 +1537,8 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
1430
1537
|
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
1431
1538
|
if (recordedSchema !== void 0) {
|
|
1432
1539
|
const assetContentByRelative = new Map(
|
|
1433
|
-
(await
|
|
1434
|
-
`${
|
|
1540
|
+
(await loadProductChangeSchemaAssets()).map((asset) => [
|
|
1541
|
+
`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1435
1542
|
asset.content
|
|
1436
1543
|
])
|
|
1437
1544
|
);
|
|
@@ -1478,6 +1585,42 @@ async function removeOpenSpecIntegration(root, options = {}) {
|
|
|
1478
1585
|
}
|
|
1479
1586
|
}
|
|
1480
1587
|
}
|
|
1588
|
+
const recordedRecovery = recordedRecoverySchemaFiles(previousMeta);
|
|
1589
|
+
if (recordedRecovery !== void 0) {
|
|
1590
|
+
const recoveryAssets = new Map(
|
|
1591
|
+
(await loadProductRecoverySchemaAssets()).map((asset) => [
|
|
1592
|
+
`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1593
|
+
asset.content
|
|
1594
|
+
])
|
|
1595
|
+
);
|
|
1596
|
+
for (const relative2 of Object.keys(recordedRecovery).sort()) {
|
|
1597
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1598
|
+
if (!await pathExists(absolute)) continue;
|
|
1599
|
+
const existing = await readFile4(absolute, "utf8");
|
|
1600
|
+
const recordedDigest = recordedRecovery[relative2];
|
|
1601
|
+
const proven = existing === recoveryAssets.get(relative2) || recordedDigest !== "" && contentDigest(existing) === recordedDigest;
|
|
1602
|
+
if (!proven) {
|
|
1603
|
+
preserved.push(relative2);
|
|
1604
|
+
} else {
|
|
1605
|
+
if (!dryRun) await rm(absolute, { force: true });
|
|
1606
|
+
removed.push(relative2);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (!dryRun) {
|
|
1610
|
+
for (const relative2 of [
|
|
1611
|
+
`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/templates`,
|
|
1612
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
1613
|
+
"openspec/schemas"
|
|
1614
|
+
]) {
|
|
1615
|
+
const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
|
|
1616
|
+
try {
|
|
1617
|
+
if ((await readdir4(absolute)).length === 0) await rmdir(absolute);
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
if (!isNotFound2(error)) throw error;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1481
1624
|
if (await pathExists(metaPath(root))) {
|
|
1482
1625
|
if (!dryRun) {
|
|
1483
1626
|
await rm(metaPath(root), { force: true });
|
|
@@ -1491,15 +1634,17 @@ export {
|
|
|
1491
1634
|
OPENSPEC_CI_EXAMPLE_RELATIVE,
|
|
1492
1635
|
OPENSPEC_META_RELATIVE,
|
|
1493
1636
|
OPENSPEC_NPX_SPEC,
|
|
1494
|
-
|
|
1495
|
-
|
|
1637
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
1638
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
|
|
1639
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
1640
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
1496
1641
|
OPENSPEC_PRODUCT_SUBDIR,
|
|
1497
1642
|
OPENSPEC_VERIFY_COMMAND,
|
|
1498
1643
|
OpenSpecMetaError,
|
|
1499
1644
|
PDAC_CONTEXT_BLOCK,
|
|
1500
1645
|
PDAC_OPERATIONS,
|
|
1501
1646
|
PDAC_RULES,
|
|
1502
|
-
|
|
1647
|
+
PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
1503
1648
|
addOpenSpecIntegration,
|
|
1504
1649
|
applyOpenSpecProductChange,
|
|
1505
1650
|
bootstrapOpenSpecWorkspace,
|
|
@@ -1514,7 +1659,8 @@ export {
|
|
|
1514
1659
|
isOpenSpecWorkspace,
|
|
1515
1660
|
listOpenSpecProductChanges,
|
|
1516
1661
|
loadOpenSpecProductChange,
|
|
1517
|
-
|
|
1662
|
+
loadProductChangeSchemaAssets,
|
|
1663
|
+
loadProductRecoverySchemaAssets,
|
|
1518
1664
|
mergeConfig,
|
|
1519
1665
|
openSpecProvider,
|
|
1520
1666
|
readOpenSpecConfig,
|