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.
Files changed (80) hide show
  1. package/CODE_OF_CONDUCT.md +131 -0
  2. package/CONTRIBUTING.md +69 -0
  3. package/LICENSE +201 -0
  4. package/README.md +74 -0
  5. package/SECURITY.md +34 -0
  6. package/data/licensing/registry.json +211 -0
  7. package/data/licensing/schema.json +207 -0
  8. package/docs/.nojekyll +0 -0
  9. package/docs/404.html +613 -0
  10. package/docs/architecture/index.html +904 -0
  11. package/docs/assets/images/favicon.png +0 -0
  12. package/docs/assets/javascripts/bundle.d7400e89.min.js +16 -0
  13. package/docs/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
  14. package/docs/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
  15. package/docs/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
  16. package/docs/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
  17. package/docs/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
  18. package/docs/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
  19. package/docs/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
  20. package/docs/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
  21. package/docs/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
  22. package/docs/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
  23. package/docs/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
  24. package/docs/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
  25. package/docs/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
  26. package/docs/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
  27. package/docs/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
  28. package/docs/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
  29. package/docs/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
  30. package/docs/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
  31. package/docs/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
  32. package/docs/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
  33. package/docs/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
  34. package/docs/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
  35. package/docs/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
  36. package/docs/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
  37. package/docs/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
  38. package/docs/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
  39. package/docs/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
  40. package/docs/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
  41. package/docs/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
  42. package/docs/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
  43. package/docs/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
  44. package/docs/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
  45. package/docs/assets/javascripts/lunr/tinyseg.js +206 -0
  46. package/docs/assets/javascripts/lunr/wordcut.js +6708 -0
  47. package/docs/assets/javascripts/workers/search.2c215733.min.js +42 -0
  48. package/docs/assets/stylesheets/main.ec1eaa64.min.css +1 -0
  49. package/docs/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
  50. package/docs/git-lifecycle/index.html +976 -0
  51. package/docs/help/index.html +907 -0
  52. package/docs/index.html +837 -0
  53. package/docs/integration/audit/baseline/audit-codes.json +62 -0
  54. package/docs/integration/audit/failure-evidence/index.html +872 -0
  55. package/docs/integration/audit/independence/index.html +841 -0
  56. package/docs/integration/audit/index.html +826 -0
  57. package/docs/integration/audit/mutation-taxonomy/index.html +1022 -0
  58. package/docs/integration/audit/schemas/audit-evidence.schema.json +182 -0
  59. package/docs/integration/audit/version-compatibility/index.html +845 -0
  60. package/docs/migration/index.html +1213 -0
  61. package/docs/quickstart/index.html +842 -0
  62. package/docs/search/search_index.json +1 -0
  63. package/docs/setup/index.html +941 -0
  64. package/docs/sitemap.xml +51 -0
  65. package/package.json +42 -0
  66. package/src/adopt-plan.mjs +594 -0
  67. package/src/check.mjs +415 -0
  68. package/src/cli.mjs +230 -0
  69. package/src/errors.mjs +144 -0
  70. package/src/gitprobe.mjs +230 -0
  71. package/src/identity-check.mjs +563 -0
  72. package/src/index.mjs +233 -0
  73. package/src/license-texts/Apache-2.0.txt +201 -0
  74. package/src/license-texts/MIT.txt +21 -0
  75. package/src/licensing.mjs +299 -0
  76. package/src/migration.mjs +427 -0
  77. package/src/projection.mjs +397 -0
  78. package/src/scaffold.mjs +178 -0
  79. package/src/skeleton.mjs +886 -0
  80. 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
+ }
@@ -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
+ }