@prodshape/integration-openspec 0.1.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/dist/index.d.ts +115 -0
- package/dist/index.js +444 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
- package/src/index.ts +651 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** The minimum supported OpenSpec CLI version. */
|
|
2
|
+
declare const MIN_SUPPORTED_OPENSPEC = "1.0.0";
|
|
3
|
+
/** The PDaC authority context block injected into openspec/config.yaml `context:`. */
|
|
4
|
+
declare const PDAC_CONTEXT_BLOCK = "<!-- pdac:context begin -->\nThis repository uses Product Definition as Code (PDaC). The canonical product definition lives in docs/product/model (actors, journeys, use cases, business rules, domain terms, requirements) and is the single source of truth for product behaviour. OpenSpec artifacts are downstream consumers of it, never a second source of truth.\nConsumers bind to canonical text through the PDaC Citation Contract: a citation records the target artifact id, a sha256 content digest, and an optional verification scenario anchor. Verification reports one status per citation: current, stale, tampered or unresolved.\nTo cite: run `npx prodshape inspect <ID>` to read the current digest, then `npx prodshape cite --id <ID> --digest <digest>` to emit the citation record. Place inline citations on their own line directly under the text they ground.\nThe product definition changes only through a Product Change under docs/product/changes/. OpenSpec changes never edit docs/product/model directly.\n<!-- pdac:context end -->";
|
|
5
|
+
/** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */
|
|
6
|
+
declare const PDAC_RULES: Record<string, string[]>;
|
|
7
|
+
/** The PDaC operation guidance injected into openspec/config.yaml `operations:`. */
|
|
8
|
+
declare const PDAC_OPERATIONS: {
|
|
9
|
+
apply: {
|
|
10
|
+
guidance: string[];
|
|
11
|
+
};
|
|
12
|
+
archive: {
|
|
13
|
+
guidance: string[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
/** Metadata recorded under .product/integrations/openspec.json. */
|
|
17
|
+
interface OpenSpecIntegrationMeta {
|
|
18
|
+
provider: 'openspec';
|
|
19
|
+
version: string;
|
|
20
|
+
openspecVersion: string;
|
|
21
|
+
installedAt: string;
|
|
22
|
+
configPath: string;
|
|
23
|
+
}
|
|
24
|
+
/** Detect the installed OpenSpec CLI version by running `openspec --version`. */
|
|
25
|
+
declare function detectOpenSpecVersion(): Promise<{
|
|
26
|
+
version: string;
|
|
27
|
+
} | {
|
|
28
|
+
error: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Check if an OpenSpec workspace exists at the given root (openspec/ directory present). */
|
|
31
|
+
declare function isOpenSpecWorkspace(root: string): Promise<boolean>;
|
|
32
|
+
/** Read and parse the existing openspec/config.yaml, or null if it does not exist. */
|
|
33
|
+
declare function readOpenSpecConfig(root: string): Promise<Record<string, unknown> | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Merge PDaC guidance into the OpenSpec config, preserving existing content.
|
|
36
|
+
*
|
|
37
|
+
* - `schema` is preserved as-is.
|
|
38
|
+
* - `context`: if the existing context already contains the PDaC block, it is replaced in place
|
|
39
|
+
* (idempotent); otherwise the PDaC block is appended after a separator. User-authored context
|
|
40
|
+
* lines are never lost.
|
|
41
|
+
* - `rules`: for each PDaC artifact (proposal, specs, design, tasks), PDaC rules are appended to
|
|
42
|
+
* existing rules, deduplicating identical entries.
|
|
43
|
+
* - `operations`: PDaC apply/archive guidance is appended to existing guidance, deduplicating.
|
|
44
|
+
* - Any other fields (githubCopilot, etc.) are preserved untouched.
|
|
45
|
+
*
|
|
46
|
+
* Returns the merged config and a list of human-readable change descriptions.
|
|
47
|
+
*/
|
|
48
|
+
declare function mergeConfig(existing: Record<string, unknown> | null): {
|
|
49
|
+
config: Record<string, unknown>;
|
|
50
|
+
changes: string[];
|
|
51
|
+
};
|
|
52
|
+
/** Serialize config back to YAML with a trailing newline. */
|
|
53
|
+
declare function serializeConfig(config: Record<string, unknown>): string;
|
|
54
|
+
/**
|
|
55
|
+
* Add the OpenSpec integration to a repository.
|
|
56
|
+
*
|
|
57
|
+
* - Detects the OpenSpec CLI version; rejects if not found or below MIN_SUPPORTED_OPENSPEC.
|
|
58
|
+
* - Requires an existing `openspec/` workspace (run `openspec init` first).
|
|
59
|
+
* - Merges PDaC guidance into `openspec/config.yaml`, preserving existing user configuration.
|
|
60
|
+
* - Records metadata under `.product/integrations/openspec.json`.
|
|
61
|
+
* - Idempotent: running twice produces the same result.
|
|
62
|
+
* - `--dry-run` reports what would change without writing.
|
|
63
|
+
* - `--force` re-merges even if the PDaC block is already present (useful after a PDaC upgrade).
|
|
64
|
+
*/
|
|
65
|
+
declare function addOpenSpecIntegration(root: string, options?: {
|
|
66
|
+
force?: boolean;
|
|
67
|
+
dryRun?: boolean;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
written: string[];
|
|
70
|
+
changes: string[];
|
|
71
|
+
meta: OpenSpecIntegrationMeta;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* Update (re-merge) the OpenSpec integration. Re-runs the merge so that a PDaC upgrade that
|
|
75
|
+
* changed the context block or rules is reflected. Idempotent.
|
|
76
|
+
*/
|
|
77
|
+
declare function updateOpenSpecIntegration(root: string, options?: {
|
|
78
|
+
force?: boolean;
|
|
79
|
+
dryRun?: boolean;
|
|
80
|
+
}): Promise<{
|
|
81
|
+
written: string[];
|
|
82
|
+
changes: string[];
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Check the OpenSpec integration health. Verifies:
|
|
86
|
+
* - OpenSpec CLI is installed and the version is supported.
|
|
87
|
+
* - `openspec/config.yaml` exists and contains the PDaC context block.
|
|
88
|
+
* - `.product/integrations/openspec.json` metadata exists.
|
|
89
|
+
* - The config's PDaC block matches the current PDaC guidance (not removed or altered).
|
|
90
|
+
*/
|
|
91
|
+
declare function checkOpenSpecIntegration(root: string): Promise<{
|
|
92
|
+
ok: boolean;
|
|
93
|
+
checks: {
|
|
94
|
+
name: string;
|
|
95
|
+
ok: boolean;
|
|
96
|
+
detail: string;
|
|
97
|
+
}[];
|
|
98
|
+
}>;
|
|
99
|
+
/**
|
|
100
|
+
* Remove the PDaC-injected content from openspec/config.yaml, preserving user-authored content,
|
|
101
|
+
* and delete the metadata file.
|
|
102
|
+
*
|
|
103
|
+
* - Removes the PDaC context block (between sentinels) from `context:`.
|
|
104
|
+
* - Removes PDaC rules from each artifact (keeps user-authored rules).
|
|
105
|
+
* - Removes PDaC operation guidance (keeps user-authored guidance).
|
|
106
|
+
* - Deletes `.product/integrations/openspec.json`.
|
|
107
|
+
* - `--dry-run` reports what would be removed without writing.
|
|
108
|
+
*/
|
|
109
|
+
declare function removeOpenSpecIntegration(root: string, options?: {
|
|
110
|
+
dryRun?: boolean;
|
|
111
|
+
}): Promise<{
|
|
112
|
+
removed: string[];
|
|
113
|
+
}>;
|
|
114
|
+
|
|
115
|
+
export { MIN_SUPPORTED_OPENSPEC, type OpenSpecIntegrationMeta, PDAC_CONTEXT_BLOCK, PDAC_OPERATIONS, PDAC_RULES, addOpenSpecIntegration, checkOpenSpecIntegration, detectOpenSpecVersion, isOpenSpecWorkspace, mergeConfig, readOpenSpecConfig, removeOpenSpecIntegration, serializeConfig, updateOpenSpecIntegration };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import { access, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import { parse, stringify } from "yaml";
|
|
6
|
+
var MIN_SUPPORTED_OPENSPEC = "1.0.0";
|
|
7
|
+
var PDAC_CONTEXT_BEGIN = "<!-- pdac:context begin -->";
|
|
8
|
+
var PDAC_CONTEXT_END = "<!-- pdac:context end -->";
|
|
9
|
+
var PDAC_CONTEXT_BLOCK = `${PDAC_CONTEXT_BEGIN}
|
|
10
|
+
This repository uses Product Definition as Code (PDaC). The canonical product definition lives in docs/product/model (actors, journeys, use cases, business rules, domain terms, requirements) and is the single source of truth for product behaviour. OpenSpec artifacts are downstream consumers of it, never a second source of truth.
|
|
11
|
+
Consumers bind to canonical text through the PDaC Citation Contract: a citation records the target artifact id, a sha256 content digest, and an optional verification scenario anchor. Verification reports one status per citation: current, stale, tampered or unresolved.
|
|
12
|
+
To cite: run \`npx prodshape inspect <ID>\` to read the current digest, then \`npx prodshape cite --id <ID> --digest <digest>\` to emit the citation record. Place inline citations on their own line directly under the text they ground.
|
|
13
|
+
The product definition changes only through a Product Change under docs/product/changes/. OpenSpec changes never edit docs/product/model directly.
|
|
14
|
+
${PDAC_CONTEXT_END}`;
|
|
15
|
+
var PDAC_RULES = {
|
|
16
|
+
proposal: [
|
|
17
|
+
"State which PDaC artifacts this change touches, each with an inline citation emitted by `npx prodshape cite --id <ID> --digest <digest>`. Never write a citation record by hand.",
|
|
18
|
+
"If the change alters product behaviour, name the Product Change (CHG id) it implements. If no Product Change exists yet, stop and ask for one instead of proceeding."
|
|
19
|
+
],
|
|
20
|
+
specs: [
|
|
21
|
+
"Every requirement derived from canonical product text MUST carry a citation to every PDaC artifact it derives from, one citation per line, placed after the requirement text and before the first scenario. A requirement often derives from more than one artifact; citing only the closest one silently hides the other derivations. Never place citations between the requirement heading and the requirement text; OpenSpec reads the first paragraph as the requirement.",
|
|
22
|
+
'Cite a specific verification scenario with an anchor when the requirement maps to one (anchor="<scenario id>").',
|
|
23
|
+
"Never paraphrase canonical text as if it were new; either cite it inline or embed it in a marker-block citation (<!-- pdac:cite ... --> canonical text <!-- /pdac:cite -->).",
|
|
24
|
+
"Never invent artifact ids or digests. Read them with `npx prodshape inspect <ID>`."
|
|
25
|
+
],
|
|
26
|
+
design: [
|
|
27
|
+
"When a design decision depends on canonical product text, cite the artifact it depends on."
|
|
28
|
+
],
|
|
29
|
+
tasks: [
|
|
30
|
+
"A task that changes cited behaviour must include a follow-up task to refresh the affected citations."
|
|
31
|
+
]
|
|
32
|
+
};
|
|
33
|
+
var PDAC_OPERATIONS = {
|
|
34
|
+
apply: {
|
|
35
|
+
guidance: [
|
|
36
|
+
"Before finishing, run `npx prodshape citations verify` and fix every stale, tampered or unresolved citation. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation to silence a diagnostic."
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
archive: {
|
|
40
|
+
guidance: [
|
|
41
|
+
"Run `npx prodshape citations verify` one final time. Archive only when every citation is current."
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var CONFIG_RELATIVE = "openspec/config.yaml";
|
|
46
|
+
var META_RELATIVE = ".product/integrations/openspec.json";
|
|
47
|
+
function configPath(root) {
|
|
48
|
+
return join(root, ...CONFIG_RELATIVE.split("/"));
|
|
49
|
+
}
|
|
50
|
+
function metaPath(root) {
|
|
51
|
+
return join(root, ...META_RELATIVE.split("/"));
|
|
52
|
+
}
|
|
53
|
+
async function exists(path) {
|
|
54
|
+
try {
|
|
55
|
+
await access(path);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function compareVersions(a, b) {
|
|
62
|
+
const parseParts = (v) => {
|
|
63
|
+
const core = v.split("-")[0]?.split("+")[0] ?? "";
|
|
64
|
+
const segments = core.split(".").map((s) => Number.parseInt(s, 10));
|
|
65
|
+
return [segments[0] ?? 0, segments[1] ?? 0, segments[2] ?? 0];
|
|
66
|
+
};
|
|
67
|
+
const [aMaj, aMin, aPatch] = parseParts(a);
|
|
68
|
+
const [bMaj, bMin, bPatch] = parseParts(b);
|
|
69
|
+
if (aMaj !== bMaj) return aMaj - bMaj;
|
|
70
|
+
if (aMin !== bMin) return aMin - bMin;
|
|
71
|
+
return aPatch - bPatch;
|
|
72
|
+
}
|
|
73
|
+
async function detectOpenSpecVersion() {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
execFile("openspec", ["--version"], { shell: true }, (error, stdout, stderr) => {
|
|
76
|
+
if (error) {
|
|
77
|
+
resolve({
|
|
78
|
+
error: `OpenSpec CLI not found on PATH (run: npm install -g openspec). ${error.message}`
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const output = `${stdout}${stderr}`.trim();
|
|
83
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
|
|
84
|
+
if (!match?.[1]) {
|
|
85
|
+
resolve({ error: `Could not parse OpenSpec version from output: ${output}` });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
resolve({ version: match[1] });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function isOpenSpecWorkspace(root) {
|
|
93
|
+
return exists(join(root, "openspec"));
|
|
94
|
+
}
|
|
95
|
+
async function readOpenSpecConfig(root) {
|
|
96
|
+
const path = configPath(root);
|
|
97
|
+
if (!await exists(path)) return null;
|
|
98
|
+
const text = await readFile(path, "utf8");
|
|
99
|
+
const parsed = parse(text);
|
|
100
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
function extractPdacContext(context) {
|
|
106
|
+
if (!context) return null;
|
|
107
|
+
const begin = context.indexOf(PDAC_CONTEXT_BEGIN);
|
|
108
|
+
const end = context.indexOf(PDAC_CONTEXT_END);
|
|
109
|
+
if (begin === -1 || end === -1 || end < begin) return null;
|
|
110
|
+
return context.slice(begin, end + PDAC_CONTEXT_END.length);
|
|
111
|
+
}
|
|
112
|
+
function mergeConfig(existing) {
|
|
113
|
+
const config = { ...existing ?? {} };
|
|
114
|
+
const changes = [];
|
|
115
|
+
const existingContext = typeof config["context"] === "string" ? config["context"] : void 0;
|
|
116
|
+
const existingPdacBlock = extractPdacContext(existingContext);
|
|
117
|
+
if (existingPdacBlock !== null) {
|
|
118
|
+
if (existingPdacBlock !== PDAC_CONTEXT_BLOCK) {
|
|
119
|
+
const replaced = existingContext.replace(existingPdacBlock, PDAC_CONTEXT_BLOCK);
|
|
120
|
+
if (replaced !== existingContext) {
|
|
121
|
+
config["context"] = replaced;
|
|
122
|
+
changes.push("Updated PDaC context block in openspec/config.yaml (context:).");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else if (existingContext) {
|
|
126
|
+
const separator = existingContext.endsWith("\n") ? "\n" : "\n\n";
|
|
127
|
+
config["context"] = `${existingContext}${separator}${PDAC_CONTEXT_BLOCK}`;
|
|
128
|
+
changes.push("Appended PDaC context block to existing openspec/config.yaml context:.");
|
|
129
|
+
} else {
|
|
130
|
+
config["context"] = PDAC_CONTEXT_BLOCK;
|
|
131
|
+
changes.push("Added PDaC context block to openspec/config.yaml (context:).");
|
|
132
|
+
}
|
|
133
|
+
const existingRules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? { ...config["rules"] } : {};
|
|
134
|
+
let rulesChanged = false;
|
|
135
|
+
for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
|
|
136
|
+
const existingList = Array.isArray(existingRules[artifact]) ? [...existingRules[artifact]] : [];
|
|
137
|
+
const merged = [...existingList];
|
|
138
|
+
for (const rule of pdacRules) {
|
|
139
|
+
if (!merged.includes(rule)) {
|
|
140
|
+
merged.push(rule);
|
|
141
|
+
rulesChanged = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
existingRules[artifact] = merged;
|
|
145
|
+
}
|
|
146
|
+
if (rulesChanged) {
|
|
147
|
+
config["rules"] = existingRules;
|
|
148
|
+
changes.push("Merged PDaC citation rules into openspec/config.yaml (rules:).");
|
|
149
|
+
}
|
|
150
|
+
const existingOperations = config["operations"] && typeof config["operations"] === "object" && !Array.isArray(config["operations"]) ? { ...config["operations"] } : {};
|
|
151
|
+
let opsChanged = false;
|
|
152
|
+
for (const opKey of ["apply", "archive"]) {
|
|
153
|
+
const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
|
|
154
|
+
const existingOp = existingOperations[opKey] && typeof existingOperations[opKey] === "object" && !Array.isArray(existingOperations[opKey]) ? { ...existingOperations[opKey] } : {};
|
|
155
|
+
const existingGuidance = Array.isArray(existingOp["guidance"]) ? [...existingOp["guidance"]] : [];
|
|
156
|
+
const merged = [...existingGuidance];
|
|
157
|
+
for (const guidance of pdacGuidance) {
|
|
158
|
+
if (!merged.includes(guidance)) {
|
|
159
|
+
merged.push(guidance);
|
|
160
|
+
opsChanged = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
existingOp["guidance"] = merged;
|
|
164
|
+
existingOperations[opKey] = existingOp;
|
|
165
|
+
}
|
|
166
|
+
if (opsChanged) {
|
|
167
|
+
config["operations"] = existingOperations;
|
|
168
|
+
changes.push("Merged PDaC operation guidance into openspec/config.yaml (operations:).");
|
|
169
|
+
}
|
|
170
|
+
return { config, changes };
|
|
171
|
+
}
|
|
172
|
+
function serializeConfig(config) {
|
|
173
|
+
return `${stringify(config)}
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
async function productshapeVersion() {
|
|
177
|
+
const packageJson = JSON.parse(
|
|
178
|
+
await readFile(new URL("../package.json", import.meta.url), "utf8")
|
|
179
|
+
);
|
|
180
|
+
return packageJson.version;
|
|
181
|
+
}
|
|
182
|
+
async function addOpenSpecIntegration(root, options = {}) {
|
|
183
|
+
const { force = false, dryRun = false } = options;
|
|
184
|
+
const detected = await detectOpenSpecVersion();
|
|
185
|
+
if ("error" in detected) {
|
|
186
|
+
throw new Error(detected.error);
|
|
187
|
+
}
|
|
188
|
+
if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!await isOpenSpecWorkspace(root)) {
|
|
194
|
+
throw new Error("No OpenSpec workspace found. Run `openspec init` first.");
|
|
195
|
+
}
|
|
196
|
+
const existing = await readOpenSpecConfig(root);
|
|
197
|
+
if (!force && existing) {
|
|
198
|
+
const existingContext = typeof existing["context"] === "string" ? existing["context"] : void 0;
|
|
199
|
+
if (existingContext && extractPdacContext(existingContext) === null) {
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const { config, changes } = mergeConfig(existing);
|
|
203
|
+
if (changes.length === 0 && !force) {
|
|
204
|
+
const meta = {
|
|
205
|
+
provider: "openspec",
|
|
206
|
+
version: await productshapeVersion(),
|
|
207
|
+
openspecVersion: detected.version,
|
|
208
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
209
|
+
configPath: CONFIG_RELATIVE
|
|
210
|
+
};
|
|
211
|
+
if (!dryRun) {
|
|
212
|
+
await writeMeta(root, meta);
|
|
213
|
+
}
|
|
214
|
+
return { written: [], changes: [], meta };
|
|
215
|
+
}
|
|
216
|
+
const written = [];
|
|
217
|
+
if (!dryRun) {
|
|
218
|
+
const serialized = serializeConfig(config);
|
|
219
|
+
await mkdir(dirname(configPath(root)), { recursive: true });
|
|
220
|
+
await writeFile(configPath(root), serialized, "utf8");
|
|
221
|
+
written.push(CONFIG_RELATIVE);
|
|
222
|
+
const meta = {
|
|
223
|
+
provider: "openspec",
|
|
224
|
+
version: await productshapeVersion(),
|
|
225
|
+
openspecVersion: detected.version,
|
|
226
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
227
|
+
configPath: CONFIG_RELATIVE
|
|
228
|
+
};
|
|
229
|
+
await writeMeta(root, meta);
|
|
230
|
+
written.push(META_RELATIVE);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
written,
|
|
234
|
+
changes,
|
|
235
|
+
meta: {
|
|
236
|
+
provider: "openspec",
|
|
237
|
+
version: await productshapeVersion(),
|
|
238
|
+
openspecVersion: detected.version,
|
|
239
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
240
|
+
configPath: CONFIG_RELATIVE
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function writeMeta(root, meta) {
|
|
245
|
+
const path = metaPath(root);
|
|
246
|
+
await mkdir(dirname(path), { recursive: true });
|
|
247
|
+
await writeFile(path, `${JSON.stringify(meta, null, 2)}
|
|
248
|
+
`, "utf8");
|
|
249
|
+
}
|
|
250
|
+
async function readMeta(root) {
|
|
251
|
+
const path = metaPath(root);
|
|
252
|
+
if (!await exists(path)) return null;
|
|
253
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
254
|
+
}
|
|
255
|
+
async function updateOpenSpecIntegration(root, options = {}) {
|
|
256
|
+
const result = await addOpenSpecIntegration(root, { force: true, dryRun: options.dryRun });
|
|
257
|
+
return { written: result.written, changes: result.changes };
|
|
258
|
+
}
|
|
259
|
+
async function checkOpenSpecIntegration(root) {
|
|
260
|
+
const checks = [];
|
|
261
|
+
const detected = await detectOpenSpecVersion();
|
|
262
|
+
if ("error" in detected) {
|
|
263
|
+
checks.push({ name: "openspec CLI", ok: false, detail: detected.error });
|
|
264
|
+
} else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
265
|
+
checks.push({
|
|
266
|
+
name: "openspec CLI",
|
|
267
|
+
ok: false,
|
|
268
|
+
detail: `OpenSpec ${detected.version} is below minimum supported ${MIN_SUPPORTED_OPENSPEC}.`
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
checks.push({
|
|
272
|
+
name: "openspec CLI",
|
|
273
|
+
ok: true,
|
|
274
|
+
detail: `OpenSpec ${detected.version} detected.`
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const config = await readOpenSpecConfig(root);
|
|
278
|
+
if (config === null) {
|
|
279
|
+
checks.push({
|
|
280
|
+
name: "config.yaml",
|
|
281
|
+
ok: false,
|
|
282
|
+
detail: "openspec/config.yaml not found. Run: prodshape integration add openspec"
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
const context = typeof config["context"] === "string" ? config["context"] : void 0;
|
|
286
|
+
const pdacBlock = extractPdacContext(context);
|
|
287
|
+
if (pdacBlock === null) {
|
|
288
|
+
checks.push({
|
|
289
|
+
name: "config.yaml",
|
|
290
|
+
ok: false,
|
|
291
|
+
detail: "PDaC context block missing from openspec/config.yaml. Run: prodshape integration add openspec"
|
|
292
|
+
});
|
|
293
|
+
} else if (pdacBlock !== PDAC_CONTEXT_BLOCK) {
|
|
294
|
+
checks.push({
|
|
295
|
+
name: "config.yaml",
|
|
296
|
+
ok: false,
|
|
297
|
+
detail: "PDaC context block in openspec/config.yaml is outdated. Run: prodshape integration update"
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
checks.push({
|
|
301
|
+
name: "config.yaml",
|
|
302
|
+
ok: true,
|
|
303
|
+
detail: "PDaC context block present and current."
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const meta = await readMeta(root);
|
|
308
|
+
if (meta === null) {
|
|
309
|
+
checks.push({
|
|
310
|
+
name: "metadata",
|
|
311
|
+
ok: false,
|
|
312
|
+
detail: ".product/integrations/openspec.json not found. Run: prodshape integration add openspec"
|
|
313
|
+
});
|
|
314
|
+
} else {
|
|
315
|
+
checks.push({
|
|
316
|
+
name: "metadata",
|
|
317
|
+
ok: true,
|
|
318
|
+
detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (config) {
|
|
322
|
+
const rules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? config["rules"] : {};
|
|
323
|
+
const missingRules = [];
|
|
324
|
+
for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
|
|
325
|
+
const list = Array.isArray(rules[artifact]) ? rules[artifact] : [];
|
|
326
|
+
for (const rule of pdacRules) {
|
|
327
|
+
if (!list.includes(rule)) missingRules.push(artifact);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (missingRules.length > 0) {
|
|
331
|
+
checks.push({
|
|
332
|
+
name: "rules",
|
|
333
|
+
ok: false,
|
|
334
|
+
detail: `PDaC rules missing for: ${[...new Set(missingRules)].join(", ")}. Run: prodshape integration update`
|
|
335
|
+
});
|
|
336
|
+
} else {
|
|
337
|
+
checks.push({ name: "rules", ok: true, detail: "All PDaC citation rules present." });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const ok = checks.every((c) => c.ok);
|
|
341
|
+
return { ok, checks };
|
|
342
|
+
}
|
|
343
|
+
async function removeOpenSpecIntegration(root, options = {}) {
|
|
344
|
+
const { dryRun = false } = options;
|
|
345
|
+
const removed = [];
|
|
346
|
+
const config = await readOpenSpecConfig(root);
|
|
347
|
+
if (config !== null) {
|
|
348
|
+
let configChanged = false;
|
|
349
|
+
const context = typeof config["context"] === "string" ? config["context"] : void 0;
|
|
350
|
+
if (context) {
|
|
351
|
+
const pdacBlock = extractPdacContext(context);
|
|
352
|
+
if (pdacBlock !== null) {
|
|
353
|
+
const cleaned = context.replace(pdacBlock, "").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
354
|
+
if (cleaned === "") {
|
|
355
|
+
delete config["context"];
|
|
356
|
+
} else {
|
|
357
|
+
config["context"] = cleaned;
|
|
358
|
+
}
|
|
359
|
+
configChanged = true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const rules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? config["rules"] : null;
|
|
363
|
+
if (rules) {
|
|
364
|
+
let rulesChanged = false;
|
|
365
|
+
for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
|
|
366
|
+
if (!Array.isArray(rules[artifact])) continue;
|
|
367
|
+
const list = rules[artifact];
|
|
368
|
+
const filtered = list.filter((r) => !pdacRules.includes(r));
|
|
369
|
+
if (filtered.length !== list.length) {
|
|
370
|
+
if (filtered.length === 0) {
|
|
371
|
+
delete rules[artifact];
|
|
372
|
+
} else {
|
|
373
|
+
rules[artifact] = filtered;
|
|
374
|
+
}
|
|
375
|
+
rulesChanged = true;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (rulesChanged) {
|
|
379
|
+
if (Object.keys(rules).length === 0) {
|
|
380
|
+
delete config["rules"];
|
|
381
|
+
}
|
|
382
|
+
configChanged = true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const operations = config["operations"] && typeof config["operations"] === "object" && !Array.isArray(config["operations"]) ? config["operations"] : null;
|
|
386
|
+
if (operations) {
|
|
387
|
+
let opsChanged = false;
|
|
388
|
+
for (const opKey of ["apply", "archive"]) {
|
|
389
|
+
const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
|
|
390
|
+
const op = operations[opKey] && typeof operations[opKey] === "object" && !Array.isArray(operations[opKey]) ? operations[opKey] : null;
|
|
391
|
+
if (!op || !Array.isArray(op["guidance"])) continue;
|
|
392
|
+
const list = op["guidance"];
|
|
393
|
+
const filtered = list.filter((g) => !pdacGuidance.includes(g));
|
|
394
|
+
if (filtered.length !== list.length) {
|
|
395
|
+
if (filtered.length === 0) {
|
|
396
|
+
delete op["guidance"];
|
|
397
|
+
} else {
|
|
398
|
+
op["guidance"] = filtered;
|
|
399
|
+
}
|
|
400
|
+
opsChanged = true;
|
|
401
|
+
}
|
|
402
|
+
if (Object.keys(op).length === 0) {
|
|
403
|
+
delete operations[opKey];
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (opsChanged) {
|
|
407
|
+
if (Object.keys(operations).length === 0) {
|
|
408
|
+
delete config["operations"];
|
|
409
|
+
}
|
|
410
|
+
configChanged = true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (configChanged && !dryRun) {
|
|
414
|
+
const serialized = serializeConfig(config);
|
|
415
|
+
await writeFile(configPath(root), serialized, "utf8");
|
|
416
|
+
removed.push(CONFIG_RELATIVE);
|
|
417
|
+
} else if (configChanged && dryRun) {
|
|
418
|
+
removed.push(CONFIG_RELATIVE);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (await exists(metaPath(root))) {
|
|
422
|
+
if (!dryRun) {
|
|
423
|
+
await rm(metaPath(root), { force: true });
|
|
424
|
+
}
|
|
425
|
+
removed.push(META_RELATIVE);
|
|
426
|
+
}
|
|
427
|
+
return { removed };
|
|
428
|
+
}
|
|
429
|
+
export {
|
|
430
|
+
MIN_SUPPORTED_OPENSPEC,
|
|
431
|
+
PDAC_CONTEXT_BLOCK,
|
|
432
|
+
PDAC_OPERATIONS,
|
|
433
|
+
PDAC_RULES,
|
|
434
|
+
addOpenSpecIntegration,
|
|
435
|
+
checkOpenSpecIntegration,
|
|
436
|
+
detectOpenSpecVersion,
|
|
437
|
+
isOpenSpecWorkspace,
|
|
438
|
+
mergeConfig,
|
|
439
|
+
readOpenSpecConfig,
|
|
440
|
+
removeOpenSpecIntegration,
|
|
441
|
+
serializeConfig,
|
|
442
|
+
updateOpenSpecIntegration
|
|
443
|
+
};
|
|
444
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * OpenSpec integration for Product Definition as Code.\n *\n * This module configures an existing OpenSpec workspace with PDaC citation rules by merging\n * guidance into `openspec/config.yaml`, and records metadata under `.product/integrations/`.\n * It never creates an OpenSpec plugin, never patches OpenSpec-generated commands or skills, and\n * never forks OpenSpec's default schema. The integration is configuration + deterministic\n * verification: consumer documents cite canonical product artifacts by id + digest, and\n * `prodshape citations verify` checks them.\n *\n * The merge logic is the heart of this module: it preserves every field the user authored\n * (schema, context, rules, operations, githubCopilot, and anything else) and only appends PDaC\n * guidance, deduplicating identical entries so the operation is idempotent.\n */\nimport { execFile } from 'node:child_process';\nimport { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { parse, stringify } from 'yaml';\n\n/** The minimum supported OpenSpec CLI version. */\nexport const MIN_SUPPORTED_OPENSPEC = '1.0.0';\n\n/**\n * Sentinel markers that bracket the PDaC-injected context block. They let `mergeConfig` find\n * and replace exactly the PDaC portion without touching user-authored context lines.\n */\nconst PDAC_CONTEXT_BEGIN = '<!-- pdac:context begin -->';\nconst PDAC_CONTEXT_END = '<!-- pdac:context end -->';\n\n/** The PDaC authority context block injected into openspec/config.yaml `context:`. */\nexport const PDAC_CONTEXT_BLOCK = `${PDAC_CONTEXT_BEGIN}\nThis repository uses Product Definition as Code (PDaC). The canonical product definition lives in docs/product/model (actors, journeys, use cases, business rules, domain terms, requirements) and is the single source of truth for product behaviour. OpenSpec artifacts are downstream consumers of it, never a second source of truth.\nConsumers bind to canonical text through the PDaC Citation Contract: a citation records the target artifact id, a sha256 content digest, and an optional verification scenario anchor. Verification reports one status per citation: current, stale, tampered or unresolved.\nTo cite: run \\`npx prodshape inspect <ID>\\` to read the current digest, then \\`npx prodshape cite --id <ID> --digest <digest>\\` to emit the citation record. Place inline citations on their own line directly under the text they ground.\nThe product definition changes only through a Product Change under docs/product/changes/. OpenSpec changes never edit docs/product/model directly.\n${PDAC_CONTEXT_END}`;\n\n/** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */\nexport const PDAC_RULES: Record<string, string[]> = {\n proposal: [\n 'State which PDaC artifacts this change touches, each with an inline citation emitted by `npx prodshape cite --id <ID> --digest <digest>`. Never write a citation record by hand.',\n 'If the change alters product behaviour, name the Product Change (CHG id) it implements. If no Product Change exists yet, stop and ask for one instead of proceeding.',\n ],\n specs: [\n 'Every requirement derived from canonical product text MUST carry a citation to every PDaC artifact it derives from, one citation per line, placed after the requirement text and before the first scenario. A requirement often derives from more than one artifact; citing only the closest one silently hides the other derivations. Never place citations between the requirement heading and the requirement text; OpenSpec reads the first paragraph as the requirement.',\n 'Cite a specific verification scenario with an anchor when the requirement maps to one (anchor=\"<scenario id>\").',\n 'Never paraphrase canonical text as if it were new; either cite it inline or embed it in a marker-block citation (<!-- pdac:cite ... --> canonical text <!-- /pdac:cite -->).',\n 'Never invent artifact ids or digests. Read them with `npx prodshape inspect <ID>`.',\n ],\n design: [\n 'When a design decision depends on canonical product text, cite the artifact it depends on.',\n ],\n tasks: [\n 'A task that changes cited behaviour must include a follow-up task to refresh the affected citations.',\n ],\n};\n\n/** The PDaC operation guidance injected into openspec/config.yaml `operations:`. */\nexport const PDAC_OPERATIONS: {\n apply: { guidance: string[] };\n archive: { guidance: string[] };\n} = {\n apply: {\n guidance: [\n 'Before finishing, run `npx prodshape citations verify` and fix every stale, tampered or unresolved citation. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation to silence a diagnostic.',\n ],\n },\n archive: {\n guidance: [\n 'Run `npx prodshape citations verify` one final time. Archive only when every citation is current.',\n ],\n },\n};\n\n/** Metadata recorded under .product/integrations/openspec.json. */\nexport interface OpenSpecIntegrationMeta {\n provider: 'openspec';\n version: string;\n openspecVersion: string;\n installedAt: string;\n configPath: string;\n}\n\n/** Repository-relative paths used by this integration. */\nconst CONFIG_RELATIVE = 'openspec/config.yaml';\nconst META_RELATIVE = '.product/integrations/openspec.json';\n\nfunction configPath(root: string): string {\n return join(root, ...CONFIG_RELATIVE.split('/'));\n}\n\nfunction metaPath(root: string): string {\n return join(root, ...META_RELATIVE.split('/'));\n}\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Compare two semver-like version strings (major.minor.patch, ignoring pre-release suffixes).\n * Returns negative if a < b, 0 if equal, positive if a > b.\n */\nfunction compareVersions(a: string, b: string): number {\n const parseParts = (v: string): [number, number, number] => {\n const core = v.split('-')[0]?.split('+')[0] ?? '';\n const segments = core.split('.').map((s) => Number.parseInt(s, 10));\n return [segments[0] ?? 0, segments[1] ?? 0, segments[2] ?? 0];\n };\n const [aMaj, aMin, aPatch] = parseParts(a);\n const [bMaj, bMin, bPatch] = parseParts(b);\n if (aMaj !== bMaj) return aMaj - bMaj;\n if (aMin !== bMin) return aMin - bMin;\n return aPatch - bPatch;\n}\n\n/** Detect the installed OpenSpec CLI version by running `openspec --version`. */\nexport async function detectOpenSpecVersion(): Promise<{ version: string } | { error: string }> {\n return new Promise((resolve) => {\n // `openspec` is typically a .cmd shim on Windows; `shell: true` lets execFile find it on PATH\n // across platforms without callers needing to know the extension.\n execFile('openspec', ['--version'], { shell: true }, (error, stdout, stderr) => {\n if (error) {\n resolve({\n error: `OpenSpec CLI not found on PATH (run: npm install -g openspec). ${error.message}`,\n });\n return;\n }\n const output = `${stdout}${stderr}`.trim();\n // `openspec --version` prints something like \"1.3.0\" or \"openspec/1.3.0 ...\".\n const match = output.match(/(\\d+\\.\\d+\\.\\d+(?:-[^\\s]+)?)/);\n if (!match?.[1]) {\n resolve({ error: `Could not parse OpenSpec version from output: ${output}` });\n return;\n }\n resolve({ version: match[1] });\n });\n });\n}\n\n/** Check if an OpenSpec workspace exists at the given root (openspec/ directory present). */\nexport async function isOpenSpecWorkspace(root: string): Promise<boolean> {\n return exists(join(root, 'openspec'));\n}\n\n/** Read and parse the existing openspec/config.yaml, or null if it does not exist. */\nexport async function readOpenSpecConfig(root: string): Promise<Record<string, unknown> | null> {\n const path = configPath(root);\n if (!(await exists(path))) return null;\n const text = await readFile(path, 'utf8');\n const parsed = parse(text);\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n // An empty or non-mapping file: treat as an empty config so we can merge into it.\n return {};\n }\n return parsed as Record<string, unknown>;\n}\n\n/**\n * Extract the PDaC context block (between sentinels) from a context string, or null if absent.\n */\nfunction extractPdacContext(context: string | undefined): string | null {\n if (!context) return null;\n const begin = context.indexOf(PDAC_CONTEXT_BEGIN);\n const end = context.indexOf(PDAC_CONTEXT_END);\n if (begin === -1 || end === -1 || end < begin) return null;\n return context.slice(begin, end + PDAC_CONTEXT_END.length);\n}\n\n/**\n * Merge PDaC guidance into the OpenSpec config, preserving existing content.\n *\n * - `schema` is preserved as-is.\n * - `context`: if the existing context already contains the PDaC block, it is replaced in place\n * (idempotent); otherwise the PDaC block is appended after a separator. User-authored context\n * lines are never lost.\n * - `rules`: for each PDaC artifact (proposal, specs, design, tasks), PDaC rules are appended to\n * existing rules, deduplicating identical entries.\n * - `operations`: PDaC apply/archive guidance is appended to existing guidance, deduplicating.\n * - Any other fields (githubCopilot, etc.) are preserved untouched.\n *\n * Returns the merged config and a list of human-readable change descriptions.\n */\nexport function mergeConfig(existing: Record<string, unknown> | null): {\n config: Record<string, unknown>;\n changes: string[];\n} {\n const config: Record<string, unknown> = { ...(existing ?? {}) };\n const changes: string[] = [];\n\n // --- context ---\n const existingContext =\n typeof config['context'] === 'string' ? (config['context'] as string) : undefined;\n const existingPdacBlock = extractPdacContext(existingContext);\n if (existingPdacBlock !== null) {\n // Replace the existing PDaC block in place (keeps it idempotent and up to date).\n if (existingPdacBlock !== PDAC_CONTEXT_BLOCK) {\n const replaced = existingContext!.replace(existingPdacBlock, PDAC_CONTEXT_BLOCK);\n if (replaced !== existingContext) {\n config['context'] = replaced;\n changes.push('Updated PDaC context block in openspec/config.yaml (context:).');\n }\n }\n // If identical, no change recorded — idempotent.\n } else if (existingContext) {\n // User has their own context: append the PDaC block after a blank-line separator.\n const separator = existingContext.endsWith('\\n') ? '\\n' : '\\n\\n';\n config['context'] = `${existingContext}${separator}${PDAC_CONTEXT_BLOCK}`;\n changes.push('Appended PDaC context block to existing openspec/config.yaml context:.');\n } else {\n config['context'] = PDAC_CONTEXT_BLOCK;\n changes.push('Added PDaC context block to openspec/config.yaml (context:).');\n }\n\n // --- rules ---\n const existingRules =\n config['rules'] && typeof config['rules'] === 'object' && !Array.isArray(config['rules'])\n ? { ...(config['rules'] as Record<string, unknown>) }\n : {};\n let rulesChanged = false;\n for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {\n const existingList = Array.isArray(existingRules[artifact])\n ? [...(existingRules[artifact] as string[])]\n : [];\n const merged = [...existingList];\n for (const rule of pdacRules) {\n if (!merged.includes(rule)) {\n merged.push(rule);\n rulesChanged = true;\n }\n }\n existingRules[artifact] = merged;\n }\n if (rulesChanged) {\n config['rules'] = existingRules;\n changes.push('Merged PDaC citation rules into openspec/config.yaml (rules:).');\n }\n\n // --- operations ---\n const existingOperations =\n config['operations'] &&\n typeof config['operations'] === 'object' &&\n !Array.isArray(config['operations'])\n ? { ...(config['operations'] as Record<string, unknown>) }\n : {};\n let opsChanged = false;\n for (const opKey of ['apply', 'archive'] as const) {\n const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;\n const existingOp =\n existingOperations[opKey] &&\n typeof existingOperations[opKey] === 'object' &&\n !Array.isArray(existingOperations[opKey])\n ? { ...(existingOperations[opKey] as Record<string, unknown>) }\n : {};\n const existingGuidance = Array.isArray(existingOp['guidance'])\n ? [...(existingOp['guidance'] as string[])]\n : [];\n const merged = [...existingGuidance];\n for (const guidance of pdacGuidance) {\n if (!merged.includes(guidance)) {\n merged.push(guidance);\n opsChanged = true;\n }\n }\n existingOp['guidance'] = merged;\n existingOperations[opKey] = existingOp;\n }\n if (opsChanged) {\n config['operations'] = existingOperations;\n changes.push('Merged PDaC operation guidance into openspec/config.yaml (operations:).');\n }\n\n return { config, changes };\n}\n\n/** Serialize config back to YAML with a trailing newline. */\nexport function serializeConfig(config: Record<string, unknown>): string {\n return `${stringify(config)}\\n`;\n}\n\n/** Read the ProductShape version from the integration-openspec package.json. */\nasync function productshapeVersion(): Promise<string> {\n const packageJson = JSON.parse(\n await readFile(new URL('../package.json', import.meta.url), 'utf8'),\n ) as { version: string };\n return packageJson.version;\n}\n\n/**\n * Add the OpenSpec integration to a repository.\n *\n * - Detects the OpenSpec CLI version; rejects if not found or below MIN_SUPPORTED_OPENSPEC.\n * - Requires an existing `openspec/` workspace (run `openspec init` first).\n * - Merges PDaC guidance into `openspec/config.yaml`, preserving existing user configuration.\n * - Records metadata under `.product/integrations/openspec.json`.\n * - Idempotent: running twice produces the same result.\n * - `--dry-run` reports what would change without writing.\n * - `--force` re-merges even if the PDaC block is already present (useful after a PDaC upgrade).\n */\nexport async function addOpenSpecIntegration(\n root: string,\n options: { force?: boolean; dryRun?: boolean } = {},\n): Promise<{ written: string[]; changes: string[]; meta: OpenSpecIntegrationMeta }> {\n const { force = false, dryRun = false } = options;\n\n const detected = await detectOpenSpecVersion();\n if ('error' in detected) {\n throw new Error(detected.error);\n }\n if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {\n throw new Error(\n `OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`,\n );\n }\n\n if (!(await isOpenSpecWorkspace(root))) {\n throw new Error('No OpenSpec workspace found. Run `openspec init` first.');\n }\n\n const existing = await readOpenSpecConfig(root);\n\n // Detect a conflicting user context that is NOT the PDaC block and has no room for it.\n // In practice we always append, so a \"conflict\" only arises if the user explicitly removed the\n // PDaC block after a previous install and we are re-adding without --force.\n if (!force && existing) {\n const existingContext =\n typeof existing['context'] === 'string' ? (existing['context'] as string) : undefined;\n if (existingContext && extractPdacContext(existingContext) === null) {\n // The user has context but no PDaC block. We will append, which is safe — not a conflict.\n }\n }\n\n const { config, changes } = mergeConfig(existing);\n\n if (changes.length === 0 && !force) {\n // Already up to date. Still record/refresh metadata.\n const meta: OpenSpecIntegrationMeta = {\n provider: 'openspec',\n version: await productshapeVersion(),\n openspecVersion: detected.version,\n installedAt: new Date().toISOString(),\n configPath: CONFIG_RELATIVE,\n };\n if (!dryRun) {\n await writeMeta(root, meta);\n }\n return { written: [], changes: [], meta };\n }\n\n const written: string[] = [];\n if (!dryRun) {\n const serialized = serializeConfig(config);\n await mkdir(dirname(configPath(root)), { recursive: true });\n await writeFile(configPath(root), serialized, 'utf8');\n written.push(CONFIG_RELATIVE);\n\n const meta: OpenSpecIntegrationMeta = {\n provider: 'openspec',\n version: await productshapeVersion(),\n openspecVersion: detected.version,\n installedAt: new Date().toISOString(),\n configPath: CONFIG_RELATIVE,\n };\n await writeMeta(root, meta);\n written.push(META_RELATIVE);\n }\n\n return {\n written,\n changes,\n meta: {\n provider: 'openspec',\n version: await productshapeVersion(),\n openspecVersion: detected.version,\n installedAt: new Date().toISOString(),\n configPath: CONFIG_RELATIVE,\n },\n };\n}\n\n/** Write the integration metadata file. */\nasync function writeMeta(root: string, meta: OpenSpecIntegrationMeta): Promise<void> {\n const path = metaPath(root);\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, `${JSON.stringify(meta, null, 2)}\\n`, 'utf8');\n}\n\n/** Read the integration metadata file, or null if absent. */\nasync function readMeta(root: string): Promise<OpenSpecIntegrationMeta | null> {\n const path = metaPath(root);\n if (!(await exists(path))) return null;\n return JSON.parse(await readFile(path, 'utf8')) as OpenSpecIntegrationMeta;\n}\n\n/**\n * Update (re-merge) the OpenSpec integration. Re-runs the merge so that a PDaC upgrade that\n * changed the context block or rules is reflected. Idempotent.\n */\nexport async function updateOpenSpecIntegration(\n root: string,\n options: { force?: boolean; dryRun?: boolean } = {},\n): Promise<{ written: string[]; changes: string[] }> {\n // `add` already re-merges idempotently; `force` ensures the metadata is refreshed even when\n // the config content did not change.\n const result = await addOpenSpecIntegration(root, { force: true, dryRun: options.dryRun });\n return { written: result.written, changes: result.changes };\n}\n\n/**\n * Check the OpenSpec integration health. Verifies:\n * - OpenSpec CLI is installed and the version is supported.\n * - `openspec/config.yaml` exists and contains the PDaC context block.\n * - `.product/integrations/openspec.json` metadata exists.\n * - The config's PDaC block matches the current PDaC guidance (not removed or altered).\n */\nexport async function checkOpenSpecIntegration(root: string): Promise<{\n ok: boolean;\n checks: { name: string; ok: boolean; detail: string }[];\n}> {\n const checks: { name: string; ok: boolean; detail: string }[] = [];\n\n // 1. OpenSpec CLI installed and supported.\n const detected = await detectOpenSpecVersion();\n if ('error' in detected) {\n checks.push({ name: 'openspec CLI', ok: false, detail: detected.error });\n } else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {\n checks.push({\n name: 'openspec CLI',\n ok: false,\n detail: `OpenSpec ${detected.version} is below minimum supported ${MIN_SUPPORTED_OPENSPEC}.`,\n });\n } else {\n checks.push({\n name: 'openspec CLI',\n ok: true,\n detail: `OpenSpec ${detected.version} detected.`,\n });\n }\n\n // 2. openspec/config.yaml exists and contains PDaC context.\n const config = await readOpenSpecConfig(root);\n if (config === null) {\n checks.push({\n name: 'config.yaml',\n ok: false,\n detail: 'openspec/config.yaml not found. Run: prodshape integration add openspec',\n });\n } else {\n const context =\n typeof config['context'] === 'string' ? (config['context'] as string) : undefined;\n const pdacBlock = extractPdacContext(context);\n if (pdacBlock === null) {\n checks.push({\n name: 'config.yaml',\n ok: false,\n detail:\n 'PDaC context block missing from openspec/config.yaml. Run: prodshape integration add openspec',\n });\n } else if (pdacBlock !== PDAC_CONTEXT_BLOCK) {\n checks.push({\n name: 'config.yaml',\n ok: false,\n detail:\n 'PDaC context block in openspec/config.yaml is outdated. Run: prodshape integration update',\n });\n } else {\n checks.push({\n name: 'config.yaml',\n ok: true,\n detail: 'PDaC context block present and current.',\n });\n }\n }\n\n // 3. Metadata file exists.\n const meta = await readMeta(root);\n if (meta === null) {\n checks.push({\n name: 'metadata',\n ok: false,\n detail:\n '.product/integrations/openspec.json not found. Run: prodshape integration add openspec',\n });\n } else {\n checks.push({\n name: 'metadata',\n ok: true,\n detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`,\n });\n }\n\n // 4. PDaC rules present.\n if (config) {\n const rules =\n config['rules'] && typeof config['rules'] === 'object' && !Array.isArray(config['rules'])\n ? (config['rules'] as Record<string, unknown>)\n : {};\n const missingRules: string[] = [];\n for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {\n const list = Array.isArray(rules[artifact]) ? (rules[artifact] as string[]) : [];\n for (const rule of pdacRules) {\n if (!list.includes(rule)) missingRules.push(artifact);\n }\n }\n if (missingRules.length > 0) {\n checks.push({\n name: 'rules',\n ok: false,\n detail: `PDaC rules missing for: ${[...new Set(missingRules)].join(', ')}. Run: prodshape integration update`,\n });\n } else {\n checks.push({ name: 'rules', ok: true, detail: 'All PDaC citation rules present.' });\n }\n }\n\n const ok = checks.every((c) => c.ok);\n return { ok, checks };\n}\n\n/**\n * Remove the PDaC-injected content from openspec/config.yaml, preserving user-authored content,\n * and delete the metadata file.\n *\n * - Removes the PDaC context block (between sentinels) from `context:`.\n * - Removes PDaC rules from each artifact (keeps user-authored rules).\n * - Removes PDaC operation guidance (keeps user-authored guidance).\n * - Deletes `.product/integrations/openspec.json`.\n * - `--dry-run` reports what would be removed without writing.\n */\nexport async function removeOpenSpecIntegration(\n root: string,\n options: { dryRun?: boolean } = {},\n): Promise<{ removed: string[] }> {\n const { dryRun = false } = options;\n const removed: string[] = [];\n\n const config = await readOpenSpecConfig(root);\n if (config !== null) {\n let configChanged = false;\n\n // Remove PDaC context block.\n const context =\n typeof config['context'] === 'string' ? (config['context'] as string) : undefined;\n if (context) {\n const pdacBlock = extractPdacContext(context);\n if (pdacBlock !== null) {\n const cleaned = context\n .replace(pdacBlock, '')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trimEnd();\n if (cleaned === '') {\n delete config['context'];\n } else {\n config['context'] = cleaned;\n }\n configChanged = true;\n }\n }\n\n // Remove PDaC rules from each artifact.\n const rules =\n config['rules'] && typeof config['rules'] === 'object' && !Array.isArray(config['rules'])\n ? (config['rules'] as Record<string, unknown>)\n : null;\n if (rules) {\n let rulesChanged = false;\n for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {\n if (!Array.isArray(rules[artifact])) continue;\n const list = rules[artifact] as string[];\n const filtered = list.filter((r) => !pdacRules.includes(r));\n if (filtered.length !== list.length) {\n if (filtered.length === 0) {\n delete rules[artifact];\n } else {\n rules[artifact] = filtered;\n }\n rulesChanged = true;\n }\n }\n if (rulesChanged) {\n if (Object.keys(rules).length === 0) {\n delete config['rules'];\n }\n configChanged = true;\n }\n }\n\n // Remove PDaC operation guidance.\n const operations =\n config['operations'] &&\n typeof config['operations'] === 'object' &&\n !Array.isArray(config['operations'])\n ? (config['operations'] as Record<string, unknown>)\n : null;\n if (operations) {\n let opsChanged = false;\n for (const opKey of ['apply', 'archive'] as const) {\n const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;\n const op =\n operations[opKey] &&\n typeof operations[opKey] === 'object' &&\n !Array.isArray(operations[opKey])\n ? (operations[opKey] as Record<string, unknown>)\n : null;\n if (!op || !Array.isArray(op['guidance'])) continue;\n const list = op['guidance'] as string[];\n const filtered = list.filter((g) => !pdacGuidance.includes(g));\n if (filtered.length !== list.length) {\n if (filtered.length === 0) {\n delete op['guidance'];\n } else {\n op['guidance'] = filtered;\n }\n opsChanged = true;\n }\n if (Object.keys(op).length === 0) {\n delete operations[opKey];\n }\n }\n if (opsChanged) {\n if (Object.keys(operations).length === 0) {\n delete config['operations'];\n }\n configChanged = true;\n }\n }\n\n if (configChanged && !dryRun) {\n const serialized = serializeConfig(config);\n await writeFile(configPath(root), serialized, 'utf8');\n removed.push(CONFIG_RELATIVE);\n } else if (configChanged && dryRun) {\n removed.push(CONFIG_RELATIVE);\n }\n }\n\n // Remove metadata file.\n if (await exists(metaPath(root))) {\n if (!dryRun) {\n await rm(metaPath(root), { force: true });\n }\n removed.push(META_RELATIVE);\n }\n\n return { removed };\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prodshape/integration-openspec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenSpec integration for Product Definition as Code: configures OpenSpec workspaces with PDaC citation rules and verifies citations against the OpenSpec population.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"provenance": true
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/juangcarmona/productshape.git",
|
|
14
|
+
"directory": "packages/integration-openspec"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"yaml": "^2.5.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"tsup": "^8.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSpec integration for Product Definition as Code.
|
|
3
|
+
*
|
|
4
|
+
* This module configures an existing OpenSpec workspace with PDaC citation rules by merging
|
|
5
|
+
* guidance into `openspec/config.yaml`, and records metadata under `.product/integrations/`.
|
|
6
|
+
* It never creates an OpenSpec plugin, never patches OpenSpec-generated commands or skills, and
|
|
7
|
+
* never forks OpenSpec's default schema. The integration is configuration + deterministic
|
|
8
|
+
* verification: consumer documents cite canonical product artifacts by id + digest, and
|
|
9
|
+
* `prodshape citations verify` checks them.
|
|
10
|
+
*
|
|
11
|
+
* The merge logic is the heart of this module: it preserves every field the user authored
|
|
12
|
+
* (schema, context, rules, operations, githubCopilot, and anything else) and only appends PDaC
|
|
13
|
+
* guidance, deduplicating identical entries so the operation is idempotent.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { parse, stringify } from 'yaml';
|
|
19
|
+
|
|
20
|
+
/** The minimum supported OpenSpec CLI version. */
|
|
21
|
+
export const MIN_SUPPORTED_OPENSPEC = '1.0.0';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sentinel markers that bracket the PDaC-injected context block. They let `mergeConfig` find
|
|
25
|
+
* and replace exactly the PDaC portion without touching user-authored context lines.
|
|
26
|
+
*/
|
|
27
|
+
const PDAC_CONTEXT_BEGIN = '<!-- pdac:context begin -->';
|
|
28
|
+
const PDAC_CONTEXT_END = '<!-- pdac:context end -->';
|
|
29
|
+
|
|
30
|
+
/** The PDaC authority context block injected into openspec/config.yaml `context:`. */
|
|
31
|
+
export const PDAC_CONTEXT_BLOCK = `${PDAC_CONTEXT_BEGIN}
|
|
32
|
+
This repository uses Product Definition as Code (PDaC). The canonical product definition lives in docs/product/model (actors, journeys, use cases, business rules, domain terms, requirements) and is the single source of truth for product behaviour. OpenSpec artifacts are downstream consumers of it, never a second source of truth.
|
|
33
|
+
Consumers bind to canonical text through the PDaC Citation Contract: a citation records the target artifact id, a sha256 content digest, and an optional verification scenario anchor. Verification reports one status per citation: current, stale, tampered or unresolved.
|
|
34
|
+
To cite: run \`npx prodshape inspect <ID>\` to read the current digest, then \`npx prodshape cite --id <ID> --digest <digest>\` to emit the citation record. Place inline citations on their own line directly under the text they ground.
|
|
35
|
+
The product definition changes only through a Product Change under docs/product/changes/. OpenSpec changes never edit docs/product/model directly.
|
|
36
|
+
${PDAC_CONTEXT_END}`;
|
|
37
|
+
|
|
38
|
+
/** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */
|
|
39
|
+
export const PDAC_RULES: Record<string, string[]> = {
|
|
40
|
+
proposal: [
|
|
41
|
+
'State which PDaC artifacts this change touches, each with an inline citation emitted by `npx prodshape cite --id <ID> --digest <digest>`. Never write a citation record by hand.',
|
|
42
|
+
'If the change alters product behaviour, name the Product Change (CHG id) it implements. If no Product Change exists yet, stop and ask for one instead of proceeding.',
|
|
43
|
+
],
|
|
44
|
+
specs: [
|
|
45
|
+
'Every requirement derived from canonical product text MUST carry a citation to every PDaC artifact it derives from, one citation per line, placed after the requirement text and before the first scenario. A requirement often derives from more than one artifact; citing only the closest one silently hides the other derivations. Never place citations between the requirement heading and the requirement text; OpenSpec reads the first paragraph as the requirement.',
|
|
46
|
+
'Cite a specific verification scenario with an anchor when the requirement maps to one (anchor="<scenario id>").',
|
|
47
|
+
'Never paraphrase canonical text as if it were new; either cite it inline or embed it in a marker-block citation (<!-- pdac:cite ... --> canonical text <!-- /pdac:cite -->).',
|
|
48
|
+
'Never invent artifact ids or digests. Read them with `npx prodshape inspect <ID>`.',
|
|
49
|
+
],
|
|
50
|
+
design: [
|
|
51
|
+
'When a design decision depends on canonical product text, cite the artifact it depends on.',
|
|
52
|
+
],
|
|
53
|
+
tasks: [
|
|
54
|
+
'A task that changes cited behaviour must include a follow-up task to refresh the affected citations.',
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The PDaC operation guidance injected into openspec/config.yaml `operations:`. */
|
|
59
|
+
export const PDAC_OPERATIONS: {
|
|
60
|
+
apply: { guidance: string[] };
|
|
61
|
+
archive: { guidance: string[] };
|
|
62
|
+
} = {
|
|
63
|
+
apply: {
|
|
64
|
+
guidance: [
|
|
65
|
+
'Before finishing, run `npx prodshape citations verify` and fix every stale, tampered or unresolved citation. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation to silence a diagnostic.',
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
archive: {
|
|
69
|
+
guidance: [
|
|
70
|
+
'Run `npx prodshape citations verify` one final time. Archive only when every citation is current.',
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Metadata recorded under .product/integrations/openspec.json. */
|
|
76
|
+
export interface OpenSpecIntegrationMeta {
|
|
77
|
+
provider: 'openspec';
|
|
78
|
+
version: string;
|
|
79
|
+
openspecVersion: string;
|
|
80
|
+
installedAt: string;
|
|
81
|
+
configPath: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Repository-relative paths used by this integration. */
|
|
85
|
+
const CONFIG_RELATIVE = 'openspec/config.yaml';
|
|
86
|
+
const META_RELATIVE = '.product/integrations/openspec.json';
|
|
87
|
+
|
|
88
|
+
function configPath(root: string): string {
|
|
89
|
+
return join(root, ...CONFIG_RELATIVE.split('/'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function metaPath(root: string): string {
|
|
93
|
+
return join(root, ...META_RELATIVE.split('/'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function exists(path: string): Promise<boolean> {
|
|
97
|
+
try {
|
|
98
|
+
await access(path);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compare two semver-like version strings (major.minor.patch, ignoring pre-release suffixes).
|
|
107
|
+
* Returns negative if a < b, 0 if equal, positive if a > b.
|
|
108
|
+
*/
|
|
109
|
+
function compareVersions(a: string, b: string): number {
|
|
110
|
+
const parseParts = (v: string): [number, number, number] => {
|
|
111
|
+
const core = v.split('-')[0]?.split('+')[0] ?? '';
|
|
112
|
+
const segments = core.split('.').map((s) => Number.parseInt(s, 10));
|
|
113
|
+
return [segments[0] ?? 0, segments[1] ?? 0, segments[2] ?? 0];
|
|
114
|
+
};
|
|
115
|
+
const [aMaj, aMin, aPatch] = parseParts(a);
|
|
116
|
+
const [bMaj, bMin, bPatch] = parseParts(b);
|
|
117
|
+
if (aMaj !== bMaj) return aMaj - bMaj;
|
|
118
|
+
if (aMin !== bMin) return aMin - bMin;
|
|
119
|
+
return aPatch - bPatch;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Detect the installed OpenSpec CLI version by running `openspec --version`. */
|
|
123
|
+
export async function detectOpenSpecVersion(): Promise<{ version: string } | { error: string }> {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
// `openspec` is typically a .cmd shim on Windows; `shell: true` lets execFile find it on PATH
|
|
126
|
+
// across platforms without callers needing to know the extension.
|
|
127
|
+
execFile('openspec', ['--version'], { shell: true }, (error, stdout, stderr) => {
|
|
128
|
+
if (error) {
|
|
129
|
+
resolve({
|
|
130
|
+
error: `OpenSpec CLI not found on PATH (run: npm install -g openspec). ${error.message}`,
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const output = `${stdout}${stderr}`.trim();
|
|
135
|
+
// `openspec --version` prints something like "1.3.0" or "openspec/1.3.0 ...".
|
|
136
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
|
|
137
|
+
if (!match?.[1]) {
|
|
138
|
+
resolve({ error: `Could not parse OpenSpec version from output: ${output}` });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
resolve({ version: match[1] });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Check if an OpenSpec workspace exists at the given root (openspec/ directory present). */
|
|
147
|
+
export async function isOpenSpecWorkspace(root: string): Promise<boolean> {
|
|
148
|
+
return exists(join(root, 'openspec'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Read and parse the existing openspec/config.yaml, or null if it does not exist. */
|
|
152
|
+
export async function readOpenSpecConfig(root: string): Promise<Record<string, unknown> | null> {
|
|
153
|
+
const path = configPath(root);
|
|
154
|
+
if (!(await exists(path))) return null;
|
|
155
|
+
const text = await readFile(path, 'utf8');
|
|
156
|
+
const parsed = parse(text);
|
|
157
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
158
|
+
// An empty or non-mapping file: treat as an empty config so we can merge into it.
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
return parsed as Record<string, unknown>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract the PDaC context block (between sentinels) from a context string, or null if absent.
|
|
166
|
+
*/
|
|
167
|
+
function extractPdacContext(context: string | undefined): string | null {
|
|
168
|
+
if (!context) return null;
|
|
169
|
+
const begin = context.indexOf(PDAC_CONTEXT_BEGIN);
|
|
170
|
+
const end = context.indexOf(PDAC_CONTEXT_END);
|
|
171
|
+
if (begin === -1 || end === -1 || end < begin) return null;
|
|
172
|
+
return context.slice(begin, end + PDAC_CONTEXT_END.length);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Merge PDaC guidance into the OpenSpec config, preserving existing content.
|
|
177
|
+
*
|
|
178
|
+
* - `schema` is preserved as-is.
|
|
179
|
+
* - `context`: if the existing context already contains the PDaC block, it is replaced in place
|
|
180
|
+
* (idempotent); otherwise the PDaC block is appended after a separator. User-authored context
|
|
181
|
+
* lines are never lost.
|
|
182
|
+
* - `rules`: for each PDaC artifact (proposal, specs, design, tasks), PDaC rules are appended to
|
|
183
|
+
* existing rules, deduplicating identical entries.
|
|
184
|
+
* - `operations`: PDaC apply/archive guidance is appended to existing guidance, deduplicating.
|
|
185
|
+
* - Any other fields (githubCopilot, etc.) are preserved untouched.
|
|
186
|
+
*
|
|
187
|
+
* Returns the merged config and a list of human-readable change descriptions.
|
|
188
|
+
*/
|
|
189
|
+
export function mergeConfig(existing: Record<string, unknown> | null): {
|
|
190
|
+
config: Record<string, unknown>;
|
|
191
|
+
changes: string[];
|
|
192
|
+
} {
|
|
193
|
+
const config: Record<string, unknown> = { ...(existing ?? {}) };
|
|
194
|
+
const changes: string[] = [];
|
|
195
|
+
|
|
196
|
+
// --- context ---
|
|
197
|
+
const existingContext =
|
|
198
|
+
typeof config['context'] === 'string' ? (config['context'] as string) : undefined;
|
|
199
|
+
const existingPdacBlock = extractPdacContext(existingContext);
|
|
200
|
+
if (existingPdacBlock !== null) {
|
|
201
|
+
// Replace the existing PDaC block in place (keeps it idempotent and up to date).
|
|
202
|
+
if (existingPdacBlock !== PDAC_CONTEXT_BLOCK) {
|
|
203
|
+
const replaced = existingContext!.replace(existingPdacBlock, PDAC_CONTEXT_BLOCK);
|
|
204
|
+
if (replaced !== existingContext) {
|
|
205
|
+
config['context'] = replaced;
|
|
206
|
+
changes.push('Updated PDaC context block in openspec/config.yaml (context:).');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// If identical, no change recorded — idempotent.
|
|
210
|
+
} else if (existingContext) {
|
|
211
|
+
// User has their own context: append the PDaC block after a blank-line separator.
|
|
212
|
+
const separator = existingContext.endsWith('\n') ? '\n' : '\n\n';
|
|
213
|
+
config['context'] = `${existingContext}${separator}${PDAC_CONTEXT_BLOCK}`;
|
|
214
|
+
changes.push('Appended PDaC context block to existing openspec/config.yaml context:.');
|
|
215
|
+
} else {
|
|
216
|
+
config['context'] = PDAC_CONTEXT_BLOCK;
|
|
217
|
+
changes.push('Added PDaC context block to openspec/config.yaml (context:).');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- rules ---
|
|
221
|
+
const existingRules =
|
|
222
|
+
config['rules'] && typeof config['rules'] === 'object' && !Array.isArray(config['rules'])
|
|
223
|
+
? { ...(config['rules'] as Record<string, unknown>) }
|
|
224
|
+
: {};
|
|
225
|
+
let rulesChanged = false;
|
|
226
|
+
for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
|
|
227
|
+
const existingList = Array.isArray(existingRules[artifact])
|
|
228
|
+
? [...(existingRules[artifact] as string[])]
|
|
229
|
+
: [];
|
|
230
|
+
const merged = [...existingList];
|
|
231
|
+
for (const rule of pdacRules) {
|
|
232
|
+
if (!merged.includes(rule)) {
|
|
233
|
+
merged.push(rule);
|
|
234
|
+
rulesChanged = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
existingRules[artifact] = merged;
|
|
238
|
+
}
|
|
239
|
+
if (rulesChanged) {
|
|
240
|
+
config['rules'] = existingRules;
|
|
241
|
+
changes.push('Merged PDaC citation rules into openspec/config.yaml (rules:).');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- operations ---
|
|
245
|
+
const existingOperations =
|
|
246
|
+
config['operations'] &&
|
|
247
|
+
typeof config['operations'] === 'object' &&
|
|
248
|
+
!Array.isArray(config['operations'])
|
|
249
|
+
? { ...(config['operations'] as Record<string, unknown>) }
|
|
250
|
+
: {};
|
|
251
|
+
let opsChanged = false;
|
|
252
|
+
for (const opKey of ['apply', 'archive'] as const) {
|
|
253
|
+
const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
|
|
254
|
+
const existingOp =
|
|
255
|
+
existingOperations[opKey] &&
|
|
256
|
+
typeof existingOperations[opKey] === 'object' &&
|
|
257
|
+
!Array.isArray(existingOperations[opKey])
|
|
258
|
+
? { ...(existingOperations[opKey] as Record<string, unknown>) }
|
|
259
|
+
: {};
|
|
260
|
+
const existingGuidance = Array.isArray(existingOp['guidance'])
|
|
261
|
+
? [...(existingOp['guidance'] as string[])]
|
|
262
|
+
: [];
|
|
263
|
+
const merged = [...existingGuidance];
|
|
264
|
+
for (const guidance of pdacGuidance) {
|
|
265
|
+
if (!merged.includes(guidance)) {
|
|
266
|
+
merged.push(guidance);
|
|
267
|
+
opsChanged = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
existingOp['guidance'] = merged;
|
|
271
|
+
existingOperations[opKey] = existingOp;
|
|
272
|
+
}
|
|
273
|
+
if (opsChanged) {
|
|
274
|
+
config['operations'] = existingOperations;
|
|
275
|
+
changes.push('Merged PDaC operation guidance into openspec/config.yaml (operations:).');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { config, changes };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Serialize config back to YAML with a trailing newline. */
|
|
282
|
+
export function serializeConfig(config: Record<string, unknown>): string {
|
|
283
|
+
return `${stringify(config)}\n`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Read the ProductShape version from the integration-openspec package.json. */
|
|
287
|
+
async function productshapeVersion(): Promise<string> {
|
|
288
|
+
const packageJson = JSON.parse(
|
|
289
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8'),
|
|
290
|
+
) as { version: string };
|
|
291
|
+
return packageJson.version;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Add the OpenSpec integration to a repository.
|
|
296
|
+
*
|
|
297
|
+
* - Detects the OpenSpec CLI version; rejects if not found or below MIN_SUPPORTED_OPENSPEC.
|
|
298
|
+
* - Requires an existing `openspec/` workspace (run `openspec init` first).
|
|
299
|
+
* - Merges PDaC guidance into `openspec/config.yaml`, preserving existing user configuration.
|
|
300
|
+
* - Records metadata under `.product/integrations/openspec.json`.
|
|
301
|
+
* - Idempotent: running twice produces the same result.
|
|
302
|
+
* - `--dry-run` reports what would change without writing.
|
|
303
|
+
* - `--force` re-merges even if the PDaC block is already present (useful after a PDaC upgrade).
|
|
304
|
+
*/
|
|
305
|
+
export async function addOpenSpecIntegration(
|
|
306
|
+
root: string,
|
|
307
|
+
options: { force?: boolean; dryRun?: boolean } = {},
|
|
308
|
+
): Promise<{ written: string[]; changes: string[]; meta: OpenSpecIntegrationMeta }> {
|
|
309
|
+
const { force = false, dryRun = false } = options;
|
|
310
|
+
|
|
311
|
+
const detected = await detectOpenSpecVersion();
|
|
312
|
+
if ('error' in detected) {
|
|
313
|
+
throw new Error(detected.error);
|
|
314
|
+
}
|
|
315
|
+
if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!(await isOpenSpecWorkspace(root))) {
|
|
322
|
+
throw new Error('No OpenSpec workspace found. Run `openspec init` first.');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const existing = await readOpenSpecConfig(root);
|
|
326
|
+
|
|
327
|
+
// Detect a conflicting user context that is NOT the PDaC block and has no room for it.
|
|
328
|
+
// In practice we always append, so a "conflict" only arises if the user explicitly removed the
|
|
329
|
+
// PDaC block after a previous install and we are re-adding without --force.
|
|
330
|
+
if (!force && existing) {
|
|
331
|
+
const existingContext =
|
|
332
|
+
typeof existing['context'] === 'string' ? (existing['context'] as string) : undefined;
|
|
333
|
+
if (existingContext && extractPdacContext(existingContext) === null) {
|
|
334
|
+
// The user has context but no PDaC block. We will append, which is safe — not a conflict.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const { config, changes } = mergeConfig(existing);
|
|
339
|
+
|
|
340
|
+
if (changes.length === 0 && !force) {
|
|
341
|
+
// Already up to date. Still record/refresh metadata.
|
|
342
|
+
const meta: OpenSpecIntegrationMeta = {
|
|
343
|
+
provider: 'openspec',
|
|
344
|
+
version: await productshapeVersion(),
|
|
345
|
+
openspecVersion: detected.version,
|
|
346
|
+
installedAt: new Date().toISOString(),
|
|
347
|
+
configPath: CONFIG_RELATIVE,
|
|
348
|
+
};
|
|
349
|
+
if (!dryRun) {
|
|
350
|
+
await writeMeta(root, meta);
|
|
351
|
+
}
|
|
352
|
+
return { written: [], changes: [], meta };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const written: string[] = [];
|
|
356
|
+
if (!dryRun) {
|
|
357
|
+
const serialized = serializeConfig(config);
|
|
358
|
+
await mkdir(dirname(configPath(root)), { recursive: true });
|
|
359
|
+
await writeFile(configPath(root), serialized, 'utf8');
|
|
360
|
+
written.push(CONFIG_RELATIVE);
|
|
361
|
+
|
|
362
|
+
const meta: OpenSpecIntegrationMeta = {
|
|
363
|
+
provider: 'openspec',
|
|
364
|
+
version: await productshapeVersion(),
|
|
365
|
+
openspecVersion: detected.version,
|
|
366
|
+
installedAt: new Date().toISOString(),
|
|
367
|
+
configPath: CONFIG_RELATIVE,
|
|
368
|
+
};
|
|
369
|
+
await writeMeta(root, meta);
|
|
370
|
+
written.push(META_RELATIVE);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
written,
|
|
375
|
+
changes,
|
|
376
|
+
meta: {
|
|
377
|
+
provider: 'openspec',
|
|
378
|
+
version: await productshapeVersion(),
|
|
379
|
+
openspecVersion: detected.version,
|
|
380
|
+
installedAt: new Date().toISOString(),
|
|
381
|
+
configPath: CONFIG_RELATIVE,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Write the integration metadata file. */
|
|
387
|
+
async function writeMeta(root: string, meta: OpenSpecIntegrationMeta): Promise<void> {
|
|
388
|
+
const path = metaPath(root);
|
|
389
|
+
await mkdir(dirname(path), { recursive: true });
|
|
390
|
+
await writeFile(path, `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Read the integration metadata file, or null if absent. */
|
|
394
|
+
async function readMeta(root: string): Promise<OpenSpecIntegrationMeta | null> {
|
|
395
|
+
const path = metaPath(root);
|
|
396
|
+
if (!(await exists(path))) return null;
|
|
397
|
+
return JSON.parse(await readFile(path, 'utf8')) as OpenSpecIntegrationMeta;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Update (re-merge) the OpenSpec integration. Re-runs the merge so that a PDaC upgrade that
|
|
402
|
+
* changed the context block or rules is reflected. Idempotent.
|
|
403
|
+
*/
|
|
404
|
+
export async function updateOpenSpecIntegration(
|
|
405
|
+
root: string,
|
|
406
|
+
options: { force?: boolean; dryRun?: boolean } = {},
|
|
407
|
+
): Promise<{ written: string[]; changes: string[] }> {
|
|
408
|
+
// `add` already re-merges idempotently; `force` ensures the metadata is refreshed even when
|
|
409
|
+
// the config content did not change.
|
|
410
|
+
const result = await addOpenSpecIntegration(root, { force: true, dryRun: options.dryRun });
|
|
411
|
+
return { written: result.written, changes: result.changes };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Check the OpenSpec integration health. Verifies:
|
|
416
|
+
* - OpenSpec CLI is installed and the version is supported.
|
|
417
|
+
* - `openspec/config.yaml` exists and contains the PDaC context block.
|
|
418
|
+
* - `.product/integrations/openspec.json` metadata exists.
|
|
419
|
+
* - The config's PDaC block matches the current PDaC guidance (not removed or altered).
|
|
420
|
+
*/
|
|
421
|
+
export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
422
|
+
ok: boolean;
|
|
423
|
+
checks: { name: string; ok: boolean; detail: string }[];
|
|
424
|
+
}> {
|
|
425
|
+
const checks: { name: string; ok: boolean; detail: string }[] = [];
|
|
426
|
+
|
|
427
|
+
// 1. OpenSpec CLI installed and supported.
|
|
428
|
+
const detected = await detectOpenSpecVersion();
|
|
429
|
+
if ('error' in detected) {
|
|
430
|
+
checks.push({ name: 'openspec CLI', ok: false, detail: detected.error });
|
|
431
|
+
} else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
|
|
432
|
+
checks.push({
|
|
433
|
+
name: 'openspec CLI',
|
|
434
|
+
ok: false,
|
|
435
|
+
detail: `OpenSpec ${detected.version} is below minimum supported ${MIN_SUPPORTED_OPENSPEC}.`,
|
|
436
|
+
});
|
|
437
|
+
} else {
|
|
438
|
+
checks.push({
|
|
439
|
+
name: 'openspec CLI',
|
|
440
|
+
ok: true,
|
|
441
|
+
detail: `OpenSpec ${detected.version} detected.`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 2. openspec/config.yaml exists and contains PDaC context.
|
|
446
|
+
const config = await readOpenSpecConfig(root);
|
|
447
|
+
if (config === null) {
|
|
448
|
+
checks.push({
|
|
449
|
+
name: 'config.yaml',
|
|
450
|
+
ok: false,
|
|
451
|
+
detail: 'openspec/config.yaml not found. Run: prodshape integration add openspec',
|
|
452
|
+
});
|
|
453
|
+
} else {
|
|
454
|
+
const context =
|
|
455
|
+
typeof config['context'] === 'string' ? (config['context'] as string) : undefined;
|
|
456
|
+
const pdacBlock = extractPdacContext(context);
|
|
457
|
+
if (pdacBlock === null) {
|
|
458
|
+
checks.push({
|
|
459
|
+
name: 'config.yaml',
|
|
460
|
+
ok: false,
|
|
461
|
+
detail:
|
|
462
|
+
'PDaC context block missing from openspec/config.yaml. Run: prodshape integration add openspec',
|
|
463
|
+
});
|
|
464
|
+
} else if (pdacBlock !== PDAC_CONTEXT_BLOCK) {
|
|
465
|
+
checks.push({
|
|
466
|
+
name: 'config.yaml',
|
|
467
|
+
ok: false,
|
|
468
|
+
detail:
|
|
469
|
+
'PDaC context block in openspec/config.yaml is outdated. Run: prodshape integration update',
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
checks.push({
|
|
473
|
+
name: 'config.yaml',
|
|
474
|
+
ok: true,
|
|
475
|
+
detail: 'PDaC context block present and current.',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 3. Metadata file exists.
|
|
481
|
+
const meta = await readMeta(root);
|
|
482
|
+
if (meta === null) {
|
|
483
|
+
checks.push({
|
|
484
|
+
name: 'metadata',
|
|
485
|
+
ok: false,
|
|
486
|
+
detail:
|
|
487
|
+
'.product/integrations/openspec.json not found. Run: prodshape integration add openspec',
|
|
488
|
+
});
|
|
489
|
+
} else {
|
|
490
|
+
checks.push({
|
|
491
|
+
name: 'metadata',
|
|
492
|
+
ok: true,
|
|
493
|
+
detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 4. PDaC rules present.
|
|
498
|
+
if (config) {
|
|
499
|
+
const rules =
|
|
500
|
+
config['rules'] && typeof config['rules'] === 'object' && !Array.isArray(config['rules'])
|
|
501
|
+
? (config['rules'] as Record<string, unknown>)
|
|
502
|
+
: {};
|
|
503
|
+
const missingRules: string[] = [];
|
|
504
|
+
for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
|
|
505
|
+
const list = Array.isArray(rules[artifact]) ? (rules[artifact] as string[]) : [];
|
|
506
|
+
for (const rule of pdacRules) {
|
|
507
|
+
if (!list.includes(rule)) missingRules.push(artifact);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (missingRules.length > 0) {
|
|
511
|
+
checks.push({
|
|
512
|
+
name: 'rules',
|
|
513
|
+
ok: false,
|
|
514
|
+
detail: `PDaC rules missing for: ${[...new Set(missingRules)].join(', ')}. Run: prodshape integration update`,
|
|
515
|
+
});
|
|
516
|
+
} else {
|
|
517
|
+
checks.push({ name: 'rules', ok: true, detail: 'All PDaC citation rules present.' });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const ok = checks.every((c) => c.ok);
|
|
522
|
+
return { ok, checks };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Remove the PDaC-injected content from openspec/config.yaml, preserving user-authored content,
|
|
527
|
+
* and delete the metadata file.
|
|
528
|
+
*
|
|
529
|
+
* - Removes the PDaC context block (between sentinels) from `context:`.
|
|
530
|
+
* - Removes PDaC rules from each artifact (keeps user-authored rules).
|
|
531
|
+
* - Removes PDaC operation guidance (keeps user-authored guidance).
|
|
532
|
+
* - Deletes `.product/integrations/openspec.json`.
|
|
533
|
+
* - `--dry-run` reports what would be removed without writing.
|
|
534
|
+
*/
|
|
535
|
+
export async function removeOpenSpecIntegration(
|
|
536
|
+
root: string,
|
|
537
|
+
options: { dryRun?: boolean } = {},
|
|
538
|
+
): Promise<{ removed: string[] }> {
|
|
539
|
+
const { dryRun = false } = options;
|
|
540
|
+
const removed: string[] = [];
|
|
541
|
+
|
|
542
|
+
const config = await readOpenSpecConfig(root);
|
|
543
|
+
if (config !== null) {
|
|
544
|
+
let configChanged = false;
|
|
545
|
+
|
|
546
|
+
// Remove PDaC context block.
|
|
547
|
+
const context =
|
|
548
|
+
typeof config['context'] === 'string' ? (config['context'] as string) : undefined;
|
|
549
|
+
if (context) {
|
|
550
|
+
const pdacBlock = extractPdacContext(context);
|
|
551
|
+
if (pdacBlock !== null) {
|
|
552
|
+
const cleaned = context
|
|
553
|
+
.replace(pdacBlock, '')
|
|
554
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
555
|
+
.trimEnd();
|
|
556
|
+
if (cleaned === '') {
|
|
557
|
+
delete config['context'];
|
|
558
|
+
} else {
|
|
559
|
+
config['context'] = cleaned;
|
|
560
|
+
}
|
|
561
|
+
configChanged = true;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Remove PDaC rules from each artifact.
|
|
566
|
+
const rules =
|
|
567
|
+
config['rules'] && typeof config['rules'] === 'object' && !Array.isArray(config['rules'])
|
|
568
|
+
? (config['rules'] as Record<string, unknown>)
|
|
569
|
+
: null;
|
|
570
|
+
if (rules) {
|
|
571
|
+
let rulesChanged = false;
|
|
572
|
+
for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
|
|
573
|
+
if (!Array.isArray(rules[artifact])) continue;
|
|
574
|
+
const list = rules[artifact] as string[];
|
|
575
|
+
const filtered = list.filter((r) => !pdacRules.includes(r));
|
|
576
|
+
if (filtered.length !== list.length) {
|
|
577
|
+
if (filtered.length === 0) {
|
|
578
|
+
delete rules[artifact];
|
|
579
|
+
} else {
|
|
580
|
+
rules[artifact] = filtered;
|
|
581
|
+
}
|
|
582
|
+
rulesChanged = true;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (rulesChanged) {
|
|
586
|
+
if (Object.keys(rules).length === 0) {
|
|
587
|
+
delete config['rules'];
|
|
588
|
+
}
|
|
589
|
+
configChanged = true;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Remove PDaC operation guidance.
|
|
594
|
+
const operations =
|
|
595
|
+
config['operations'] &&
|
|
596
|
+
typeof config['operations'] === 'object' &&
|
|
597
|
+
!Array.isArray(config['operations'])
|
|
598
|
+
? (config['operations'] as Record<string, unknown>)
|
|
599
|
+
: null;
|
|
600
|
+
if (operations) {
|
|
601
|
+
let opsChanged = false;
|
|
602
|
+
for (const opKey of ['apply', 'archive'] as const) {
|
|
603
|
+
const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
|
|
604
|
+
const op =
|
|
605
|
+
operations[opKey] &&
|
|
606
|
+
typeof operations[opKey] === 'object' &&
|
|
607
|
+
!Array.isArray(operations[opKey])
|
|
608
|
+
? (operations[opKey] as Record<string, unknown>)
|
|
609
|
+
: null;
|
|
610
|
+
if (!op || !Array.isArray(op['guidance'])) continue;
|
|
611
|
+
const list = op['guidance'] as string[];
|
|
612
|
+
const filtered = list.filter((g) => !pdacGuidance.includes(g));
|
|
613
|
+
if (filtered.length !== list.length) {
|
|
614
|
+
if (filtered.length === 0) {
|
|
615
|
+
delete op['guidance'];
|
|
616
|
+
} else {
|
|
617
|
+
op['guidance'] = filtered;
|
|
618
|
+
}
|
|
619
|
+
opsChanged = true;
|
|
620
|
+
}
|
|
621
|
+
if (Object.keys(op).length === 0) {
|
|
622
|
+
delete operations[opKey];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (opsChanged) {
|
|
626
|
+
if (Object.keys(operations).length === 0) {
|
|
627
|
+
delete config['operations'];
|
|
628
|
+
}
|
|
629
|
+
configChanged = true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (configChanged && !dryRun) {
|
|
634
|
+
const serialized = serializeConfig(config);
|
|
635
|
+
await writeFile(configPath(root), serialized, 'utf8');
|
|
636
|
+
removed.push(CONFIG_RELATIVE);
|
|
637
|
+
} else if (configChanged && dryRun) {
|
|
638
|
+
removed.push(CONFIG_RELATIVE);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Remove metadata file.
|
|
643
|
+
if (await exists(metaPath(root))) {
|
|
644
|
+
if (!dryRun) {
|
|
645
|
+
await rm(metaPath(root), { force: true });
|
|
646
|
+
}
|
|
647
|
+
removed.push(META_RELATIVE);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return { removed };
|
|
651
|
+
}
|