ddduck 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/LICENSE +21 -0
- package/README.md +125 -0
- package/docs/architecture.md +77 -0
- package/docs/cli.md +239 -0
- package/docs/getting-started.md +98 -0
- package/docs/model-reference.md +92 -0
- package/docs/model.md +75 -0
- package/package.json +76 -0
- package/policies/concept-owner-domain.yaml +8 -0
- package/policies/documentation-model-reference-resolution.yaml +8 -0
- package/policies/no-dangling-model-reference.yaml +8 -0
- package/policies/policy-spec.schema.json +23 -0
- package/schemas/context-pack.schema.json +83 -0
- package/schemas/fr-to-code-audit.schema.json +85 -0
- package/schemas/product/concept.schema.json +17 -0
- package/schemas/product/domain-interface.schema.json +18 -0
- package/schemas/product/domain.schema.json +24 -0
- package/schemas/product/evidence-anchor.schema.json +30 -0
- package/schemas/product/guarantee.schema.json +35 -0
- package/schemas/product/model.schema.json +20 -0
- package/schemas/product/relationship.schema.json +21 -0
- package/schemas/product/use-case.schema.json +27 -0
- package/scripts/audit-fr-to-code.mjs +162 -0
- package/scripts/check-generated-docs.mjs +58 -0
- package/scripts/check-generated-graph-svg.mjs +60 -0
- package/scripts/check-generated-graph.mjs +66 -0
- package/scripts/check-model.mjs +488 -0
- package/scripts/ddduck.mjs +542 -0
- package/scripts/generate-agent-readiness-report.mjs +23 -0
- package/scripts/generate-docs.mjs +205 -0
- package/scripts/generate-graph-svg.mjs +359 -0
- package/scripts/generate-graph.mjs +268 -0
- package/scripts/lib/agent-readiness-evals.mjs +433 -0
- package/scripts/lib/agent-readiness-report.mjs +79 -0
- package/scripts/lib/cli-contract.mjs +162 -0
- package/scripts/lib/context-pack.mjs +107 -0
- package/scripts/lib/ddduck-config.mjs +57 -0
- package/scripts/lib/fr-to-code-audit.mjs +144 -0
- package/scripts/lib/product-layout.mjs +93 -0
- package/scripts/lib/product-operation.mjs +431 -0
- package/scripts/lib/product-paths.mjs +43 -0
- package/scripts/lib/product-query.mjs +284 -0
- package/scripts/lib/product-root-resolver.mjs +167 -0
- package/scripts/lib/scan-ignore.mjs +8 -0
- package/scripts/lib/skill-installer.mjs +410 -0
- package/scripts/query-model.mjs +64 -0
- package/scripts/run-agent-readiness-evals.mjs +57 -0
- package/skills/update-ddduck-specs/SKILL.md +98 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { stringify } from "yaml";
|
|
20
|
+
import { checkGeneratedDocs } from "../check-generated-docs.mjs";
|
|
21
|
+
import { checkGeneratedGraph } from "../check-generated-graph.mjs";
|
|
22
|
+
import { validateProduct } from "../check-model.mjs";
|
|
23
|
+
import { writeModelOverview } from "../generate-docs.mjs";
|
|
24
|
+
import { writeModelGraph } from "../generate-graph.mjs";
|
|
25
|
+
import { detectProductLayout, loadProductSnapshot } from "./product-layout.mjs";
|
|
26
|
+
import { resolveContainedOutput } from "./product-paths.mjs";
|
|
27
|
+
import { nonProductSourceEntries } from "./product-root-resolver.mjs";
|
|
28
|
+
|
|
29
|
+
const lockRelativePath = ".ddduck-operation.lock";
|
|
30
|
+
const reclaimRelativePath = ".ddduck-operation.reclaim";
|
|
31
|
+
const stagingPrefix = ".ddduck-operation-stage-";
|
|
32
|
+
const generatedPaths = Object.freeze([
|
|
33
|
+
"generated/docs/model-overview.md",
|
|
34
|
+
"generated/graph/model-graph.json",
|
|
35
|
+
"generated/graph/model-graph.ndjson",
|
|
36
|
+
]);
|
|
37
|
+
// The canonical SVG is published atomically alongside the reported outputs, but
|
|
38
|
+
// kept off `generatedPaths` so it stays out of the CLI/query freshness contract.
|
|
39
|
+
// It is rendered by the Graphviz WASM engine, which is async, so we produce it in
|
|
40
|
+
// a child process to keep this runner synchronous (mirrors the check subprocess).
|
|
41
|
+
const auxiliaryGeneratedPaths = Object.freeze(["generated/graph/model-graph.svg"]);
|
|
42
|
+
const svgGeneratorScript = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "generate-graph-svg.mjs");
|
|
43
|
+
const excludedSourceEntries = new Set([...nonProductSourceEntries, lockRelativePath, reclaimRelativePath]);
|
|
44
|
+
|
|
45
|
+
export class ProductBusyError extends Error {
|
|
46
|
+
constructor(root, lockPath, ownerPid) {
|
|
47
|
+
const holder = ownerPid ? ` (held by running process ${ownerPid})` : "";
|
|
48
|
+
super(`Product root is busy: ${root}; ${lockPath} exists${holder}`);
|
|
49
|
+
this.name = "ProductBusyError";
|
|
50
|
+
this.nextAction = ownerPid
|
|
51
|
+
? `Wait for process ${ownerPid} to finish and retry, or delete ${lockPath} and retry if that process is not a ddduck operation.`
|
|
52
|
+
: `If no other ddduck operation is running on this product, delete ${lockPath} and retry.`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function runProductOperation({ root, transform }) {
|
|
57
|
+
if (typeof transform !== "function") throw new TypeError("product operation requires a transform function");
|
|
58
|
+
const normalizedRoot = realpathSync(path.resolve(root));
|
|
59
|
+
const lockPath = resolveContainedOutput(normalizedRoot, lockRelativePath);
|
|
60
|
+
const publishGeneratedTargets = generatedPaths.map((relativePath) =>
|
|
61
|
+
resolveContainedOutput(normalizedRoot, relativePath),
|
|
62
|
+
);
|
|
63
|
+
const publishAuxiliaryTargets = auxiliaryGeneratedPaths.map((relativePath) =>
|
|
64
|
+
resolveContainedOutput(normalizedRoot, relativePath),
|
|
65
|
+
);
|
|
66
|
+
let lockAcquired = false;
|
|
67
|
+
let stagingRoot;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
acquireLock(lockPath, normalizedRoot);
|
|
71
|
+
lockAcquired = true;
|
|
72
|
+
sweepDeadReclaimClaim(normalizedRoot);
|
|
73
|
+
sweepOrphanedStaging(normalizedRoot);
|
|
74
|
+
stagingRoot = mkdtempSync(resolveContainedOutput(normalizedRoot, stagingPrefix));
|
|
75
|
+
copyProductSource(normalizedRoot, stagingRoot);
|
|
76
|
+
|
|
77
|
+
const snapshot = loadProductSnapshot(detectProductLayout(stagingRoot));
|
|
78
|
+
const plan = validatePlan(transform(snapshot));
|
|
79
|
+
const canonicalPaths = applyReplacements(stagingRoot, plan.replacements);
|
|
80
|
+
validateStagedProduct(stagingRoot);
|
|
81
|
+
writeModelOverview(stagingRoot);
|
|
82
|
+
writeModelGraph(stagingRoot);
|
|
83
|
+
renderModelGraphSvg(stagingRoot);
|
|
84
|
+
checkGeneratedDocs(stagingRoot);
|
|
85
|
+
checkGeneratedGraph(stagingRoot);
|
|
86
|
+
|
|
87
|
+
const publishCanonicalTargets = canonicalPaths.map((relativePath) =>
|
|
88
|
+
resolveContainedOutput(normalizedRoot, relativePath),
|
|
89
|
+
);
|
|
90
|
+
preflightPublicationTargets(normalizedRoot, [
|
|
91
|
+
...canonicalPaths.map((relativePath, index) => ({
|
|
92
|
+
relativePath,
|
|
93
|
+
targetPath: publishCanonicalTargets[index],
|
|
94
|
+
})),
|
|
95
|
+
...generatedPaths.map((relativePath, index) => ({
|
|
96
|
+
relativePath,
|
|
97
|
+
targetPath: publishGeneratedTargets[index],
|
|
98
|
+
})),
|
|
99
|
+
...auxiliaryGeneratedPaths.map((relativePath, index) => ({
|
|
100
|
+
relativePath,
|
|
101
|
+
targetPath: publishAuxiliaryTargets[index],
|
|
102
|
+
})),
|
|
103
|
+
]);
|
|
104
|
+
for (let index = 0; index < canonicalPaths.length; index += 1) {
|
|
105
|
+
copyPublishedFile(stagingRoot, canonicalPaths[index], publishCanonicalTargets[index]);
|
|
106
|
+
}
|
|
107
|
+
for (let index = 0; index < generatedPaths.length; index += 1) {
|
|
108
|
+
copyPublishedFile(stagingRoot, generatedPaths[index], publishGeneratedTargets[index]);
|
|
109
|
+
}
|
|
110
|
+
for (let index = 0; index < auxiliaryGeneratedPaths.length; index += 1) {
|
|
111
|
+
copyPublishedFile(stagingRoot, auxiliaryGeneratedPaths[index], publishAuxiliaryTargets[index]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
operation: plan.operation,
|
|
116
|
+
root: normalizedRoot,
|
|
117
|
+
affectedIds: [...plan.affectedIds],
|
|
118
|
+
canonicalPaths,
|
|
119
|
+
generatedPaths: [...generatedPaths],
|
|
120
|
+
};
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
if (stagingRoot) rmSync(stagingRoot, { recursive: true, force: true });
|
|
124
|
+
} finally {
|
|
125
|
+
if (lockAcquired) unlinkSync(lockPath);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Render the canonical SVG into staging via a child process (the Graphviz WASM
|
|
131
|
+
// engine is async; this keeps runProductOperation synchronous).
|
|
132
|
+
function renderModelGraphSvg(stagingRoot) {
|
|
133
|
+
const result = spawnSync(process.execPath, [svgGeneratorScript, "--root", stagingRoot], {
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
});
|
|
136
|
+
if (result.error) {
|
|
137
|
+
throw new Error(`Failed to render the model graph SVG: ${result.error.message}`);
|
|
138
|
+
}
|
|
139
|
+
if (result.status !== 0) {
|
|
140
|
+
throw new Error(`Failed to render the model graph SVG: ${result.stderr.trim()}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function preflightPublicationTargets(root, targets) {
|
|
145
|
+
for (const { relativePath, targetPath } of targets) {
|
|
146
|
+
const targetMetadata = lstatIfPresent(targetPath);
|
|
147
|
+
if (targetMetadata?.isSymbolicLink()) {
|
|
148
|
+
throw new Error(`Refusing to publish ${relativePath}: destination must not be a symbolic link`);
|
|
149
|
+
}
|
|
150
|
+
if (targetMetadata && !targetMetadata.isFile()) {
|
|
151
|
+
throw new Error(`Refusing to publish ${relativePath}: destination must be a regular file`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let parent = path.dirname(targetPath);
|
|
155
|
+
while (parent !== root) {
|
|
156
|
+
const parentMetadata = lstatIfPresent(parent);
|
|
157
|
+
if (parentMetadata?.isSymbolicLink()) {
|
|
158
|
+
throw new Error(`Refusing to publish ${relativePath}: destination parent must not be a symbolic link`);
|
|
159
|
+
}
|
|
160
|
+
if (parentMetadata && !parentMetadata.isDirectory()) {
|
|
161
|
+
throw new Error(`Refusing to publish ${relativePath}: destination parent must be a directory`);
|
|
162
|
+
}
|
|
163
|
+
const next = path.dirname(parent);
|
|
164
|
+
if (next === parent) break;
|
|
165
|
+
parent = next;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function lstatIfPresent(targetPath) {
|
|
171
|
+
try {
|
|
172
|
+
return lstatSync(targetPath);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.code === "ENOENT") return null;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function acquireLock(lockPath, root) {
|
|
180
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
181
|
+
try {
|
|
182
|
+
createLockFile(lockPath);
|
|
183
|
+
return;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error.code !== "EEXIST") throw error;
|
|
186
|
+
const owner = readLockOwner(lockPath);
|
|
187
|
+
if (owner.state === "held" && isProcessAlive(owner.pid)) {
|
|
188
|
+
throw new ProductBusyError(root, lockPath, owner.pid);
|
|
189
|
+
}
|
|
190
|
+
if (owner.state === "unknown" || attempt > 0) throw new ProductBusyError(root, lockPath, null);
|
|
191
|
+
if (owner.state === "held") reclaimStaleLock(lockPath, root);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function reclaimStaleLock(lockPath, root) {
|
|
197
|
+
const claimPath = path.join(path.dirname(lockPath), reclaimRelativePath);
|
|
198
|
+
acquireReclaimClaim(claimPath, root);
|
|
199
|
+
try {
|
|
200
|
+
const owner = readLockOwner(lockPath);
|
|
201
|
+
if (owner.state === "held" && isProcessAlive(owner.pid)) {
|
|
202
|
+
throw new ProductBusyError(root, lockPath, owner.pid);
|
|
203
|
+
}
|
|
204
|
+
if (owner.state === "unknown") throw new ProductBusyError(root, lockPath, null);
|
|
205
|
+
if (owner.state === "held") removeLockIfPresent(lockPath);
|
|
206
|
+
} finally {
|
|
207
|
+
removeLockIfPresent(claimPath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function acquireReclaimClaim(claimPath, root) {
|
|
212
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
213
|
+
try {
|
|
214
|
+
createLockFile(claimPath);
|
|
215
|
+
return;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error.code !== "EEXIST") throw error;
|
|
218
|
+
const owner = readLockOwner(claimPath);
|
|
219
|
+
if (owner.state === "held" && isProcessAlive(owner.pid)) {
|
|
220
|
+
throw new ProductBusyError(root, claimPath, owner.pid);
|
|
221
|
+
}
|
|
222
|
+
if (owner.state === "unknown" || attempt > 0) throw new ProductBusyError(root, claimPath, null);
|
|
223
|
+
if (owner.state === "held") takeOverDeadClaim(claimPath, owner.pid, root);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function takeOverDeadClaim(claimPath, deadPid, root) {
|
|
229
|
+
// Rename is the atomic step: only one taker can move the dead claim aside,
|
|
230
|
+
// so concurrent takers cannot both treat it as removed.
|
|
231
|
+
const takeoverPath = `${claimPath}.${process.pid}`;
|
|
232
|
+
try {
|
|
233
|
+
renameSync(claimPath, takeoverPath);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error.code === "ENOENT") return;
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
const captured = readLockOwner(takeoverPath);
|
|
239
|
+
if (captured.state === "held" && captured.pid === deadPid && !isProcessAlive(deadPid)) {
|
|
240
|
+
removeLockIfPresent(takeoverPath);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// The claim changed hands between read and rename: restore it and yield.
|
|
244
|
+
try {
|
|
245
|
+
renameSync(takeoverPath, claimPath);
|
|
246
|
+
} catch {
|
|
247
|
+
removeLockIfPresent(takeoverPath);
|
|
248
|
+
}
|
|
249
|
+
throw new ProductBusyError(root, claimPath, captured.state === "held" ? captured.pid : null);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function createLockFile(lockPath) {
|
|
253
|
+
let descriptor;
|
|
254
|
+
let created = false;
|
|
255
|
+
try {
|
|
256
|
+
descriptor = openSync(lockPath, "wx", 0o600);
|
|
257
|
+
created = true;
|
|
258
|
+
writeFileSync(descriptor, `${process.pid}\n`);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (created) unlinkSync(lockPath);
|
|
261
|
+
throw error;
|
|
262
|
+
} finally {
|
|
263
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readLockOwner(lockPath) {
|
|
268
|
+
let content;
|
|
269
|
+
try {
|
|
270
|
+
content = readFileSync(lockPath, "utf8");
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error.code === "ENOENT") return { state: "released" };
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
const recordedPid = content.trim();
|
|
276
|
+
const pid = Number.parseInt(recordedPid, 10);
|
|
277
|
+
if (Number.isInteger(pid) && pid > 0 && String(pid) === recordedPid) return { state: "held", pid };
|
|
278
|
+
return { state: "unknown" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isProcessAlive(pid) {
|
|
282
|
+
try {
|
|
283
|
+
process.kill(pid, 0);
|
|
284
|
+
return true;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return error.code !== "ESRCH";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function removeLockIfPresent(lockPath) {
|
|
291
|
+
try {
|
|
292
|
+
unlinkSync(lockPath);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error.code !== "ENOENT") throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function sweepDeadReclaimClaim(root) {
|
|
299
|
+
for (const entry of readdirSync(root)) {
|
|
300
|
+
if (entry !== reclaimRelativePath && !entry.startsWith(`${reclaimRelativePath}.`)) continue;
|
|
301
|
+
const claimPath = path.join(root, entry);
|
|
302
|
+
const owner = readLockOwner(claimPath);
|
|
303
|
+
if (owner.state !== "held" || !isProcessAlive(owner.pid)) removeLockIfPresent(claimPath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function sweepOrphanedStaging(root) {
|
|
308
|
+
for (const entry of readdirSync(root)) {
|
|
309
|
+
if (entry.startsWith(stagingPrefix)) rmSync(path.join(root, entry), { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function assertProductNotBusy(root) {
|
|
314
|
+
const normalizedRoot = realpathSync(path.resolve(root));
|
|
315
|
+
const lockPath = path.join(normalizedRoot, lockRelativePath);
|
|
316
|
+
const owner = readLockOwner(lockPath);
|
|
317
|
+
if (owner.state === "held" && isProcessAlive(owner.pid)) {
|
|
318
|
+
throw new ProductBusyError(normalizedRoot, lockPath, owner.pid);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function findLeftoverOperationState(root) {
|
|
323
|
+
const normalizedRoot = realpathSync(path.resolve(root));
|
|
324
|
+
const lockPath = path.join(normalizedRoot, lockRelativePath);
|
|
325
|
+
const owner = readLockOwner(lockPath);
|
|
326
|
+
if (owner.state === "held" && isProcessAlive(owner.pid)) return null;
|
|
327
|
+
const entries = [];
|
|
328
|
+
if (owner.state !== "released") entries.push(lockRelativePath);
|
|
329
|
+
const claimEntries = readdirSync(normalizedRoot).filter(
|
|
330
|
+
(entry) => entry === reclaimRelativePath || entry.startsWith(`${reclaimRelativePath}.`),
|
|
331
|
+
);
|
|
332
|
+
for (const entry of claimEntries.sort()) {
|
|
333
|
+
const claimOwner = readLockOwner(path.join(normalizedRoot, entry));
|
|
334
|
+
if (claimOwner.state !== "released" && !(claimOwner.state === "held" && isProcessAlive(claimOwner.pid))) {
|
|
335
|
+
entries.push(entry);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
entries.push(
|
|
339
|
+
...readdirSync(normalizedRoot)
|
|
340
|
+
.filter((entry) => entry.startsWith(stagingPrefix))
|
|
341
|
+
.sort(),
|
|
342
|
+
);
|
|
343
|
+
return entries.length === 0 ? null : { root: normalizedRoot, entries };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function copyProductSource(root, stagingRoot) {
|
|
347
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
348
|
+
if (
|
|
349
|
+
excludedSourceEntries.has(entry.name) ||
|
|
350
|
+
entry.name.startsWith(stagingPrefix) ||
|
|
351
|
+
entry.name.startsWith(`${reclaimRelativePath}.`) ||
|
|
352
|
+
path.join(root, entry.name) === stagingRoot
|
|
353
|
+
) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
copySourceEntry(path.join(root, entry.name), path.join(stagingRoot, entry.name), root);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function copySourceEntry(sourcePath, destinationPath, root) {
|
|
361
|
+
const metadata = lstatSync(sourcePath);
|
|
362
|
+
if (metadata.isSymbolicLink()) {
|
|
363
|
+
throw new Error(`Refusing to stage symbolic link ${path.relative(root, sourcePath)}`);
|
|
364
|
+
}
|
|
365
|
+
if (metadata.isDirectory()) {
|
|
366
|
+
mkdirSync(destinationPath);
|
|
367
|
+
for (const entry of readdirSync(sourcePath).sort()) {
|
|
368
|
+
copySourceEntry(path.join(sourcePath, entry), path.join(destinationPath, entry), root);
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!metadata.isFile()) throw new Error(`Refusing to stage non-file ${path.relative(root, sourcePath)}`);
|
|
373
|
+
copyFileSync(sourcePath, destinationPath);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validatePlan(plan) {
|
|
377
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan)) {
|
|
378
|
+
throw new TypeError("product transform must return an operation plan");
|
|
379
|
+
}
|
|
380
|
+
if (typeof plan.operation !== "string" || plan.operation.length === 0) {
|
|
381
|
+
throw new TypeError("product operation plan requires operation");
|
|
382
|
+
}
|
|
383
|
+
if (!Array.isArray(plan.affectedIds) || !plan.affectedIds.every((id) => typeof id === "string")) {
|
|
384
|
+
throw new TypeError("product operation plan requires affectedIds");
|
|
385
|
+
}
|
|
386
|
+
if (!Array.isArray(plan.replacements)) {
|
|
387
|
+
throw new TypeError("product operation plan requires replacements");
|
|
388
|
+
}
|
|
389
|
+
return plan;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function applyReplacements(stagingRoot, replacements) {
|
|
393
|
+
const canonicalPaths = [];
|
|
394
|
+
const seenPaths = new Set();
|
|
395
|
+
for (const replacement of replacements) {
|
|
396
|
+
const relativePath = normalizeCanonicalPath(replacement?.path);
|
|
397
|
+
if (seenPaths.has(relativePath)) throw new Error(`Duplicate canonical replacement ${relativePath}`);
|
|
398
|
+
if (!replacement || typeof replacement.value !== "object" || replacement.value === null) {
|
|
399
|
+
throw new TypeError(`Canonical replacement ${relativePath} requires a YAML mapping value`);
|
|
400
|
+
}
|
|
401
|
+
const target = resolveContainedOutput(stagingRoot, relativePath);
|
|
402
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
403
|
+
writeFileSync(target, stringify(replacement.value));
|
|
404
|
+
seenPaths.add(relativePath);
|
|
405
|
+
canonicalPaths.push(relativePath);
|
|
406
|
+
}
|
|
407
|
+
return canonicalPaths.sort();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function normalizeCanonicalPath(relativePath) {
|
|
411
|
+
if (typeof relativePath !== "string") throw new TypeError("canonical replacement requires path");
|
|
412
|
+
const normalized = relativePath.split(path.sep).join("/");
|
|
413
|
+
if (
|
|
414
|
+
normalized !== "product.yaml" &&
|
|
415
|
+
!/^model\/(?:domains|concepts|relationships|use-cases|interfaces|guarantees)\/[^/]+\.yaml$/.test(normalized)
|
|
416
|
+
) {
|
|
417
|
+
throw new Error(`Invalid canonical YAML replacement path ${JSON.stringify(relativePath)}`);
|
|
418
|
+
}
|
|
419
|
+
return normalized;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function validateStagedProduct(stagingRoot) {
|
|
423
|
+
const check = validateProduct(stagingRoot);
|
|
424
|
+
if (check.errors.length > 0) throw new Error(check.errors.join("\n"));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function copyPublishedFile(stagingRoot, relativePath, targetPath) {
|
|
428
|
+
const sourcePath = resolveContainedOutput(stagingRoot, relativePath);
|
|
429
|
+
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
430
|
+
copyFileSync(sourcePath, targetPath);
|
|
431
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function resolveContainedOutput(rootPath, relativePath) {
|
|
5
|
+
if (path.isAbsolute(relativePath)) {
|
|
6
|
+
throw new Error("output path must be relative to root");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const segments = relativePath.split(/[\\/]/);
|
|
10
|
+
if (segments.includes("..")) {
|
|
11
|
+
throw new Error("output path must not contain .. traversal");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const realRoot = realpathSync(rootPath);
|
|
15
|
+
const target = path.resolve(realRoot, relativePath);
|
|
16
|
+
const relative = path.relative(realRoot, target);
|
|
17
|
+
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
18
|
+
throw new Error("output path must stay below root");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let current = realRoot;
|
|
22
|
+
for (const segment of relative.split(path.sep).slice(0, -1)) {
|
|
23
|
+
current = path.join(current, segment);
|
|
24
|
+
if (lstatOrMissing(current)?.isSymbolicLink()) {
|
|
25
|
+
throw new Error("output path must not traverse a symbolic link");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (lstatOrMissing(target)?.isSymbolicLink()) {
|
|
30
|
+
throw new Error("output path must not target a symbolic link");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function lstatOrMissing(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
return lstatSync(filePath);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === "ENOENT") return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|