skill-family-engineering-kit 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/CODE_OF_CONDUCT.md +131 -0
- package/CONTRIBUTING.md +69 -0
- package/LICENSE +201 -0
- package/README.md +74 -0
- package/SECURITY.md +34 -0
- package/data/licensing/registry.json +211 -0
- package/data/licensing/schema.json +207 -0
- package/docs/.nojekyll +0 -0
- package/docs/404.html +613 -0
- package/docs/architecture/index.html +904 -0
- package/docs/assets/images/favicon.png +0 -0
- package/docs/assets/javascripts/bundle.d7400e89.min.js +16 -0
- package/docs/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
- package/docs/assets/javascripts/lunr/tinyseg.js +206 -0
- package/docs/assets/javascripts/lunr/wordcut.js +6708 -0
- package/docs/assets/javascripts/workers/search.2c215733.min.js +42 -0
- package/docs/assets/stylesheets/main.ec1eaa64.min.css +1 -0
- package/docs/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
- package/docs/git-lifecycle/index.html +976 -0
- package/docs/help/index.html +907 -0
- package/docs/index.html +837 -0
- package/docs/integration/audit/baseline/audit-codes.json +62 -0
- package/docs/integration/audit/failure-evidence/index.html +872 -0
- package/docs/integration/audit/independence/index.html +841 -0
- package/docs/integration/audit/index.html +826 -0
- package/docs/integration/audit/mutation-taxonomy/index.html +1022 -0
- package/docs/integration/audit/schemas/audit-evidence.schema.json +182 -0
- package/docs/integration/audit/version-compatibility/index.html +845 -0
- package/docs/migration/index.html +1213 -0
- package/docs/quickstart/index.html +842 -0
- package/docs/search/search_index.json +1 -0
- package/docs/setup/index.html +941 -0
- package/docs/sitemap.xml +51 -0
- package/package.json +42 -0
- package/src/adopt-plan.mjs +594 -0
- package/src/check.mjs +415 -0
- package/src/cli.mjs +230 -0
- package/src/errors.mjs +144 -0
- package/src/gitprobe.mjs +230 -0
- package/src/identity-check.mjs +563 -0
- package/src/index.mjs +233 -0
- package/src/license-texts/Apache-2.0.txt +201 -0
- package/src/license-texts/MIT.txt +21 -0
- package/src/licensing.mjs +299 -0
- package/src/migration.mjs +427 -0
- package/src/projection.mjs +397 -0
- package/src/scaffold.mjs +178 -0
- package/src/skeleton.mjs +886 -0
- package/src/workspace.mjs +236 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { lstat, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ContractsError } from "skill-family-contracts";
|
|
4
|
+
import {
|
|
5
|
+
classifyPathInput,
|
|
6
|
+
computeResourceClosure,
|
|
7
|
+
digestBytes,
|
|
8
|
+
resolveContained,
|
|
9
|
+
writeFileAtomic,
|
|
10
|
+
} from "skill-family-harness-node";
|
|
11
|
+
import { KIT_ERROR_KINDS, kitError, refusalError } from "./errors.mjs";
|
|
12
|
+
import { KIT_TOOL_NAME, KIT_VERSION, PROJECTION_MANIFEST_PATH } from "./skeleton.mjs";
|
|
13
|
+
import {
|
|
14
|
+
loadTargetFacts,
|
|
15
|
+
matchAnyGlob,
|
|
16
|
+
normalizeRelPath,
|
|
17
|
+
readOptionalJson,
|
|
18
|
+
resolveTargetRoot,
|
|
19
|
+
} from "./workspace.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* projection — write ONLY manifest-authorized managed artifacts.
|
|
23
|
+
*
|
|
24
|
+
* The projection manifest is the authorization document. Every entry is
|
|
25
|
+
* checked before ANY write happens (two-phase execution):
|
|
26
|
+
*
|
|
27
|
+
* 1. path input classification (harness): traversal, absolute, UNC, and
|
|
28
|
+
* Windows-drive inputs are refused before resolution;
|
|
29
|
+
* 2. containment (harness resolveContained): escaping and symlink-escape
|
|
30
|
+
* paths are refused;
|
|
31
|
+
* 3. self-projection: the manifest may not list itself;
|
|
32
|
+
* 4. authorization: the path must be declared managed by the target's own
|
|
33
|
+
* facts (file registry, project manifest managedFiles, or managed-file
|
|
34
|
+
* lock). A target without any managed declaration authorizes nothing;
|
|
35
|
+
* 5. handwritten protection: a path matching handwritten patterns is never
|
|
36
|
+
* written, even if some declaration also claims it as managed;
|
|
37
|
+
* 6. conflict guard: existing files are overwritten only when the entry
|
|
38
|
+
* declares the exact prior sha256; identical existing bytes are an
|
|
39
|
+
* idempotent no-op; anything else is a conflict.
|
|
40
|
+
*
|
|
41
|
+
* If any entry fails a check, the whole projection is refused and nothing
|
|
42
|
+
* is written. A failure during or after the write phase rolls everything
|
|
43
|
+
* back: overwritten files are restored from their in-memory prior bytes
|
|
44
|
+
* and every file created by this run is removed, so a failed projection
|
|
45
|
+
* never leaves the target half-updated (FC-17).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
49
|
+
|
|
50
|
+
function invalidManifest(message, extraDetails) {
|
|
51
|
+
return kitError(KIT_ERROR_KINDS.INVALID_MANIFEST, message, extraDetails);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Loads and shape-validates the projection manifest (kit-level shape). */
|
|
55
|
+
export async function loadProjectionManifest(rootAbs, manifestRelPath) {
|
|
56
|
+
const rawManifestPath = manifestRelPath ?? PROJECTION_MANIFEST_PATH;
|
|
57
|
+
// Classification runs on the RAW input: ambiguous cross-platform inputs
|
|
58
|
+
// must be refused before any normalization can mask them.
|
|
59
|
+
const classification = classifyPathInput(rawManifestPath);
|
|
60
|
+
if (!classification.ok) {
|
|
61
|
+
throw kitError(
|
|
62
|
+
classification.kind,
|
|
63
|
+
`projection manifest path rejected (kind: ${classification.kind})`,
|
|
64
|
+
{ input: rawManifestPath },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const manifestPath = normalizeRelPath(rawManifestPath);
|
|
68
|
+
const loaded = await readOptionalJson(rootAbs, manifestPath);
|
|
69
|
+
if (!loaded.ok) {
|
|
70
|
+
throw invalidManifest(
|
|
71
|
+
loaded.reason === "missing"
|
|
72
|
+
? `projection manifest not found: ${manifestPath}`
|
|
73
|
+
: `projection manifest is not valid JSON: ${manifestPath}`,
|
|
74
|
+
{ manifest: manifestPath },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const manifest = loaded.value;
|
|
78
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
79
|
+
throw invalidManifest("projection manifest must be a JSON object");
|
|
80
|
+
}
|
|
81
|
+
if (manifest.schemaVersion !== 1) {
|
|
82
|
+
throw invalidManifest("projection manifest schemaVersion must be 1");
|
|
83
|
+
}
|
|
84
|
+
if (manifest.kind !== "skill-family.projection-manifest") {
|
|
85
|
+
throw invalidManifest("projection manifest kind must be skill-family.projection-manifest");
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(manifest.entries) || manifest.entries.length === 0) {
|
|
88
|
+
throw invalidManifest("projection manifest entries must be a non-empty array");
|
|
89
|
+
}
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const [index, entry] of manifest.entries.entries()) {
|
|
92
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
93
|
+
throw invalidManifest(`entries[${index}] must be an object`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof entry.path !== "string" || entry.path.length === 0) {
|
|
96
|
+
throw invalidManifest(`entries[${index}].path must be a non-empty string`);
|
|
97
|
+
}
|
|
98
|
+
const normalized = normalizeRelPath(entry.path);
|
|
99
|
+
if (seen.has(normalized)) {
|
|
100
|
+
throw invalidManifest(`duplicate entries[].path: ${normalized}`);
|
|
101
|
+
}
|
|
102
|
+
seen.add(normalized);
|
|
103
|
+
const content = entry.content;
|
|
104
|
+
const hasText = content && typeof content.text === "string";
|
|
105
|
+
const hasBase64 = content && typeof content.base64 === "string";
|
|
106
|
+
if (!content || typeof content !== "object" || hasText === hasBase64) {
|
|
107
|
+
throw invalidManifest(
|
|
108
|
+
`entries[${index}].content must carry exactly one of { text } or { base64 }`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (hasBase64 && !/^[A-Za-z0-9+/]*={0,2}$/.test(content.base64)) {
|
|
112
|
+
throw invalidManifest(`entries[${index}].content.base64 is not valid base64`);
|
|
113
|
+
}
|
|
114
|
+
if (entry.expect !== undefined) {
|
|
115
|
+
const expect = entry.expect;
|
|
116
|
+
const states = ["absent", "sha256"];
|
|
117
|
+
if (!expect || typeof expect !== "object" || !states.includes(expect.state)) {
|
|
118
|
+
throw invalidManifest(
|
|
119
|
+
`entries[${index}].expect.state must be one of: ${states.join(", ")}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (expect.state === "sha256" && !SHA256_HEX_PATTERN.test(expect.value ?? "")) {
|
|
123
|
+
throw invalidManifest(`entries[${index}].expect.value must be a lowercase sha256 hex digest`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { manifestPath, manifest };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function desiredBytes(entry) {
|
|
131
|
+
return entry.content.text !== undefined
|
|
132
|
+
? Buffer.from(entry.content.text, "utf8")
|
|
133
|
+
: Buffer.from(entry.content.base64, "base64");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Full rollback of a failed write phase (FC-17): every overwrite is
|
|
138
|
+
* restored from its in-memory prior bytes and every file created by this
|
|
139
|
+
* run is removed. Best-effort per file; the original failure is what the
|
|
140
|
+
* caller reports. Paths were already containment-checked during planning.
|
|
141
|
+
*/
|
|
142
|
+
async function rollbackWrites(rootAbs, actions, written) {
|
|
143
|
+
const restored = [];
|
|
144
|
+
const removed = [];
|
|
145
|
+
for (const item of written) {
|
|
146
|
+
const action = actions.find((entry) => entry.rel === item.path);
|
|
147
|
+
try {
|
|
148
|
+
if (action && action.type === "overwrite" && action.priorBytes !== undefined) {
|
|
149
|
+
await writeFileAtomic(rootAbs, item.path, action.priorBytes);
|
|
150
|
+
restored.push(item.path);
|
|
151
|
+
} else {
|
|
152
|
+
await unlink(path.join(rootAbs, item.path));
|
|
153
|
+
removed.push(item.path);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Rollback is best-effort; the original failure is reported.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { restored, removed };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Runs one projection.
|
|
164
|
+
* Options: { root, manifest } where manifest is a root-relative path
|
|
165
|
+
* (default: skill-family.projection.json).
|
|
166
|
+
* Returns a receipt document; throws KitError (stable kind) on any
|
|
167
|
+
* refusal — in which case nothing was written.
|
|
168
|
+
*/
|
|
169
|
+
export async function runProjection({ root, manifest: manifestRelPath } = {}) {
|
|
170
|
+
const rootAbs = await resolveTargetRoot(root ?? ".");
|
|
171
|
+
const facts = await loadTargetFacts(rootAbs);
|
|
172
|
+
const { manifestPath, manifest } = await loadProjectionManifest(rootAbs, manifestRelPath);
|
|
173
|
+
|
|
174
|
+
// Phase 1 — validate every entry before any write.
|
|
175
|
+
const plan = [];
|
|
176
|
+
const refusals = [];
|
|
177
|
+
for (const entry of manifest.entries) {
|
|
178
|
+
const rejection = await validateEntry({ rawPath: entry.path, entry, facts, manifestPath, rootAbs });
|
|
179
|
+
if (rejection) {
|
|
180
|
+
refusals.push(rejection);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
plan.push({ rel: normalizeRelPath(entry.path), entry, desired: desiredBytes(entry) });
|
|
184
|
+
}
|
|
185
|
+
if (refusals.length > 0) {
|
|
186
|
+
throw refusalError(
|
|
187
|
+
refusals,
|
|
188
|
+
`projection refused: ${refusals.length} entr${refusals.length === 1 ? "y" : "ies"} violated the write boundary; nothing was written`,
|
|
189
|
+
{ manifest: manifestPath },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Phase 2 — classify current state per entry (still no writes).
|
|
194
|
+
const actions = [];
|
|
195
|
+
for (const item of plan) {
|
|
196
|
+
const action = await classifyEntry(rootAbs, item);
|
|
197
|
+
if (action.refusal) {
|
|
198
|
+
refusals.push({ path: item.rel, ...action.refusal });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
actions.push(action);
|
|
202
|
+
}
|
|
203
|
+
if (refusals.length > 0) {
|
|
204
|
+
throw refusalError(
|
|
205
|
+
refusals,
|
|
206
|
+
`projection refused: ${refusals.length} conflict${refusals.length === 1 ? "" : "s"} detected; nothing was written`,
|
|
207
|
+
{ manifest: manifestPath },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Phase 3 — write. Overwrites keep prior bytes for restore-on-failure.
|
|
212
|
+
const written = [];
|
|
213
|
+
const unchanged = [];
|
|
214
|
+
try {
|
|
215
|
+
for (const action of actions) {
|
|
216
|
+
if (action.type === "unchanged") {
|
|
217
|
+
unchanged.push({ path: action.rel, sha256: action.sha256 });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (action.type === "overwrite") {
|
|
221
|
+
action.priorBytes = await readFile(path.join(rootAbs, action.rel));
|
|
222
|
+
}
|
|
223
|
+
await writeFileAtomic(rootAbs, action.rel, action.desired);
|
|
224
|
+
written.push({ path: action.rel, sha256: action.sha256, mode: action.type });
|
|
225
|
+
}
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
// FC-17: restore overwritten files AND remove every file created by
|
|
228
|
+
// this run — a failed projection leaves no half-updated target.
|
|
229
|
+
const { restored, removed } = await rollbackWrites(rootAbs, actions, written);
|
|
230
|
+
// Coded errors (kit or harness) propagate unchanged so their stable
|
|
231
|
+
// kinds survive; anything else becomes a coded projection failure.
|
|
232
|
+
if (cause instanceof ContractsError) throw cause;
|
|
233
|
+
throw kitError(
|
|
234
|
+
KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED,
|
|
235
|
+
`projection write failed: ${cause && cause.message ? cause.message : "unknown"}`,
|
|
236
|
+
{ restored, removed },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Phase 4 — verification: re-read written files and compute the closure.
|
|
241
|
+
for (const item of written) {
|
|
242
|
+
const bytes = await readFile(path.join(rootAbs, item.path));
|
|
243
|
+
if (digestBytes(bytes) !== item.sha256) {
|
|
244
|
+
// A post-write verification failure is still a failed projection:
|
|
245
|
+
// roll back everything this run touched.
|
|
246
|
+
const { restored, removed } = await rollbackWrites(rootAbs, actions, written);
|
|
247
|
+
throw kitError(
|
|
248
|
+
KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED,
|
|
249
|
+
`post-write verification failed for ${item.path}`,
|
|
250
|
+
{ path: item.path, restored, removed },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const closure = await computeResourceClosure({
|
|
255
|
+
root: rootAbs,
|
|
256
|
+
resources: [
|
|
257
|
+
{ path: manifestPath, role: "input" },
|
|
258
|
+
...written.map((item) => ({ path: item.path, role: "output" })),
|
|
259
|
+
...unchanged.map((item) => ({ path: item.path, role: "output" })),
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
kind: "skill-family.projection-receipt",
|
|
265
|
+
schemaVersion: 1,
|
|
266
|
+
generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
|
|
267
|
+
manifest: manifestPath,
|
|
268
|
+
written,
|
|
269
|
+
unchanged,
|
|
270
|
+
closure: { digest: closure.digest, resourceCount: closure.resources.length },
|
|
271
|
+
policy:
|
|
272
|
+
"projection wrote only manifest-authorized managed artifacts; handwritten and unauthorized paths were never touched",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function validateEntry({ rawPath, entry, facts, manifestPath, rootAbs }) {
|
|
277
|
+
void entry;
|
|
278
|
+
const rel = normalizeRelPath(rawPath);
|
|
279
|
+
// Classification runs on the RAW input so ambiguous cross-platform paths
|
|
280
|
+
// (backslashes on POSIX, drive letters, UNC) are refused before any
|
|
281
|
+
// normalization can mask them.
|
|
282
|
+
const classification = classifyPathInput(rawPath);
|
|
283
|
+
if (!classification.ok) {
|
|
284
|
+
return {
|
|
285
|
+
path: rel,
|
|
286
|
+
kind: classification.kind,
|
|
287
|
+
code: "SFC2004",
|
|
288
|
+
detail: `path rejected before resolution (kind: ${classification.kind})`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Full containment preflight (traversal, symlink escape, realpath escape)
|
|
292
|
+
// before any authorization decision; escaping paths never reach a write.
|
|
293
|
+
try {
|
|
294
|
+
await resolveContained(rootAbs, rel);
|
|
295
|
+
} catch (cause) {
|
|
296
|
+
return {
|
|
297
|
+
path: rel,
|
|
298
|
+
kind: cause && cause.details && cause.details.kind ? cause.details.kind : KIT_ERROR_KINDS.UNAUTHORIZED_PATH,
|
|
299
|
+
code: "SFC2004",
|
|
300
|
+
detail: `containment preflight rejected the path: ${cause && cause.message ? cause.message : "unknown"}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (rel === normalizeRelPath(manifestPath)) {
|
|
304
|
+
return {
|
|
305
|
+
path: rel,
|
|
306
|
+
kind: KIT_ERROR_KINDS.SELF_PROJECTION,
|
|
307
|
+
code: "SFC2004",
|
|
308
|
+
detail: "the projection manifest may not list itself",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// Handwritten material wins over any managed declaration.
|
|
312
|
+
if (matchAnyGlob(facts.handwrittenPatterns, rel)) {
|
|
313
|
+
return {
|
|
314
|
+
path: rel,
|
|
315
|
+
kind: KIT_ERROR_KINDS.HANDWRITTEN_OVERWRITE,
|
|
316
|
+
code: "SFC2004",
|
|
317
|
+
detail: "path matches handwritten patterns; the kit never writes handwritten material",
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (!facts.managedSet.has(rel)) {
|
|
321
|
+
return {
|
|
322
|
+
path: rel,
|
|
323
|
+
kind: KIT_ERROR_KINDS.UNAUTHORIZED_PATH,
|
|
324
|
+
code: "SFC2004",
|
|
325
|
+
detail:
|
|
326
|
+
"path is not declared managed by the target (file registry, project manifest managedFiles, or managed-file lock)",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function classifyEntry(rootAbs, item) {
|
|
333
|
+
const { rel, desired } = item;
|
|
334
|
+
const desiredSha256 = digestBytes(desired);
|
|
335
|
+
const target = path.join(rootAbs, rel);
|
|
336
|
+
let st = null;
|
|
337
|
+
try {
|
|
338
|
+
st = await lstat(target);
|
|
339
|
+
} catch {
|
|
340
|
+
st = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (st === null) {
|
|
344
|
+
if (item.entry.expect && item.entry.expect.state === "sha256") {
|
|
345
|
+
return {
|
|
346
|
+
refusal: {
|
|
347
|
+
kind: KIT_ERROR_KINDS.CONFLICT_DRIFT,
|
|
348
|
+
code: "SFC2004",
|
|
349
|
+
detail: "expect.sha256 declared prior content, but the path does not exist",
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return { type: "create", rel, desired, sha256: desiredSha256 };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (st.isSymbolicLink()) {
|
|
357
|
+
return {
|
|
358
|
+
refusal: {
|
|
359
|
+
kind: KIT_ERROR_KINDS.SYMLINK_ON_PLANNED_PATH,
|
|
360
|
+
code: "SFC2004",
|
|
361
|
+
detail: "a symbolic link occupies the planned path; the kit never writes through links",
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (st.isDirectory()) {
|
|
366
|
+
return {
|
|
367
|
+
refusal: {
|
|
368
|
+
kind: KIT_ERROR_KINDS.TYPE_CONFLICT,
|
|
369
|
+
code: "SFC2004",
|
|
370
|
+
detail: "a directory occupies the planned file path",
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const existing = await readFile(target);
|
|
376
|
+
const existingSha256 = digestBytes(existing);
|
|
377
|
+
if (existingSha256 === desiredSha256) {
|
|
378
|
+
return { type: "unchanged", rel, sha256: desiredSha256 };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const expect = item.entry.expect;
|
|
382
|
+
if (expect && expect.state === "sha256" && expect.value === existingSha256) {
|
|
383
|
+
return { type: "overwrite", rel, desired, sha256: desiredSha256 };
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
refusal: {
|
|
387
|
+
kind: KIT_ERROR_KINDS.CONFLICT_DRIFT,
|
|
388
|
+
code: "SFC2004",
|
|
389
|
+
detail:
|
|
390
|
+
expect === undefined
|
|
391
|
+
? "existing content differs and no expect prior-state was declared; refusing to overwrite"
|
|
392
|
+
: expect.state === "absent"
|
|
393
|
+
? "expect.absent declared, but the path exists with different content"
|
|
394
|
+
: "existing content differs from the declared expect.sha256 prior state",
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONTRACTS_VERSION, ContractsError, findSchemaByObject } from "skill-family-contracts";
|
|
4
|
+
import {
|
|
5
|
+
computeResourceClosure,
|
|
6
|
+
digestBytes,
|
|
7
|
+
readFileContained,
|
|
8
|
+
validateContractDocument,
|
|
9
|
+
writeFileAtomic,
|
|
10
|
+
} from "skill-family-harness-node";
|
|
11
|
+
import { KIT_ERROR_KINDS, kitError } from "./errors.mjs";
|
|
12
|
+
import {
|
|
13
|
+
describeSkeletonFiles,
|
|
14
|
+
KIT_TOOL_NAME,
|
|
15
|
+
KIT_VERSION,
|
|
16
|
+
MANAGED_LOCK_PATH,
|
|
17
|
+
normalizeSkeletonInputs,
|
|
18
|
+
PROJECT_MANIFEST_PATH,
|
|
19
|
+
} from "./skeleton.mjs";
|
|
20
|
+
import { resolveTargetRoot } from "./workspace.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* scaffold — generate a project skeleton into an EMPTY target only.
|
|
24
|
+
*
|
|
25
|
+
* Boundary rules (all enforced before any write):
|
|
26
|
+
* - the target must be an existing empty directory, or a not-yet-existing
|
|
27
|
+
* path whose parent exists (the kit creates the final directory only);
|
|
28
|
+
* - a non-empty target (any entry, dotfiles included) is refused with the
|
|
29
|
+
* stable kind target-not-empty and left byte-for-byte untouched;
|
|
30
|
+
* - every file write goes through the harness atomic contained writer, so
|
|
31
|
+
* a failure never leaves a partial file and no path can escape the
|
|
32
|
+
* target root.
|
|
33
|
+
*
|
|
34
|
+
* After writing, the two contract documents are re-read from disk and
|
|
35
|
+
* validated against their registered schemas, and the resource closure of
|
|
36
|
+
* the written set is computed; both are part of the returned result.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
async function assertEmptyTarget(root) {
|
|
40
|
+
let dirents;
|
|
41
|
+
try {
|
|
42
|
+
dirents = await readdir(root, { withFileTypes: true });
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw kitError(
|
|
45
|
+
KIT_ERROR_KINDS.INVALID_ROOT,
|
|
46
|
+
`target directory cannot be read: ${cause && cause.code ? cause.code : "unknown"}`,
|
|
47
|
+
{ root: "<opaque>" },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (dirents.length > 0) {
|
|
51
|
+
throw kitError(
|
|
52
|
+
KIT_ERROR_KINDS.TARGET_NOT_EMPTY,
|
|
53
|
+
`scaffold refuses a non-empty target (${dirents.length} entr${dirents.length === 1 ? "y" : "ies"} present); nothing was written`,
|
|
54
|
+
{ root: "<opaque>", entryCount: dirents.length },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scaffolds one project skeleton.
|
|
61
|
+
* Options: { root, projectId, projectName, profileId, licensingProfile, licensingProfileData, profilesRoot }.
|
|
62
|
+
* Returns a structured result; throws KitError with a stable kind on any
|
|
63
|
+
* refusal. Never writes outside the target root.
|
|
64
|
+
*/
|
|
65
|
+
export async function scaffoldTarget({ root, projectId, projectName, profileId, licensingProfile, licensingVariant, licensingProfileData, profilesRoot, identityProjections } = {}) {
|
|
66
|
+
if (root === undefined || root === null) {
|
|
67
|
+
throw kitError(KIT_ERROR_KINDS.INVALID_ROOT, "scaffold requires an explicit target root");
|
|
68
|
+
}
|
|
69
|
+
const rootAbs = path.resolve(root);
|
|
70
|
+
|
|
71
|
+
// Intake validation first: a rejected id/name/profile must not create
|
|
72
|
+
// anything on disk.
|
|
73
|
+
const inputs = normalizeSkeletonInputs({
|
|
74
|
+
projectId,
|
|
75
|
+
projectName,
|
|
76
|
+
profileId,
|
|
77
|
+
licensingProfile,
|
|
78
|
+
licensingVariant,
|
|
79
|
+
rootBasename: path.basename(rootAbs),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Resolve the licensing profile and compute the complete deterministic
|
|
83
|
+
// write set before creating the target directory. Invalid or incomplete
|
|
84
|
+
// profile coordinates (for example a multi-variant profile without a
|
|
85
|
+
// variant) must leave zero filesystem traces.
|
|
86
|
+
const skeleton = await describeSkeletonFiles({
|
|
87
|
+
...inputs,
|
|
88
|
+
licensingProfileData,
|
|
89
|
+
profilesRoot,
|
|
90
|
+
identityProjections,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The target may not exist yet; its parent must (the kit creates only
|
|
94
|
+
// the final component, never a chain of directories outside the target).
|
|
95
|
+
let targetExists = false;
|
|
96
|
+
try {
|
|
97
|
+
const st = await lstat(rootAbs);
|
|
98
|
+
targetExists = true;
|
|
99
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
100
|
+
throw kitError(
|
|
101
|
+
KIT_ERROR_KINDS.TARGET_NOT_DIRECTORY,
|
|
102
|
+
"scaffold target exists but is not a plain directory",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
if (cause instanceof ContractsError) throw cause;
|
|
107
|
+
if (targetExists) throw cause;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (targetExists) {
|
|
111
|
+
await assertEmptyTarget(rootAbs);
|
|
112
|
+
} else {
|
|
113
|
+
try {
|
|
114
|
+
await mkdir(rootAbs); // non-recursive: parent must already exist
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
throw kitError(
|
|
117
|
+
KIT_ERROR_KINDS.INVALID_ROOT,
|
|
118
|
+
`cannot create scaffold target (does its parent exist?): ${cause && cause.code ? cause.code : "unknown"}`,
|
|
119
|
+
{ root: "<opaque>" },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// From here on, every access is contained in rootAbs.
|
|
125
|
+
const resolvedRoot = await resolveTargetRoot(rootAbs);
|
|
126
|
+
const written = [];
|
|
127
|
+
for (const file of skeleton.files) {
|
|
128
|
+
await writeFileAtomic(resolvedRoot, file.path, file.content);
|
|
129
|
+
written.push({
|
|
130
|
+
path: file.path,
|
|
131
|
+
fileClass: file.fileClass,
|
|
132
|
+
sha256: digestBytes(Buffer.from(file.content, "utf8")),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Self-verification: re-read the contract documents from disk and
|
|
137
|
+
// validate them against the registered schemas (contracts authority).
|
|
138
|
+
const verifications = [];
|
|
139
|
+
for (const [objectName, relPath] of [
|
|
140
|
+
["project-manifest", PROJECT_MANIFEST_PATH],
|
|
141
|
+
["managed-file-lock", MANAGED_LOCK_PATH],
|
|
142
|
+
]) {
|
|
143
|
+
const registration = findSchemaByObject(objectName);
|
|
144
|
+
const text = await readFileContained(resolvedRoot, relPath, { encoding: "utf8" });
|
|
145
|
+
const outcome = validateContractDocument(JSON.parse(text), { schemaId: registration.$id });
|
|
146
|
+
if (!outcome.valid) {
|
|
147
|
+
throw kitError(
|
|
148
|
+
KIT_ERROR_KINDS.PROJECTION_WRITE_FAILED,
|
|
149
|
+
`scaffold produced a ${objectName} that fails its registered schema`,
|
|
150
|
+
{ path: relPath, errorCode: outcome.errorCode },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
verifications.push({ path: relPath, schemaId: registration.$id, valid: true });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Resource closure of the written set (harness mechanism).
|
|
157
|
+
const closure = await computeResourceClosure({
|
|
158
|
+
root: resolvedRoot,
|
|
159
|
+
resources: skeleton.files.map((file) => ({ path: file.path, role: "output" })),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
kind: "skill-family.scaffold-result",
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
generatedBy: { tool: KIT_TOOL_NAME, version: KIT_VERSION },
|
|
166
|
+
project: {
|
|
167
|
+
...inputs,
|
|
168
|
+
licensingProfile: skeleton.licensing.profile,
|
|
169
|
+
licensingVariant: skeleton.licensing.variant,
|
|
170
|
+
contractsVersion: CONTRACTS_VERSION,
|
|
171
|
+
},
|
|
172
|
+
files: written,
|
|
173
|
+
verifications,
|
|
174
|
+
closure: { digest: closure.digest, resourceCount: closure.resources.length },
|
|
175
|
+
policy:
|
|
176
|
+
"scaffold writes only into an empty target; every write is atomic and contained; nothing outside the target is touched",
|
|
177
|
+
};
|
|
178
|
+
}
|