@prodshape/integration-openspec 0.5.3 → 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 +68 -0
- package/assets/openspec-product-change-schema/schema.yaml +151 -0
- package/assets/openspec-product-change-schema/scripts/product-apply.mjs +105 -0
- package/assets/openspec-product-change-schema/scripts/product-validate.mjs +77 -0
- package/assets/openspec-product-change-schema/templates/change.md +51 -0
- package/assets/openspec-product-change-schema/templates/proposal.md +41 -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 +175 -4
- package/dist/index.js +794 -35
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +585 -19
- package/src/product-change-schema.ts +93 -0
- package/src/product-workflow.ts +552 -0
package/src/index.ts
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenSpec integration for Product Definition as Code.
|
|
2
|
+
* OpenSpec integration for Product Definition as Code, in two lanes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* verification: consumer documents cite canonical product artifacts by id + digest, and
|
|
9
|
-
* `prodshape citations verify` checks them.
|
|
4
|
+
* The citation lane configures OpenSpec's spec-driven delivery workflow: PDaC citation rules are
|
|
5
|
+
* merged into `openspec/config.yaml`, metadata is recorded under `.product/integrations/`, and
|
|
6
|
+
* consumer documents cite canonical product artifacts by id + digest for
|
|
7
|
+
* `prodshape citations verify` to check.
|
|
10
8
|
*
|
|
11
|
-
* The
|
|
9
|
+
* The product lane hosts the PDaC product workflow inside OpenSpec: the integration installs a
|
|
10
|
+
* project-local `product-change` schema at `openspec/schemas/product-change/`, OpenSpec's official project
|
|
11
|
+
* extension surface, whose changes host a Product Change delta that `product-workflow.ts`
|
|
12
|
+
* validates as an overlay and applies deterministically.
|
|
13
|
+
*
|
|
14
|
+
* Both lanes write only official OpenSpec surfaces plus ProductShape's own integration metadata.
|
|
15
|
+
* The integration never patches OpenSpec-generated commands or skills, never modifies OpenSpec's
|
|
16
|
+
* built-in schemas, and never writes into a native spec-driven change's documents.
|
|
17
|
+
*
|
|
18
|
+
* The merge logic is the heart of the citation lane: it preserves every field the user authored
|
|
12
19
|
* (schema, context, rules, operations, githubCopilot, and anything else) and only appends PDaC
|
|
13
20
|
* guidance, deduplicating identical entries so the operation is idempotent.
|
|
14
21
|
*/
|
|
15
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { mkdir, readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
16
23
|
import { dirname } from 'node:path';
|
|
17
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
contentDigest,
|
|
26
|
+
isNotFound,
|
|
27
|
+
isRepositoryRelativePath,
|
|
28
|
+
resolveInRepository,
|
|
29
|
+
} from '@prodshape/core';
|
|
18
30
|
import { parse, stringify } from 'yaml';
|
|
31
|
+
import {
|
|
32
|
+
loadProductChangeSchemaAssets,
|
|
33
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
34
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
|
|
35
|
+
PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
36
|
+
loadProductRecoverySchemaAssets,
|
|
37
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
38
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
39
|
+
} from './product-change-schema.js';
|
|
19
40
|
import { runCommand } from './process.js';
|
|
20
41
|
import { envWithLocalBin, isOpenSpecWorkspace, pathExists } from './workspace.js';
|
|
21
42
|
|
|
@@ -25,6 +46,34 @@ export {
|
|
|
25
46
|
isOpenSpecCliAvailable,
|
|
26
47
|
openSpecProvider,
|
|
27
48
|
} from './population.js';
|
|
49
|
+
export {
|
|
50
|
+
loadProductChangeSchemaAssets,
|
|
51
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
52
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
|
|
53
|
+
PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
54
|
+
loadProductRecoverySchemaAssets,
|
|
55
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
56
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
57
|
+
} from './product-change-schema.js';
|
|
58
|
+
export type { ProductChangeSchemaAsset } from './product-change-schema.js';
|
|
59
|
+
export {
|
|
60
|
+
applyOpenSpecProductChange,
|
|
61
|
+
deriveDeliveryContext,
|
|
62
|
+
inspectProductModel,
|
|
63
|
+
listOpenSpecProductChanges,
|
|
64
|
+
loadOpenSpecProductChange,
|
|
65
|
+
OPENSPEC_PRODUCT_SUBDIR,
|
|
66
|
+
validateOpenSpecProductChange,
|
|
67
|
+
} from './product-workflow.js';
|
|
68
|
+
export type {
|
|
69
|
+
DeliveryContextBundle,
|
|
70
|
+
DeliveryContextEntry,
|
|
71
|
+
OpenSpecProductApplyOutcome,
|
|
72
|
+
OpenSpecProductApplyResult,
|
|
73
|
+
OpenSpecProductChangeRef,
|
|
74
|
+
OpenSpecProductChangeValidation,
|
|
75
|
+
ProductModelInspection,
|
|
76
|
+
} from './product-workflow.js';
|
|
28
77
|
|
|
29
78
|
/** The minimum supported OpenSpec CLI version. */
|
|
30
79
|
export const MIN_SUPPORTED_OPENSPEC = '1.0.0';
|
|
@@ -50,7 +99,7 @@ A citation binds consumer text to a canonical artifact: it records the artifact
|
|
|
50
99
|
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.
|
|
51
100
|
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 — never fix it quietly.
|
|
52
101
|
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.
|
|
53
|
-
The accepted Product Definition changes only through a Product Change
|
|
102
|
+
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.
|
|
54
103
|
${PDAC_CONTEXT_END}`;
|
|
55
104
|
|
|
56
105
|
/** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */
|
|
@@ -59,7 +108,7 @@ export const PDAC_RULES: Record<string, string[]> = {
|
|
|
59
108
|
'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.',
|
|
60
109
|
'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 — the backlog item behind it, if there is one — 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.',
|
|
61
110
|
'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.',
|
|
62
|
-
'If the change implements altered product behaviour, name the Product Change (CHG id) whose applied or accepted artifacts it implements. If no overlay-validated
|
|
111
|
+
'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.',
|
|
63
112
|
'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.',
|
|
64
113
|
],
|
|
65
114
|
specs: [
|
|
@@ -137,6 +186,94 @@ export interface OpenSpecIntegrationMeta {
|
|
|
137
186
|
ciExamplePath?: string;
|
|
138
187
|
/** Absent in metadata written before the field existed; treated as the current guidance. */
|
|
139
188
|
managed?: ManagedStrings;
|
|
189
|
+
/**
|
|
190
|
+
* The installed OpenSpec product schema: its name, the OpenSpec floor the product workflow
|
|
191
|
+
* needs, and every managed file it consists of, each with the content digest it was installed
|
|
192
|
+
* at. The per-file record and digest are the ownership proof (ADR 0008): update replaces and
|
|
193
|
+
* remove deletes a file only when that exact path was recorded and its bytes still match the
|
|
194
|
+
* recorded digest (legacy path-only records additionally require the current asset bytes). A
|
|
195
|
+
* diverged or unrecorded file is preserved and reported, never overwritten or deleted. Absent in metadata written before the
|
|
196
|
+
* schema existed; `integration update` installs the schema and adds the record. Metadata
|
|
197
|
+
* written by earlier spike revisions carried a plain path array; it is read as paths with no
|
|
198
|
+
* recorded digest, so ownership then rests on matching the current assets.
|
|
199
|
+
*/
|
|
200
|
+
productSchema?: { name: string; requiresOpenspec: string; files: Record<string, string> };
|
|
201
|
+
recoverySchema?: { name: string; requiresOpenspec: string; files: Record<string, string> };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The pre-#227 schema location accepted only for safe, ownership-proven migration. */
|
|
205
|
+
const LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME = 'product';
|
|
206
|
+
const LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE = 'openspec/schemas/product';
|
|
207
|
+
|
|
208
|
+
/** The recorded product-schema files as path-to-digest, tolerating the earlier array shape. */
|
|
209
|
+
function recordedProductSchemaFiles(
|
|
210
|
+
meta: OpenSpecIntegrationMeta | null,
|
|
211
|
+
): Record<string, string> | undefined {
|
|
212
|
+
const record = meta?.productSchema;
|
|
213
|
+
if (!record) return undefined;
|
|
214
|
+
if (
|
|
215
|
+
typeof record !== 'object' ||
|
|
216
|
+
record === null ||
|
|
217
|
+
(record.name !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME &&
|
|
218
|
+
record.name !== LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME &&
|
|
219
|
+
record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME) ||
|
|
220
|
+
typeof record.requiresOpenspec !== 'string'
|
|
221
|
+
) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"'productSchema' must name the product schema and carry a string 'requiresOpenspec'",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const files = record.files as unknown;
|
|
227
|
+
const validatePath = (file: unknown): file is string =>
|
|
228
|
+
typeof file === 'string' &&
|
|
229
|
+
(file.startsWith(`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/`) ||
|
|
230
|
+
file.startsWith(`${LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) ||
|
|
231
|
+
file.startsWith(`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/`)) &&
|
|
232
|
+
isRepositoryRelativePath(file);
|
|
233
|
+
if (Array.isArray(files)) {
|
|
234
|
+
if (!files.every(validatePath)) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`'productSchema.files' contains a path outside the managed OpenSpec schema locations`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return Object.fromEntries(files.map((file) => [file, '']));
|
|
240
|
+
}
|
|
241
|
+
if (files !== null && typeof files === 'object') {
|
|
242
|
+
const entries = Object.entries(files as Record<string, unknown>);
|
|
243
|
+
if (!entries.every(([file]) => validatePath(file))) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`'productSchema.files' contains a path outside the managed OpenSpec schema locations`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
!entries.every(
|
|
250
|
+
([, digest]) => typeof digest === 'string' && /^sha256:[0-9a-f]{64}$/.test(digest),
|
|
251
|
+
)
|
|
252
|
+
) {
|
|
253
|
+
throw new Error("'productSchema.files' contains an invalid content digest");
|
|
254
|
+
}
|
|
255
|
+
return Object.fromEntries(entries as [string, string][]);
|
|
256
|
+
}
|
|
257
|
+
throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function recordedRecoverySchemaFiles(
|
|
261
|
+
meta: OpenSpecIntegrationMeta | null,
|
|
262
|
+
): Record<string, string> | undefined {
|
|
263
|
+
const record = meta?.recoverySchema;
|
|
264
|
+
if (!record) return undefined;
|
|
265
|
+
if (
|
|
266
|
+
record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME ||
|
|
267
|
+
typeof record.requiresOpenspec !== 'string'
|
|
268
|
+
) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
"'recoverySchema' must name the product-recovery schema and carry a string 'requiresOpenspec'",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (!record.files || typeof record.files !== 'object' || Array.isArray(record.files)) {
|
|
274
|
+
throw new Error("'recoverySchema.files' must be a path-to-digest object");
|
|
275
|
+
}
|
|
276
|
+
return record.files;
|
|
140
277
|
}
|
|
141
278
|
|
|
142
279
|
/** Repository-relative paths used by this integration. */
|
|
@@ -580,6 +717,148 @@ export async function addOpenSpecIntegration(
|
|
|
580
717
|
);
|
|
581
718
|
}
|
|
582
719
|
|
|
720
|
+
// The OpenSpec product schema: managed files under openspec/schemas/product-change, the framework's
|
|
721
|
+
// official project-local schema surface. The files are version-independent data, so they are
|
|
722
|
+
// installed regardless of the detected CLI version; whether the product workflow is USABLE on
|
|
723
|
+
// this CLI is a separate, capability-specific report (`checkOpenSpecIntegration`), never a
|
|
724
|
+
// different set of bytes.
|
|
725
|
+
//
|
|
726
|
+
// Ownership is proven per file, never inferred from the schema path or byte equality (ADR
|
|
727
|
+
// 0008): a file may be replaced only when the metadata recorded that exact path and its bytes
|
|
728
|
+
// still match the recorded digest. Any pre-existing unrecorded file is a collision, even when
|
|
729
|
+
// byte-identical to the asset; on update a diverged managed file is preserved and reported, and
|
|
730
|
+
// an obsolete still-managed file is removed only while its digest continues to match.
|
|
731
|
+
const schemaAssets = await loadProductChangeSchemaAssets();
|
|
732
|
+
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
733
|
+
const currentSchemaPaths = new Set(
|
|
734
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`),
|
|
735
|
+
);
|
|
736
|
+
const schemaFileDigests: Record<string, string> = {};
|
|
737
|
+
const schemaWrites: { relative: string; absolute: string; content: string }[] = [];
|
|
738
|
+
const schemaDeletes: { relative: string; absolute: string }[] = [];
|
|
739
|
+
const schemaCollisions: string[] = [];
|
|
740
|
+
const schemaPreserved: string[] = [];
|
|
741
|
+
let schemaInstalledAnything = false;
|
|
742
|
+
for (const asset of schemaAssets) {
|
|
743
|
+
const relative = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
744
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
745
|
+
const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
|
|
746
|
+
const assetDigest = contentDigest(asset.content);
|
|
747
|
+
if (existing === null) {
|
|
748
|
+
schemaWrites.push({ relative, absolute, content: asset.content });
|
|
749
|
+
schemaFileDigests[relative] = assetDigest;
|
|
750
|
+
schemaInstalledAnything = true;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const recordedDigest = recordedSchema?.[relative];
|
|
754
|
+
if (recordedDigest === undefined) {
|
|
755
|
+
// Ownership is per file. Byte equality proves compatibility, not authorship: adopting an
|
|
756
|
+
// identical user file on a fresh install or when a later release adds a new asset would let
|
|
757
|
+
// a subsequent remove delete content the integration never installed.
|
|
758
|
+
schemaCollisions.push(relative);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (existing === asset.content) {
|
|
762
|
+
// A recorded managed path is already byte-identical to the current asset: nothing to write.
|
|
763
|
+
schemaFileDigests[relative] = assetDigest;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (recordedDigest !== '' && contentDigest(existing) === recordedDigest) {
|
|
767
|
+
// Proven managed at an older recorded version: replace with the current assets.
|
|
768
|
+
schemaWrites.push({ relative, absolute, content: asset.content });
|
|
769
|
+
schemaFileDigests[relative] = assetDigest;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
// Managed path whose content diverged from both the record and the current assets: a hand
|
|
773
|
+
// edit. Preserve it, keep its recorded digest (restoring the recorded content re-enables
|
|
774
|
+
// management), and report it.
|
|
775
|
+
schemaPreserved.push(relative);
|
|
776
|
+
if (recordedDigest !== '') schemaFileDigests[relative] = recordedDigest;
|
|
777
|
+
}
|
|
778
|
+
for (const [relative, recordedDigest] of Object.entries(recordedSchema ?? {})) {
|
|
779
|
+
if (currentSchemaPaths.has(relative)) continue;
|
|
780
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
781
|
+
if (!(await pathExists(absolute))) continue;
|
|
782
|
+
const existing = await readFile(absolute, 'utf8');
|
|
783
|
+
if (recordedDigest !== '' && contentDigest(existing) === recordedDigest) {
|
|
784
|
+
schemaDeletes.push({ relative, absolute });
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// Ownership extends to absence, but deletion still requires the recorded digest proof. A
|
|
788
|
+
// diverged obsolete file stays user-visible and remains recorded so check/remove can continue
|
|
789
|
+
// reporting it; a digest-less legacy record is preserved but cannot become ownership proof.
|
|
790
|
+
schemaPreserved.push(relative);
|
|
791
|
+
if (recordedDigest !== '') schemaFileDigests[relative] = recordedDigest;
|
|
792
|
+
}
|
|
793
|
+
if (schemaCollisions.length > 0) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
`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.`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (schemaWrites.length > 0) {
|
|
799
|
+
changes.push(
|
|
800
|
+
recordedSchema !== undefined || !schemaInstalledAnything
|
|
801
|
+
? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? '' : 's'}).`
|
|
802
|
+
: `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}.`,
|
|
803
|
+
);
|
|
804
|
+
if (compareVersions(openspecVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
805
|
+
changes.push(
|
|
806
|
+
`Product workflow unavailable until OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
for (const { relative } of schemaDeletes) {
|
|
811
|
+
changes.push(`Removed obsolete managed file ${relative}.`);
|
|
812
|
+
}
|
|
813
|
+
for (const relative of schemaPreserved) {
|
|
814
|
+
changes.push(
|
|
815
|
+
`Preserved hand-edited managed file ${relative}; restore or delete it, then run: prodshape integration update.`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// The recovery workload has its own schema and ownership record. It is intentionally kept
|
|
820
|
+
// beside, rather than inside, product-change: recovery starts without an accepted baseline.
|
|
821
|
+
const recoveryAssets = await loadProductRecoverySchemaAssets();
|
|
822
|
+
const previousRecovery = recordedRecoverySchemaFiles(previousMeta);
|
|
823
|
+
const recoveryDigests: Record<string, string> = {};
|
|
824
|
+
const recoveryWrites: { relative: string; absolute: string; content: string }[] = [];
|
|
825
|
+
const recoveryCollisions: string[] = [];
|
|
826
|
+
for (const asset of recoveryAssets) {
|
|
827
|
+
const relative = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
828
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
829
|
+
const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
|
|
830
|
+
const digest = contentDigest(asset.content);
|
|
831
|
+
const recorded = previousRecovery?.[relative];
|
|
832
|
+
if (existing === null) {
|
|
833
|
+
recoveryWrites.push({ relative, absolute, content: asset.content });
|
|
834
|
+
recoveryDigests[relative] = digest;
|
|
835
|
+
} else if (recorded === undefined) {
|
|
836
|
+
recoveryCollisions.push(relative);
|
|
837
|
+
} else if (existing === asset.content) {
|
|
838
|
+
recoveryDigests[relative] = digest;
|
|
839
|
+
} else if (recorded !== '' && contentDigest(existing) === recorded) {
|
|
840
|
+
recoveryWrites.push({ relative, absolute, content: asset.content });
|
|
841
|
+
recoveryDigests[relative] = digest;
|
|
842
|
+
} else {
|
|
843
|
+
recoveryDigests[relative] = recorded;
|
|
844
|
+
changes.push(
|
|
845
|
+
`Preserved hand-edited managed file ${relative}; restore or delete it, then run: prodshape integration update.`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (recoveryCollisions.length > 0) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`Refusing to install the OpenSpec product-recovery schema: unrecorded files exist (${recoveryCollisions.join(', ')}). The integration never overwrites user-authored files.`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (recoveryWrites.length > 0) {
|
|
855
|
+
changes.push(
|
|
856
|
+
previousRecovery === undefined
|
|
857
|
+
? `Installed the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.`
|
|
858
|
+
: `Updated the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE} (${recoveryWrites.length} files).`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
583
862
|
// `installedAt` records when this integration was first installed, and is preserved from here
|
|
584
863
|
// on. Stamping a fresh timestamp on every invocation is what made a no-op `add` — and every
|
|
585
864
|
// `integration update` — rewrite this file, so a command that reported "already up to date"
|
|
@@ -594,6 +873,16 @@ export async function addOpenSpecIntegration(
|
|
|
594
873
|
configPath: CONFIG_RELATIVE,
|
|
595
874
|
ciExamplePath: CI_EXAMPLE_RELATIVE,
|
|
596
875
|
managed: currentManagedStrings(),
|
|
876
|
+
productSchema: {
|
|
877
|
+
name: OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
878
|
+
requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
879
|
+
files: schemaFileDigests,
|
|
880
|
+
},
|
|
881
|
+
recoverySchema: {
|
|
882
|
+
name: OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
883
|
+
requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
884
|
+
files: recoveryDigests,
|
|
885
|
+
},
|
|
597
886
|
};
|
|
598
887
|
const updatedAt =
|
|
599
888
|
changes.length > 0 && previousMeta !== null ? now : (previousMeta?.updatedAt ?? undefined);
|
|
@@ -616,6 +905,19 @@ export async function addOpenSpecIntegration(
|
|
|
616
905
|
await writeFile(ciAbsolute, ciDesired, 'utf8');
|
|
617
906
|
written.push(CI_EXAMPLE_RELATIVE);
|
|
618
907
|
}
|
|
908
|
+
for (const deletion of schemaDeletes) {
|
|
909
|
+
await rm(deletion.absolute, { force: true });
|
|
910
|
+
}
|
|
911
|
+
for (const write of schemaWrites) {
|
|
912
|
+
await mkdir(dirname(write.absolute), { recursive: true });
|
|
913
|
+
await writeFile(write.absolute, write.content, 'utf8');
|
|
914
|
+
written.push(write.relative);
|
|
915
|
+
}
|
|
916
|
+
for (const write of recoveryWrites) {
|
|
917
|
+
await mkdir(dirname(write.absolute), { recursive: true });
|
|
918
|
+
await writeFile(write.absolute, write.content, 'utf8');
|
|
919
|
+
written.push(write.relative);
|
|
920
|
+
}
|
|
619
921
|
if (await writeMetaIfChanged(root, meta)) {
|
|
620
922
|
written.push(META_RELATIVE);
|
|
621
923
|
}
|
|
@@ -693,7 +995,16 @@ async function readMeta(root: string): Promise<OpenSpecIntegrationMeta | null> {
|
|
|
693
995
|
"it does not match the metadata schema ('provider' must be 'openspec' and 'installedAt' a string)",
|
|
694
996
|
);
|
|
695
997
|
}
|
|
696
|
-
|
|
998
|
+
const meta = record as unknown as OpenSpecIntegrationMeta;
|
|
999
|
+
try {
|
|
1000
|
+
recordedProductSchemaFiles(meta);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
throw new OpenSpecMetaError(
|
|
1003
|
+
`it does not match the product schema ownership record (${error instanceof Error ? error.message : String(error)})`,
|
|
1004
|
+
{ cause: error },
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
return meta;
|
|
697
1008
|
}
|
|
698
1009
|
|
|
699
1010
|
/**
|
|
@@ -704,12 +1015,15 @@ export async function updateOpenSpecIntegration(
|
|
|
704
1015
|
root: string,
|
|
705
1016
|
options: { force?: boolean; dryRun?: boolean; warningsAsErrors?: boolean } = {},
|
|
706
1017
|
): Promise<{ written: string[]; changes: string[] }> {
|
|
707
|
-
//
|
|
708
|
-
//
|
|
1018
|
+
// An existing metadata record is the compatibility verdict established during installation.
|
|
1019
|
+
// Managed file reconciliation must remain usable in release jobs and repositories that no
|
|
1020
|
+
// longer have OpenSpec on PATH; initial installation and explicit health checks still probe it.
|
|
1021
|
+
const previousMeta = await readMeta(root);
|
|
709
1022
|
const result = await addOpenSpecIntegration(root, {
|
|
710
1023
|
force: true,
|
|
711
1024
|
dryRun: options.dryRun,
|
|
712
1025
|
warningsAsErrors: options.warningsAsErrors,
|
|
1026
|
+
...(previousMeta?.openspecVersion ? { cliVersion: previousMeta.openspecVersion } : {}),
|
|
713
1027
|
});
|
|
714
1028
|
return { written: result.written, changes: result.changes };
|
|
715
1029
|
}
|
|
@@ -720,6 +1034,9 @@ export async function updateOpenSpecIntegration(
|
|
|
720
1034
|
* - `openspec/config.yaml` exists and contains the PDaC context block.
|
|
721
1035
|
* - `.product/integrations/openspec.json` metadata exists.
|
|
722
1036
|
* - The config's PDaC block matches the current PDaC guidance (not removed or altered).
|
|
1037
|
+
* - The product schema's managed files are installed and intact, with the product workflow's
|
|
1038
|
+
* availability reported per capability: an OpenSpec CLI below the product floor leaves the
|
|
1039
|
+
* citation lane usable and states the product workflow UNAVAILABLE instead of failing it.
|
|
723
1040
|
*/
|
|
724
1041
|
export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
725
1042
|
ok: boolean;
|
|
@@ -729,6 +1046,7 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
729
1046
|
|
|
730
1047
|
// 1. OpenSpec CLI installed and supported.
|
|
731
1048
|
const detected = await detectOpenSpecVersion(root);
|
|
1049
|
+
const detectedVersion = 'error' in detected ? undefined : detected.version;
|
|
732
1050
|
if ('error' in detected) {
|
|
733
1051
|
checks.push({ name: 'openspec CLI', ok: false, detail: detected.error });
|
|
734
1052
|
} else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
@@ -838,6 +1156,142 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
838
1156
|
}
|
|
839
1157
|
}
|
|
840
1158
|
|
|
1159
|
+
// 5. Product workflow: managed schema state plus capability-specific availability. The schema
|
|
1160
|
+
// files are inert data valid under any CLI, so a floor-violating CLI is not a managed-state
|
|
1161
|
+
// defect; it makes the product workflow UNAVAILABLE, stated as the check's verdict rather than
|
|
1162
|
+
// buried as a footnote, while the citation lane stays independently usable.
|
|
1163
|
+
const schemaAssets = await loadProductChangeSchemaAssets();
|
|
1164
|
+
if (meta === null || meta.productSchema === undefined) {
|
|
1165
|
+
checks.push({
|
|
1166
|
+
name: 'product workflow',
|
|
1167
|
+
ok: false,
|
|
1168
|
+
detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}). Run: prodshape integration update`,
|
|
1169
|
+
});
|
|
1170
|
+
} else {
|
|
1171
|
+
const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
|
|
1172
|
+
const currentSchemaPaths = new Set(
|
|
1173
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`),
|
|
1174
|
+
);
|
|
1175
|
+
const missing: string[] = [];
|
|
1176
|
+
const unrecorded: string[] = [];
|
|
1177
|
+
const outdated: string[] = [];
|
|
1178
|
+
const diverged: string[] = [];
|
|
1179
|
+
const obsolete = Object.keys(recordedSchema)
|
|
1180
|
+
.filter((relative) => !currentSchemaPaths.has(relative))
|
|
1181
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1182
|
+
for (const asset of schemaAssets) {
|
|
1183
|
+
const relative = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1184
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1185
|
+
const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
|
|
1186
|
+
const recordedDigest = recordedSchema[relative];
|
|
1187
|
+
if (recordedDigest === undefined) {
|
|
1188
|
+
unrecorded.push(relative);
|
|
1189
|
+
} else if (existing === null) {
|
|
1190
|
+
missing.push(relative);
|
|
1191
|
+
} else if (existing !== asset.content) {
|
|
1192
|
+
if (recordedDigest !== '' && contentDigest(existing) === recordedDigest) {
|
|
1193
|
+
outdated.push(relative);
|
|
1194
|
+
} else {
|
|
1195
|
+
diverged.push(relative);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (
|
|
1200
|
+
missing.length > 0 ||
|
|
1201
|
+
unrecorded.length > 0 ||
|
|
1202
|
+
outdated.length > 0 ||
|
|
1203
|
+
diverged.length > 0 ||
|
|
1204
|
+
obsolete.length > 0
|
|
1205
|
+
) {
|
|
1206
|
+
const parts: string[] = [];
|
|
1207
|
+
if (missing.length > 0) {
|
|
1208
|
+
parts.push(`missing: ${missing.join(', ')} (run: prodshape integration update)`);
|
|
1209
|
+
}
|
|
1210
|
+
if (unrecorded.length > 0) {
|
|
1211
|
+
parts.push(
|
|
1212
|
+
`unrecorded: ${unrecorded.join(', ')} (move user files aside, then run: prodshape integration update)`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
if (outdated.length > 0) {
|
|
1216
|
+
parts.push(
|
|
1217
|
+
`outdated managed content: ${outdated.join(', ')} (run: prodshape integration update)`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
if (diverged.length > 0) {
|
|
1221
|
+
parts.push(
|
|
1222
|
+
`hand-edited and preserved: ${diverged.join(', ')} (restore or delete them, then run: prodshape integration update)`,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
if (obsolete.length > 0) {
|
|
1226
|
+
parts.push(
|
|
1227
|
+
`obsolete managed content: ${obsolete.join(', ')} (run: prodshape integration update)`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
checks.push({
|
|
1231
|
+
name: 'product workflow',
|
|
1232
|
+
ok: false,
|
|
1233
|
+
detail: `Product schema files need attention. ${parts.join(' ')}`,
|
|
1234
|
+
});
|
|
1235
|
+
} else if (detectedVersion === undefined) {
|
|
1236
|
+
checks.push({
|
|
1237
|
+
name: 'product workflow',
|
|
1238
|
+
ok: true,
|
|
1239
|
+
detail:
|
|
1240
|
+
'Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy.',
|
|
1241
|
+
});
|
|
1242
|
+
} else if (compareVersions(detectedVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
1243
|
+
checks.push({
|
|
1244
|
+
name: 'product workflow',
|
|
1245
|
+
ok: true,
|
|
1246
|
+
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.`,
|
|
1247
|
+
});
|
|
1248
|
+
} else {
|
|
1249
|
+
checks.push({
|
|
1250
|
+
name: 'product workflow',
|
|
1251
|
+
ok: true,
|
|
1252
|
+
detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} floor.`,
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// 6. Hosted recovery schema ownership is checked independently from product-change because
|
|
1258
|
+
// the two workloads have different preconditions and may evolve on different cadences.
|
|
1259
|
+
const recoveryAssets = await loadProductRecoverySchemaAssets();
|
|
1260
|
+
const recoveryRecord = recordedRecoverySchemaFiles(meta);
|
|
1261
|
+
if (recoveryRecord === undefined) {
|
|
1262
|
+
checks.push({
|
|
1263
|
+
name: 'product recovery workflow',
|
|
1264
|
+
// Existing installations predate #229. Keep the citation and existing product-change
|
|
1265
|
+
// lanes healthy while advertising the opt-in migration; a fresh add/update records the
|
|
1266
|
+
// recovery schema and is checked strictly below.
|
|
1267
|
+
ok: true,
|
|
1268
|
+
detail: `Product recovery workflow UNAVAILABLE: schema not installed (${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}). Run: prodshape integration update to enable it.`,
|
|
1269
|
+
});
|
|
1270
|
+
} else {
|
|
1271
|
+
const defects: string[] = [];
|
|
1272
|
+
for (const asset of recoveryAssets) {
|
|
1273
|
+
const relative = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1274
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1275
|
+
const expected = recoveryRecord[relative];
|
|
1276
|
+
if (expected === undefined) defects.push(`unrecorded: ${relative}`);
|
|
1277
|
+
else if (!(await pathExists(absolute))) defects.push(`missing: ${relative}`);
|
|
1278
|
+
else {
|
|
1279
|
+
const actual = await readFile(absolute, 'utf8');
|
|
1280
|
+
if (actual !== asset.content && contentDigest(actual) !== expected) {
|
|
1281
|
+
defects.push(`hand-edited and preserved: ${relative}`);
|
|
1282
|
+
} else if (actual !== asset.content) defects.push(`outdated managed content: ${relative}`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
checks.push({
|
|
1286
|
+
name: 'product recovery workflow',
|
|
1287
|
+
ok: defects.length === 0,
|
|
1288
|
+
detail:
|
|
1289
|
+
defects.length === 0
|
|
1290
|
+
? `Product recovery workflow available: schema installed at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.`
|
|
1291
|
+
: `Product-recovery schema files need attention. ${defects.join(' ')}`,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
841
1295
|
const ok = checks.every((c) => c.ok);
|
|
842
1296
|
return { ok, checks };
|
|
843
1297
|
}
|
|
@@ -849,20 +1303,25 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
849
1303
|
* - Removes the PDaC context block (between sentinels) from `context:`.
|
|
850
1304
|
* - Removes PDaC rules from each artifact (keeps user-authored rules).
|
|
851
1305
|
* - Removes PDaC operation guidance (keeps user-authored guidance).
|
|
1306
|
+
* - Deletes the installed product schema files still proven managed (recorded digest or current
|
|
1307
|
+
* asset bytes); hand-edited managed files are preserved and reported in `preserved`, and with
|
|
1308
|
+
* no recorded product schema nothing under openspec/schemas is touched.
|
|
852
1309
|
* - Deletes `.product/integrations/openspec.json`.
|
|
853
|
-
* - `--dry-run` reports what would be removed without writing.
|
|
1310
|
+
* - `--dry-run` reports what would be removed and preserved without writing.
|
|
854
1311
|
*/
|
|
855
1312
|
export async function removeOpenSpecIntegration(
|
|
856
1313
|
root: string,
|
|
857
1314
|
options: { dryRun?: boolean } = {},
|
|
858
|
-
): Promise<{ removed: string[] }> {
|
|
1315
|
+
): Promise<{ removed: string[]; preserved: string[] }> {
|
|
859
1316
|
const { dryRun = false } = options;
|
|
860
1317
|
const removed: string[] = [];
|
|
1318
|
+
const preserved: string[] = [];
|
|
861
1319
|
|
|
862
1320
|
// Remove both what the metadata recorded as injected and what the current guidance would
|
|
863
1321
|
// inject: the union covers metadata written before the record existed as well as entries left
|
|
864
1322
|
// behind by an older ProductShape.
|
|
865
|
-
const
|
|
1323
|
+
const previousMeta = await readMeta(root);
|
|
1324
|
+
const recorded = previousMeta?.managed;
|
|
866
1325
|
const current = currentManagedStrings();
|
|
867
1326
|
const managedRules: Record<string, string[]> = {};
|
|
868
1327
|
for (const key of new Set([
|
|
@@ -991,6 +1450,113 @@ export async function removeOpenSpecIntegration(
|
|
|
991
1450
|
removed.push(CI_EXAMPLE_RELATIVE);
|
|
992
1451
|
}
|
|
993
1452
|
|
|
1453
|
+
// Remove the installed product schema, deleting only paths with a per-file ownership record and
|
|
1454
|
+
// bytes still proven managed by the recorded digest (or, for a legacy path-only record, the
|
|
1455
|
+
// current asset). A diverged or unrecorded file is preserved and reported, and with no recorded
|
|
1456
|
+
// product schema at all nothing under openspec/schemas is touched. Directories are pruned only
|
|
1457
|
+
// when the removal emptied them, so adjacent schemas and user files inside ours survive.
|
|
1458
|
+
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
1459
|
+
if (recordedSchema !== undefined) {
|
|
1460
|
+
const assetContentByRelative = new Map(
|
|
1461
|
+
(await loadProductChangeSchemaAssets()).map((asset) => [
|
|
1462
|
+
`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1463
|
+
asset.content,
|
|
1464
|
+
]),
|
|
1465
|
+
);
|
|
1466
|
+
const schemaFileSet = Object.keys(recordedSchema).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1467
|
+
const pruneCandidates = new Set<string>();
|
|
1468
|
+
for (const relative of schemaFileSet) {
|
|
1469
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1470
|
+
if (!(await pathExists(absolute))) continue;
|
|
1471
|
+
const existing = await readFile(absolute, 'utf8');
|
|
1472
|
+
const recordedDigest = recordedSchema[relative];
|
|
1473
|
+
const proven =
|
|
1474
|
+
existing === assetContentByRelative.get(relative) ||
|
|
1475
|
+
(recordedDigest !== undefined &&
|
|
1476
|
+
recordedDigest !== '' &&
|
|
1477
|
+
contentDigest(existing) === recordedDigest);
|
|
1478
|
+
if (!proven) {
|
|
1479
|
+
preserved.push(relative);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
if (!dryRun) {
|
|
1483
|
+
await rm(absolute, { force: true });
|
|
1484
|
+
let parent = dirname(relative);
|
|
1485
|
+
while (
|
|
1486
|
+
parent.startsWith('openspec/schemas') &&
|
|
1487
|
+
parent !== 'openspec/schemas' &&
|
|
1488
|
+
parent !== 'openspec'
|
|
1489
|
+
) {
|
|
1490
|
+
pruneCandidates.add(parent);
|
|
1491
|
+
parent = parent.split('/').slice(0, -1).join('/');
|
|
1492
|
+
}
|
|
1493
|
+
pruneCandidates.add('openspec/schemas');
|
|
1494
|
+
}
|
|
1495
|
+
removed.push(relative);
|
|
1496
|
+
}
|
|
1497
|
+
// A schema-level record is not ownership of every current asset path. If a path is absent
|
|
1498
|
+
// from the per-file record, preserve and report it even when its bytes equal today's asset.
|
|
1499
|
+
for (const relative of [...assetContentByRelative.keys()].sort((a, b) =>
|
|
1500
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
1501
|
+
)) {
|
|
1502
|
+
if (recordedSchema[relative] !== undefined) continue;
|
|
1503
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1504
|
+
if (await pathExists(absolute)) preserved.push(relative);
|
|
1505
|
+
}
|
|
1506
|
+
if (!dryRun) {
|
|
1507
|
+
// Deepest first, so a directory is only considered after its children were.
|
|
1508
|
+
const ordered = [...pruneCandidates].sort((a, b) => b.length - a.length);
|
|
1509
|
+
for (const relative of ordered) {
|
|
1510
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1511
|
+
try {
|
|
1512
|
+
const entries = await readdir(absolute);
|
|
1513
|
+
if (entries.length === 0) await rmdir(absolute);
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
if (!isNotFound(error)) throw error;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const recordedRecovery = recordedRecoverySchemaFiles(previousMeta);
|
|
1522
|
+
if (recordedRecovery !== undefined) {
|
|
1523
|
+
const recoveryAssets = new Map(
|
|
1524
|
+
(await loadProductRecoverySchemaAssets()).map((asset) => [
|
|
1525
|
+
`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1526
|
+
asset.content,
|
|
1527
|
+
]),
|
|
1528
|
+
);
|
|
1529
|
+
for (const relative of Object.keys(recordedRecovery).sort()) {
|
|
1530
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1531
|
+
if (!(await pathExists(absolute))) continue;
|
|
1532
|
+
const existing = await readFile(absolute, 'utf8');
|
|
1533
|
+
const recordedDigest = recordedRecovery[relative];
|
|
1534
|
+
const proven =
|
|
1535
|
+
existing === recoveryAssets.get(relative) ||
|
|
1536
|
+
(recordedDigest !== '' && contentDigest(existing) === recordedDigest);
|
|
1537
|
+
if (!proven) {
|
|
1538
|
+
preserved.push(relative);
|
|
1539
|
+
} else {
|
|
1540
|
+
if (!dryRun) await rm(absolute, { force: true });
|
|
1541
|
+
removed.push(relative);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (!dryRun) {
|
|
1545
|
+
for (const relative of [
|
|
1546
|
+
`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/templates`,
|
|
1547
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
1548
|
+
'openspec/schemas',
|
|
1549
|
+
]) {
|
|
1550
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1551
|
+
try {
|
|
1552
|
+
if ((await readdir(absolute)).length === 0) await rmdir(absolute);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (!isNotFound(error)) throw error;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
994
1560
|
// Remove metadata file.
|
|
995
1561
|
if (await pathExists(metaPath(root))) {
|
|
996
1562
|
if (!dryRun) {
|
|
@@ -999,5 +1565,5 @@ export async function removeOpenSpecIntegration(
|
|
|
999
1565
|
removed.push(META_RELATIVE);
|
|
1000
1566
|
}
|
|
1001
1567
|
|
|
1002
|
-
return { removed };
|
|
1568
|
+
return { removed, preserved };
|
|
1003
1569
|
}
|