ddduck 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/docs/cli.md +68 -7
- package/docs/definition-workflow.md +130 -0
- package/docs/getting-started.md +15 -45
- package/docs/templates/change-brief.md +48 -0
- package/package.json +4 -2
- package/schemas/model-diff.schema.json +107 -0
- package/scripts/ddduck.mjs +51 -2
- package/scripts/generate-docs.mjs +1 -1
- package/scripts/lib/cli-contract.mjs +16 -5
- package/scripts/lib/fr-to-code-audit.mjs +6 -0
- package/scripts/lib/product-authoring.mjs +52 -0
- package/scripts/lib/product-diff.mjs +155 -0
- package/scripts/lib/product-operation.mjs +9 -1
- package/scripts/lib/skill-installer.mjs +180 -45
- package/skills/update-ddduck-specs/SKILL.md +25 -83
- package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
- package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
- package/skills/update-ddduck-specs/references/reviewing-changes.md +45 -0
|
@@ -82,7 +82,7 @@ export function renderHelp(command) {
|
|
|
82
82
|
const usage = {
|
|
83
83
|
undefined: [
|
|
84
84
|
"Usage: ddduck <command> [options]",
|
|
85
|
-
"Commands: init, check, generate, query, install, create, move, split, retire.",
|
|
85
|
+
"Commands: init, check, generate, query, diff, install, create, move, split, retire.",
|
|
86
86
|
"Run `ddduck <command> --help` for command usage.",
|
|
87
87
|
],
|
|
88
88
|
init: [
|
|
@@ -118,19 +118,30 @@ export function renderHelp(command) {
|
|
|
118
118
|
"JSON: output is always JSON; --json is accepted and has no effect.",
|
|
119
119
|
"Options: --id <model-node-id> (repeatable for context), --root <product-root>, --history.",
|
|
120
120
|
],
|
|
121
|
+
diff: [
|
|
122
|
+
"Syntax: ddduck diff --base <previous-product-root> [--root <product-root>] [--json]",
|
|
123
|
+
"Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); --base is required; output is text.",
|
|
124
|
+
"Writes: nothing; source roots are validated independently and generated freshness is not required.",
|
|
125
|
+
"Success output: a stable-ID comparison of added, removed, changed, and relocated canonical records, with source digests and explicit exclusions.",
|
|
126
|
+
"Exit status: 0 on a completed comparison (including differences) or help; 2 for a busy root; 1 for invalid input, source, interrupted state, or different Model IDs.",
|
|
127
|
+
"JSON: --json emits one ModelDiff document; comparison is canonical YAML only and requires human interpretation.",
|
|
128
|
+
],
|
|
121
129
|
install: [
|
|
122
130
|
"Syntax: ddduck install skill update-ddduck-specs [--repo <repository-root>]",
|
|
123
131
|
"Defaults: --repo is the current directory.",
|
|
124
|
-
"Writes: the selected host skill
|
|
132
|
+
"Writes: the selected host skill bundle and .ddduck/agent-skills.lock.json in --repo.",
|
|
125
133
|
"Success output: one text result with the action (created, upgraded, or no-op), repository, canonical path, and lock path.",
|
|
126
134
|
"Exit status: 0 on installation, no-op, or help; nonzero on invalid input or conflicting host state.",
|
|
127
135
|
"JSON: unavailable; --json is not accepted.",
|
|
128
136
|
],
|
|
129
137
|
create: [
|
|
130
138
|
"Syntax: ddduck create guarantee --origin <origin> --classification <invariant|acceptance-criterion> --owner <domain-id> --statement <text> [--root <product-root>] [--json]",
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
139
|
+
"Syntax: ddduck create domain --id domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
|
|
140
|
+
"Syntax: ddduck create concept --id concept:<slug> --owner domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
|
|
141
|
+
"Syntax: ddduck create use-case --file <yaml-file> [--root <product-root>] [--json]",
|
|
142
|
+
"Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); guarantees allocate the next origin/classification serial; other kinds require an explicit ID or complete UseCase input.",
|
|
143
|
+
"Writes: the new node, its owning collection, and all generated views through staged publication; input files are read-only.",
|
|
144
|
+
"Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
|
|
134
145
|
"Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
|
|
135
146
|
"JSON: --json emits the same result as one JSON object.",
|
|
136
147
|
],
|
|
@@ -141,6 +141,12 @@ function renderReport(record, productionAnchors, testAnchors) {
|
|
|
141
141
|
requirement: record.requirement,
|
|
142
142
|
coverage: record.coverage,
|
|
143
143
|
verdict: record.verdict,
|
|
144
|
+
verificationScope: {
|
|
145
|
+
verdictSource: "input-record",
|
|
146
|
+
anchorIntegrityChecked: true,
|
|
147
|
+
testsExecuted: false,
|
|
148
|
+
behaviorVerified: false,
|
|
149
|
+
},
|
|
144
150
|
productionAnchors,
|
|
145
151
|
testAnchors,
|
|
146
152
|
reviewerDisposition: record.reviewerDisposition,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const kinds = {
|
|
2
|
+
Domain: { prefix: "domain", directory: "domains", collection: "domains" },
|
|
3
|
+
Concept: { prefix: "concept", directory: "concepts", collection: "concepts" },
|
|
4
|
+
UseCase: { prefix: "use-case", directory: "use-cases", collection: "useCases" },
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function buildAuthoringPlan(snapshot, request) {
|
|
8
|
+
const definition = kinds[request.kind];
|
|
9
|
+
if (!definition) throw new Error(`Unsupported creation kind ${request.kind}`);
|
|
10
|
+
const models = snapshot.nodes.filter(({ kind }) => kind === "Model");
|
|
11
|
+
if (models.length !== 1) throw new Error("Creation requires exactly one Model");
|
|
12
|
+
const [model] = models;
|
|
13
|
+
const node =
|
|
14
|
+
request.kind === "UseCase"
|
|
15
|
+
? globalThis.structuredClone(request.node)
|
|
16
|
+
: {
|
|
17
|
+
schemaVersion: "1",
|
|
18
|
+
kind: request.kind,
|
|
19
|
+
id: request.id,
|
|
20
|
+
model: model.id,
|
|
21
|
+
...(request.kind === "Concept" ? { ownerDomain: request.ownerDomain } : {}),
|
|
22
|
+
name: request.name,
|
|
23
|
+
purpose: request.purpose,
|
|
24
|
+
...(request.kind === "Domain" ? { concepts: [], interfaces: [], guarantees: [] } : {}),
|
|
25
|
+
};
|
|
26
|
+
if (node?.kind !== request.kind) throw new Error(`create ${definition.prefix} requires kind ${request.kind}`);
|
|
27
|
+
if (node.model !== model.id) throw new Error(`New node model must be ${model.id}`);
|
|
28
|
+
if (typeof node.id !== "string" || !new RegExp(`^${definition.prefix}:[a-z0-9][a-z0-9-]*$`).test(node.id)) {
|
|
29
|
+
throw new Error(`Invalid ${request.kind} ID ${JSON.stringify(node.id)}`);
|
|
30
|
+
}
|
|
31
|
+
if (snapshot.nodes.some(({ id }) => id === node.id)) throw new Error(`Model node ${node.id} already exists`);
|
|
32
|
+
const destination = `model/${definition.directory}/${node.id.slice(definition.prefix.length + 1)}.yaml`;
|
|
33
|
+
if (Object.values(snapshot.canonicalPaths).includes(destination)) {
|
|
34
|
+
throw new Error(`Canonical destination already occupied: ${destination}`);
|
|
35
|
+
}
|
|
36
|
+
const parent =
|
|
37
|
+
request.kind === "Concept"
|
|
38
|
+
? snapshot.nodes.find(({ id, kind }) => kind === "Domain" && id === node.ownerDomain)
|
|
39
|
+
: model;
|
|
40
|
+
if (!parent) throw new Error(`Unknown domain ${node.ownerDomain}`);
|
|
41
|
+
return {
|
|
42
|
+
operation: `create ${definition.prefix}`,
|
|
43
|
+
affectedIds: [node.id, parent.id],
|
|
44
|
+
replacements: [
|
|
45
|
+
{ path: destination, value: node },
|
|
46
|
+
{
|
|
47
|
+
path: snapshot.canonicalPaths[parent.id],
|
|
48
|
+
value: { ...parent, [definition.collection]: [...(parent[definition.collection] ?? []), node.id] },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { validateProduct } from "../check-model.mjs";
|
|
4
|
+
import { shellQuote, sourceDigestFromSources } from "./context-pack.mjs";
|
|
5
|
+
import { detectProductLayout, loadProductNodes } from "./product-layout.mjs";
|
|
6
|
+
import { assertProductNotBusy, findLeftoverOperationState } from "./product-operation.mjs";
|
|
7
|
+
|
|
8
|
+
export function compareProductSnapshots(before, after) {
|
|
9
|
+
const beforeModel = modelId(before);
|
|
10
|
+
const afterModel = modelId(after);
|
|
11
|
+
if (beforeModel !== afterModel) {
|
|
12
|
+
const error = new Error(`Cannot compare different Model IDs: ${beforeModel} and ${afterModel}`);
|
|
13
|
+
error.nextAction = "Choose two product roots representing the same Model ID, then retry the diff.";
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
const beforeNodes = new Map(before.nodes.map((node) => [node.id, node]));
|
|
17
|
+
const afterNodes = new Map(after.nodes.map((node) => [node.id, node]));
|
|
18
|
+
const result = { added: [], removed: [], changed: [], relocated: [] };
|
|
19
|
+
for (const id of [...new Set([...beforeNodes.keys(), ...afterNodes.keys()])].sort()) {
|
|
20
|
+
const previous = beforeNodes.get(id);
|
|
21
|
+
const current = afterNodes.get(id);
|
|
22
|
+
if (!previous) {
|
|
23
|
+
result.added.push(record(current, after.canonicalPaths[id]));
|
|
24
|
+
} else if (!current) {
|
|
25
|
+
result.removed.push(record(previous, before.canonicalPaths[id]));
|
|
26
|
+
} else {
|
|
27
|
+
const changes = fieldChanges(previous, current);
|
|
28
|
+
if (changes.length > 0) result.changed.push({ id, kind: current.kind, changes });
|
|
29
|
+
if (before.canonicalPaths[id] !== after.canonicalPaths[id]) {
|
|
30
|
+
result.relocated.push({
|
|
31
|
+
id,
|
|
32
|
+
kind: current.kind,
|
|
33
|
+
beforePath: before.canonicalPaths[id],
|
|
34
|
+
afterPath: after.canonicalPaths[id],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function compareProductRoots(beforeRoot, afterRoot) {
|
|
43
|
+
const before = loadDiffRoot(beforeRoot);
|
|
44
|
+
const after = loadDiffRoot(afterRoot);
|
|
45
|
+
return {
|
|
46
|
+
schemaVersion: "1",
|
|
47
|
+
kind: "ModelDiff",
|
|
48
|
+
before: { modelId: modelId(before.snapshot), sourceDigest: before.sourceDigest },
|
|
49
|
+
after: { modelId: modelId(after.snapshot), sourceDigest: after.sourceDigest },
|
|
50
|
+
scope: "canonical-yaml-only",
|
|
51
|
+
excludedScopes: ["decision-content", "evidence-content", "delivery-artifacts", "runtime"],
|
|
52
|
+
...compareProductSnapshots(before.snapshot, after.snapshot),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function renderProductDiff(report) {
|
|
57
|
+
const lines = [
|
|
58
|
+
`Model diff: ${report.before.modelId}`,
|
|
59
|
+
`Before: ${report.before.sourceDigest}`,
|
|
60
|
+
`After: ${report.after.sourceDigest}`,
|
|
61
|
+
`Scope: ${report.scope}`,
|
|
62
|
+
`Excluded: ${report.excludedScopes.join(", ")}`,
|
|
63
|
+
];
|
|
64
|
+
for (const entry of report.added) lines.push(`Added ${entry.id} (${entry.kind}) at ${entry.sourcePath}`);
|
|
65
|
+
for (const entry of report.removed) lines.push(`Removed ${entry.id} (${entry.kind}) from ${entry.sourcePath}`);
|
|
66
|
+
for (const entry of report.changed) {
|
|
67
|
+
lines.push(`Changed ${entry.id} (${entry.kind})`);
|
|
68
|
+
for (const change of entry.changes) {
|
|
69
|
+
const before = change.beforePresent ? JSON.stringify(change.before) : "<absent>";
|
|
70
|
+
const after = change.afterPresent ? JSON.stringify(change.after) : "<absent>";
|
|
71
|
+
lines.push(` ${change.path}: ${before} -> ${after}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const entry of report.relocated) {
|
|
75
|
+
lines.push(`Relocated ${entry.id} (${entry.kind}): ${entry.beforePath} -> ${entry.afterPath}`);
|
|
76
|
+
}
|
|
77
|
+
if ([report.added, report.removed, report.changed, report.relocated].every((entries) => entries.length === 0)) {
|
|
78
|
+
lines.push("No canonical record changes.");
|
|
79
|
+
}
|
|
80
|
+
lines.push("Structural comparison requires human interpretation; source reads are not atomic snapshots.");
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadDiffRoot(rootPath) {
|
|
85
|
+
const layout = detectProductLayout(rootPath);
|
|
86
|
+
try {
|
|
87
|
+
assertReadable(layout.root);
|
|
88
|
+
const check = validateProduct(layout.root, { includeDocumentation: false, sourceOnly: true });
|
|
89
|
+
if (check.errors.length > 0) throw new Error(`Validation failed for ${layout.root}:\n${check.errors.join("\n")}`);
|
|
90
|
+
const loaded = loadProductNodes(layout);
|
|
91
|
+
const canonicalPaths = Object.fromEntries(
|
|
92
|
+
[...loaded.nodeFiles].map(([id, filePath]) => [
|
|
93
|
+
id,
|
|
94
|
+
path.relative(layout.root, filePath).split(path.sep).join("/"),
|
|
95
|
+
]),
|
|
96
|
+
);
|
|
97
|
+
const sources = new Map([...loaded.nodeSources].map(([id, bytes]) => [canonicalPaths[id], bytes]));
|
|
98
|
+
assertReadable(layout.root);
|
|
99
|
+
return {
|
|
100
|
+
snapshot: { nodes: [...loaded.nodes.values()], canonicalPaths },
|
|
101
|
+
sourceDigest: sourceDigestFromSources(sources),
|
|
102
|
+
};
|
|
103
|
+
} catch (error) {
|
|
104
|
+
error.nextAction ??= `Fix the product root and its source files, run ddduck check --root ${shellQuote(layout.root)}, then retry the diff.`;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertReadable(root) {
|
|
110
|
+
assertProductNotBusy(root);
|
|
111
|
+
const leftover = findLeftoverOperationState(root);
|
|
112
|
+
if (leftover) {
|
|
113
|
+
const error = new Error(`An interrupted ddduck operation left ${leftover.entries.join(", ")} in ${root}`);
|
|
114
|
+
error.nextAction = `Run ddduck generate --root ${shellQuote(root)} to reclaim the interrupted operation state, then retry the diff.`;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function modelId(snapshot) {
|
|
120
|
+
const models = snapshot.nodes.filter((node) => node.kind === "Model");
|
|
121
|
+
if (models.length !== 1) throw new Error("A product snapshot must contain exactly one Model record");
|
|
122
|
+
return models[0].id;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function record(node, sourcePath) {
|
|
126
|
+
return { id: node.id, kind: node.kind, sourcePath, node: globalThis.structuredClone(node) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fieldChanges(before, after, pointer = "") {
|
|
130
|
+
const changes = [];
|
|
131
|
+
for (const key of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
|
|
132
|
+
const fieldPath = `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
|
133
|
+
const beforePresent = Object.hasOwn(before, key);
|
|
134
|
+
const afterPresent = Object.hasOwn(after, key);
|
|
135
|
+
const previous = before[key];
|
|
136
|
+
const current = after[key];
|
|
137
|
+
if (beforePresent && afterPresent && isDeepStrictEqual(previous, current)) continue;
|
|
138
|
+
if (beforePresent && afterPresent && isMapping(previous) && isMapping(current)) {
|
|
139
|
+
changes.push(...fieldChanges(previous, current, fieldPath));
|
|
140
|
+
} else {
|
|
141
|
+
changes.push({
|
|
142
|
+
path: fieldPath,
|
|
143
|
+
beforePresent,
|
|
144
|
+
afterPresent,
|
|
145
|
+
...(beforePresent ? { before: globalThis.structuredClone(previous) } : {}),
|
|
146
|
+
...(afterPresent ? { after: globalThis.structuredClone(current) } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return changes.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isMapping(value) {
|
|
154
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
155
|
+
}
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { isScalar, parseDocument, stringify } from "yaml";
|
|
29
|
+
import { isScalar, isSeq, parseDocument, stringify } from "yaml";
|
|
30
30
|
import { checkGeneratedDocs } from "../check-generated-docs.mjs";
|
|
31
31
|
import { checkGeneratedGraph } from "../check-generated-graph.mjs";
|
|
32
32
|
import { validateProduct } from "../check-model.mjs";
|
|
@@ -476,6 +476,14 @@ function serializeReplacement(target, relativePath, value) {
|
|
|
476
476
|
const existing = document.get(key, true);
|
|
477
477
|
if (isScalar(existing) && (next === null || typeof next !== "object")) {
|
|
478
478
|
existing.value = next;
|
|
479
|
+
} else if (
|
|
480
|
+
isSeq(existing) &&
|
|
481
|
+
Array.isArray(next) &&
|
|
482
|
+
next.length > existing.items.length &&
|
|
483
|
+
next.every((item) => item === null || typeof item !== "object") &&
|
|
484
|
+
existing.items.every((item, index) => isScalar(item) && item.value === next[index])
|
|
485
|
+
) {
|
|
486
|
+
for (const item of next.slice(existing.items.length)) existing.add(document.createNode(item));
|
|
479
487
|
} else {
|
|
480
488
|
document.set(key, document.createNode(next));
|
|
481
489
|
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Installer for the bundled update-ddduck-specs agent skill, behind `ddduck
|
|
3
3
|
* install skill`. Selects a host topology from the repository state (codex
|
|
4
4
|
* .agents/, claude-code .claude/, or shared via symlink), plans create,
|
|
5
|
-
* upgrade, or no-op against the canonical
|
|
5
|
+
* upgrade, or no-op against the canonical skill bundle and the
|
|
6
6
|
* .ddduck/agent-skills.lock.json lock, and applies the plan with atomic
|
|
7
7
|
* writes plus rollback of everything touched on failure. Conflicting host
|
|
8
|
-
* state or a locally modified
|
|
8
|
+
* state or a locally modified managed bundle refuses with a nextAction
|
|
9
9
|
* naming the exact path to resolve.
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import { createHash, randomUUID } from "node:crypto";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
|
|
26
|
-
const lockSchemaVersion =
|
|
26
|
+
const lockSchemaVersion = 2;
|
|
27
27
|
const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
|
|
28
28
|
|
|
29
29
|
const defaultOperations = {
|
|
@@ -126,7 +126,7 @@ const installTopologies = [
|
|
|
126
126
|
/**
|
|
127
127
|
* Install (or upgrade) the bundled skill into a repository: load, plan, apply.
|
|
128
128
|
* @param {{repository: string, skillName: string, skillPath: string, packageVersion: string, operations?: object}} options - Repository root, skill identity, bundled asset path, and ddduck version for the lock.
|
|
129
|
-
* @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
129
|
+
* @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
130
130
|
*/
|
|
131
131
|
export function installSkill(options) {
|
|
132
132
|
const bundle = loadSkillBundle(options);
|
|
@@ -135,25 +135,35 @@ export function installSkill(options) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
* Read the bundled skill
|
|
138
|
+
* Read the bundled skill directory and compute per-file and bundle digests.
|
|
139
139
|
* @param {{skillName: string, skillPath: string, operations?: object}} options - Skill name, SKILL.md path, and fs overrides for tests.
|
|
140
|
-
* @returns {{name: string, bytes: Buffer, sha256: string}} The loaded bundle.
|
|
140
|
+
* @returns {{name: string, files: {path: string, bytes: Buffer, sha256: string}[], skillSha256: string, bundleSha256: string, sha256: string}} The loaded bundle.
|
|
141
141
|
*/
|
|
142
142
|
export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
|
|
143
143
|
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
144
144
|
if (pathState(skillPath, resolvedOperations).type !== "file") {
|
|
145
145
|
throw new Error(`Missing bundled skill asset: ${skillPath}`);
|
|
146
146
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
147
|
+
const files = readBundleFiles(path.dirname(skillPath), resolvedOperations);
|
|
148
|
+
const skill = files.find(({ path: relativePath }) => relativePath === skillFileName);
|
|
149
|
+
if (!skill) throw new Error(`Missing bundled skill asset: ${skillPath}`);
|
|
150
|
+
const bundleSha256 = files.length === 1 ? skill.sha256 : digestBundle(files);
|
|
151
|
+
return {
|
|
152
|
+
name: skillName,
|
|
153
|
+
files,
|
|
154
|
+
skillSha256: skill.sha256,
|
|
155
|
+
bundleSha256,
|
|
156
|
+
// Preserve the internal single-file field while older callers migrate.
|
|
157
|
+
sha256: skill.sha256,
|
|
158
|
+
};
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
/**
|
|
152
162
|
* Inspect the repository's lock, canonical file, and host adapters and decide
|
|
153
163
|
* the action: create, upgrade, or no-op — or throw on conflicting host state,
|
|
154
164
|
* an incomplete lock, or a locally modified canonical skill.
|
|
155
|
-
* @param {{repository: string, skillName: string, bundle:
|
|
156
|
-
* @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[],
|
|
165
|
+
* @param {{repository: string, skillName: string, bundle: object, operations?: object}} options - Repository root, skill name, loaded bundle, and fs overrides.
|
|
166
|
+
* @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[], writeBundle: boolean, staleFiles: string[]}} The install plan for applySkillInstall.
|
|
157
167
|
*/
|
|
158
168
|
export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
|
|
159
169
|
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
@@ -183,55 +193,71 @@ export function planSkillInstall({ repository, skillName, bundle, operations = {
|
|
|
183
193
|
);
|
|
184
194
|
}
|
|
185
195
|
if (canonical.type === "absent") {
|
|
186
|
-
return createPlan({ paths, adapters,
|
|
196
|
+
return createPlan({ paths, adapters, writeBundle: true });
|
|
187
197
|
}
|
|
188
|
-
if (
|
|
198
|
+
if (
|
|
199
|
+
canonical.type !== "file" ||
|
|
200
|
+
sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.skillSha256 ||
|
|
201
|
+
bundle.files.length !== 1
|
|
202
|
+
) {
|
|
189
203
|
throw conflictingHostState(`Conflicting canonical skill destination: ${paths.canonical}`, paths.canonical);
|
|
190
204
|
}
|
|
191
|
-
return createPlan({ paths, adapters,
|
|
205
|
+
return createPlan({ paths, adapters, writeBundle: false });
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
const lock = lockState.value;
|
|
195
209
|
if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
|
|
196
|
-
if (canonical.type === "absent")
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
throw locallyModifiedCanonical(paths.canonical);
|
|
210
|
+
if (canonical.type === "absent") {
|
|
211
|
+
assertRemainingBundleMatchesLock(paths, lock, resolvedOperations);
|
|
212
|
+
return createPlan({ paths, adapters, writeBundle: true });
|
|
200
213
|
}
|
|
214
|
+
if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
|
|
215
|
+
assertInstalledBundleMatchesLock(paths, lock, resolvedOperations);
|
|
201
216
|
if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
|
|
202
217
|
|
|
218
|
+
const installedDigest = lock.bundleSha256 ?? lock.skillSha256;
|
|
219
|
+
const staleFiles =
|
|
220
|
+
lock.files
|
|
221
|
+
?.map(({ path: relativePath }) => relativePath)
|
|
222
|
+
.filter((relativePath) => !bundle.files.some((file) => file.path === relativePath)) ?? [];
|
|
223
|
+
|
|
203
224
|
return {
|
|
204
|
-
action:
|
|
225
|
+
action: installedDigest === bundle.bundleSha256 ? "no-op" : "upgrade",
|
|
205
226
|
paths,
|
|
206
227
|
topology,
|
|
207
228
|
adaptersToMaterialize: [],
|
|
208
|
-
|
|
229
|
+
writeBundle: installedDigest !== bundle.bundleSha256,
|
|
230
|
+
staleFiles,
|
|
209
231
|
};
|
|
210
232
|
}
|
|
211
233
|
|
|
212
234
|
/**
|
|
213
|
-
* Execute an install plan: atomically write
|
|
235
|
+
* Execute an install plan: atomically write each bundled file, materialize
|
|
214
236
|
* host adapters, and write the lock; on failure roll back everything touched
|
|
215
237
|
* and report any recovery failures in the thrown error.
|
|
216
|
-
* @param {{packageVersion: string, bundle:
|
|
217
|
-
* @returns {{action: string, skillSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
238
|
+
* @param {{packageVersion: string, bundle: object, plan: object, operations?: object}} options - ddduck version for the lock, loaded bundle, plan from planSkillInstall, and fs overrides.
|
|
239
|
+
* @returns {{action: string, skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
218
240
|
*/
|
|
219
241
|
export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
|
|
220
242
|
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
221
243
|
if (plan.action === "no-op") return installResult("no-op", bundle, plan);
|
|
222
244
|
|
|
223
245
|
const touched = [];
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
? resolvedOperations.readFileSync(plan.paths.canonical)
|
|
227
|
-
: null;
|
|
228
|
-
let canonicalWritten = false;
|
|
246
|
+
const originalBundle = plan.writeBundle ? snapshotDirectory(plan.paths.canonicalDirectory, resolvedOperations) : null;
|
|
247
|
+
let bundleWritten = false;
|
|
229
248
|
const materializedAdapters = [];
|
|
230
249
|
|
|
231
250
|
try {
|
|
232
|
-
if (plan.
|
|
233
|
-
|
|
234
|
-
|
|
251
|
+
if (plan.writeBundle) {
|
|
252
|
+
bundleWritten = true;
|
|
253
|
+
for (const file of bundle.files) {
|
|
254
|
+
atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, resolvedOperations, touched);
|
|
255
|
+
}
|
|
256
|
+
for (const relativePath of plan.staleFiles ?? []) {
|
|
257
|
+
const stalePath = path.join(plan.paths.canonicalDirectory, relativePath);
|
|
258
|
+
touched.push(stalePath);
|
|
259
|
+
resolvedOperations.rmSync(stalePath, { force: true });
|
|
260
|
+
}
|
|
235
261
|
}
|
|
236
262
|
for (const adapter of plan.adaptersToMaterialize) {
|
|
237
263
|
adapter.materialize({
|
|
@@ -252,8 +278,8 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
|
|
|
252
278
|
} catch (error) {
|
|
253
279
|
const recoveryFailures = restoreAfterFailure({
|
|
254
280
|
plan,
|
|
255
|
-
|
|
256
|
-
|
|
281
|
+
originalBundle,
|
|
282
|
+
bundleWritten,
|
|
257
283
|
materializedAdapters,
|
|
258
284
|
operations: resolvedOperations,
|
|
259
285
|
touched,
|
|
@@ -267,19 +293,21 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
|
|
|
267
293
|
function installResult(action, bundle, plan) {
|
|
268
294
|
return {
|
|
269
295
|
action,
|
|
270
|
-
skillSha256: bundle.
|
|
296
|
+
skillSha256: bundle.skillSha256,
|
|
297
|
+
bundleSha256: bundle.bundleSha256,
|
|
271
298
|
canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
|
|
272
299
|
lockPath: toPosixPath(lockRelativePath),
|
|
273
300
|
};
|
|
274
301
|
}
|
|
275
302
|
|
|
276
|
-
function createPlan({ paths, adapters,
|
|
303
|
+
function createPlan({ paths, adapters, writeBundle }) {
|
|
277
304
|
return {
|
|
278
305
|
action: "create",
|
|
279
306
|
paths,
|
|
280
307
|
topology: paths.topology,
|
|
281
308
|
adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
|
|
282
|
-
|
|
309
|
+
writeBundle,
|
|
310
|
+
staleFiles: [],
|
|
283
311
|
};
|
|
284
312
|
}
|
|
285
313
|
|
|
@@ -358,23 +386,39 @@ function selectTopology({ root, lockState, operations }) {
|
|
|
358
386
|
function isValidLock(lock, skillName, topology) {
|
|
359
387
|
return (
|
|
360
388
|
lock &&
|
|
361
|
-
lock.schemaVersion
|
|
389
|
+
[1, lockSchemaVersion].includes(lock.schemaVersion) &&
|
|
362
390
|
lock.skill === skillName &&
|
|
363
391
|
typeof lock.ddduckVersion === "string" &&
|
|
364
392
|
lock.ddduckVersion.length > 0 &&
|
|
365
393
|
toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
|
|
366
394
|
/^[a-f0-9]{64}$/.test(lock.skillSha256) &&
|
|
395
|
+
(lock.schemaVersion === 1 || isValidBundleLock(lock)) &&
|
|
367
396
|
JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
|
|
368
397
|
);
|
|
369
398
|
}
|
|
370
399
|
|
|
371
400
|
function createLock({ packageVersion, bundle, topology }) {
|
|
401
|
+
if (bundle.files.length === 1) {
|
|
402
|
+
return {
|
|
403
|
+
schemaVersion: 1,
|
|
404
|
+
skill: bundle.name,
|
|
405
|
+
ddduckVersion: packageVersion,
|
|
406
|
+
canonicalPath: toPosixPath(topology.canonicalRelativePath),
|
|
407
|
+
skillSha256: bundle.skillSha256,
|
|
408
|
+
adapters: expectedAdapters(topology),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
372
411
|
return {
|
|
373
412
|
schemaVersion: lockSchemaVersion,
|
|
374
413
|
skill: bundle.name,
|
|
375
414
|
ddduckVersion: packageVersion,
|
|
376
415
|
canonicalPath: toPosixPath(topology.canonicalRelativePath),
|
|
377
|
-
skillSha256: bundle.
|
|
416
|
+
skillSha256: bundle.skillSha256,
|
|
417
|
+
bundleSha256: bundle.bundleSha256,
|
|
418
|
+
files: bundle.files.map(({ path: relativePath, sha256: fileSha256 }) => ({
|
|
419
|
+
path: relativePath,
|
|
420
|
+
sha256: fileSha256,
|
|
421
|
+
})),
|
|
378
422
|
adapters: expectedAdapters(topology),
|
|
379
423
|
};
|
|
380
424
|
}
|
|
@@ -412,7 +456,7 @@ function atomicWrite(destination, content, operations, touched) {
|
|
|
412
456
|
}
|
|
413
457
|
}
|
|
414
458
|
|
|
415
|
-
function restoreAfterFailure({ plan,
|
|
459
|
+
function restoreAfterFailure({ plan, originalBundle, bundleWritten, materializedAdapters, operations, touched }) {
|
|
416
460
|
const failures = [];
|
|
417
461
|
for (const adapter of materializedAdapters.toReversed()) {
|
|
418
462
|
try {
|
|
@@ -425,17 +469,16 @@ function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materi
|
|
|
425
469
|
failures.push(`remove ${adapter.host} adapter: ${error.message}`);
|
|
426
470
|
}
|
|
427
471
|
}
|
|
428
|
-
if (!
|
|
472
|
+
if (!bundleWritten && originalBundle === null) return failures;
|
|
429
473
|
|
|
430
474
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
operations.rmSync(plan.paths.canonical, { force: true });
|
|
475
|
+
touched.push(plan.paths.canonicalDirectory);
|
|
476
|
+
operations.rmSync(plan.paths.canonicalDirectory, { recursive: true, force: true });
|
|
477
|
+
for (const file of originalBundle ?? []) {
|
|
478
|
+
atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, operations, touched);
|
|
436
479
|
}
|
|
437
480
|
} catch (error) {
|
|
438
|
-
failures.push(`restore canonical skill: ${error.message}`);
|
|
481
|
+
failures.push(`restore canonical skill bundle: ${error.message}`);
|
|
439
482
|
}
|
|
440
483
|
return failures;
|
|
441
484
|
}
|
|
@@ -454,6 +497,98 @@ function locallyModifiedCanonical(canonicalPath) {
|
|
|
454
497
|
return error;
|
|
455
498
|
}
|
|
456
499
|
|
|
500
|
+
function locallyModifiedBundle(bundlePath) {
|
|
501
|
+
const error = new Error(`Locally modified canonical skill bundle: ${bundlePath}`);
|
|
502
|
+
error.nextAction = `Revert or remove ${bundlePath}, then re-run ddduck install skill update-ddduck-specs.`;
|
|
503
|
+
return error;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function readBundleFiles(directory, operations, relativeDirectory = "") {
|
|
507
|
+
const current = path.join(directory, relativeDirectory);
|
|
508
|
+
const files = [];
|
|
509
|
+
const entries = operations
|
|
510
|
+
.readdirSync(current, { withFileTypes: true })
|
|
511
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const relativePath = path.join(relativeDirectory, entry.name);
|
|
514
|
+
if (entry.isDirectory()) {
|
|
515
|
+
files.push(...readBundleFiles(directory, operations, relativePath));
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (!entry.isFile()) throw new Error(`Unsupported bundled skill entry: ${path.join(directory, relativePath)}`);
|
|
519
|
+
const bytes = operations.readFileSync(path.join(directory, relativePath));
|
|
520
|
+
files.push({ path: toPosixPath(relativePath), bytes, sha256: sha256(bytes) });
|
|
521
|
+
}
|
|
522
|
+
return files;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function digestBundle(files) {
|
|
526
|
+
const digest = createHash("sha256");
|
|
527
|
+
for (const file of files) {
|
|
528
|
+
digest.update(`${file.path.length}:${file.path}:${file.bytes.length}:`);
|
|
529
|
+
digest.update(file.bytes);
|
|
530
|
+
}
|
|
531
|
+
return digest.digest("hex");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function isValidBundleLock(lock) {
|
|
535
|
+
return (
|
|
536
|
+
/^[a-f0-9]{64}$/.test(lock.bundleSha256) &&
|
|
537
|
+
Array.isArray(lock.files) &&
|
|
538
|
+
lock.files.length > 0 &&
|
|
539
|
+
lock.files.every(
|
|
540
|
+
(file) =>
|
|
541
|
+
file &&
|
|
542
|
+
typeof file.path === "string" &&
|
|
543
|
+
file.path.length > 0 &&
|
|
544
|
+
!path.isAbsolute(file.path) &&
|
|
545
|
+
!file.path.split("/").includes("..") &&
|
|
546
|
+
/^[a-f0-9]{64}$/.test(file.sha256),
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function assertInstalledBundleMatchesLock(paths, lock, operations) {
|
|
552
|
+
if (lock.schemaVersion === 1) {
|
|
553
|
+
if (sha256(operations.readFileSync(paths.canonical)) !== lock.skillSha256) {
|
|
554
|
+
throw locallyModifiedCanonical(paths.canonical);
|
|
555
|
+
}
|
|
556
|
+
const entries = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
557
|
+
if (entries.some(({ path: relativePath }) => relativePath !== skillFileName)) {
|
|
558
|
+
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
564
|
+
const expectedPaths = lock.files.map(({ path: relativePath }) => relativePath);
|
|
565
|
+
if (
|
|
566
|
+
JSON.stringify(installed.map(({ path: relativePath }) => relativePath)) !== JSON.stringify(expectedPaths) ||
|
|
567
|
+
installed.some((file, index) => file.sha256 !== lock.files[index].sha256)
|
|
568
|
+
) {
|
|
569
|
+
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function assertRemainingBundleMatchesLock(paths, lock, operations) {
|
|
574
|
+
const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
575
|
+
const expected = new Map(
|
|
576
|
+
lock.schemaVersion === 1
|
|
577
|
+
? [[skillFileName, lock.skillSha256]]
|
|
578
|
+
: lock.files.map(({ path: relativePath, sha256: fileSha256 }) => [relativePath, fileSha256]),
|
|
579
|
+
);
|
|
580
|
+
if (installed.some((file) => expected.get(file.path) !== file.sha256)) {
|
|
581
|
+
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function snapshotDirectory(directory, operations) {
|
|
586
|
+
const state = pathState(directory, operations);
|
|
587
|
+
if (state.type === "absent") return null;
|
|
588
|
+
if (state.type !== "directory") throw locallyModifiedBundle(directory);
|
|
589
|
+
return readBundleFiles(directory, operations);
|
|
590
|
+
}
|
|
591
|
+
|
|
457
592
|
function sha256(bytes) {
|
|
458
593
|
return createHash("sha256").update(bytes).digest("hex");
|
|
459
594
|
}
|