skill-family-engineering-kit 0.4.0 → 0.6.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 (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CHANGELOG.zh-CN.md +42 -0
  3. package/README.md +12 -11
  4. package/README.zh-CN.md +12 -11
  5. package/package.json +3 -3
  6. package/release-notes/0.5.0.yaml +21 -0
  7. package/release-notes/0.6.0.yaml +25 -0
  8. package/src/adopt-plan.mjs +81 -159
  9. package/src/check.mjs +443 -19
  10. package/src/cli.mjs +80 -1
  11. package/src/core-check.mjs +8 -4
  12. package/src/entry-check.mjs +384 -0
  13. package/src/errors.mjs +21 -0
  14. package/src/index.mjs +35 -6
  15. package/src/projection.mjs +221 -12
  16. package/src/relock.mjs +397 -0
  17. package/src/skeleton.mjs +162 -4
  18. package/CODE_OF_CONDUCT.md +0 -131
  19. package/CONTRIBUTING.md +0 -25
  20. package/SECURITY.md +0 -33
  21. package/docs/.nojekyll +0 -0
  22. package/docs/404.html +0 -1666
  23. package/docs/agents/architecture-routing/index.html +0 -1944
  24. package/docs/agents/capability-catalog.en.json +0 -1748
  25. package/docs/agents/capability-catalog.json +0 -1034
  26. package/docs/agents/capability-catalog.schema.json +0 -179
  27. package/docs/agents/capability-catalog.zh-CN.json +0 -1748
  28. package/docs/agents/index.html +0 -1914
  29. package/docs/architecture/index.html +0 -2194
  30. package/docs/assets/images/favicon.png +0 -0
  31. package/docs/assets/javascripts/bundle.d7400e89.min.js +0 -16
  32. package/docs/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  33. package/docs/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  34. package/docs/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  35. package/docs/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  36. package/docs/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  37. package/docs/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  38. package/docs/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  39. package/docs/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  40. package/docs/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  41. package/docs/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  42. package/docs/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  43. package/docs/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  44. package/docs/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  45. package/docs/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  46. package/docs/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  47. package/docs/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  48. package/docs/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  49. package/docs/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  50. package/docs/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  51. package/docs/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  52. package/docs/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  53. package/docs/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  54. package/docs/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  55. package/docs/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  56. package/docs/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  57. package/docs/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  58. package/docs/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  59. package/docs/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  60. package/docs/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  61. package/docs/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  62. package/docs/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  63. package/docs/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  64. package/docs/assets/javascripts/lunr/tinyseg.js +0 -206
  65. package/docs/assets/javascripts/lunr/wordcut.js +0 -6708
  66. package/docs/assets/javascripts/workers/search.2c215733.min.js +0 -42
  67. package/docs/assets/stylesheets/main.ec1eaa64.min.css +0 -1
  68. package/docs/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  69. package/docs/en/agents/architecture-routing/index.html +0 -1944
  70. package/docs/en/agents/index.html +0 -1925
  71. package/docs/en/architecture/index.html +0 -2196
  72. package/docs/en/examples-and-fixtures/index.html +0 -1888
  73. package/docs/en/help/index.html +0 -2035
  74. package/docs/en/index.html +0 -1895
  75. package/docs/en/licensing/index.html +0 -1917
  76. package/docs/en/migration/index.html +0 -2335
  77. package/docs/en/quickstart/index.html +0 -1919
  78. package/docs/en/recipes/adapter-text-closure/index.html +0 -2000
  79. package/docs/en/recipes/adopt-existing-repository/index.html +0 -1987
  80. package/docs/en/recipes/deterministic-human-report/index.html +0 -1998
  81. package/docs/en/recipes/domain-schema-validation/index.html +0 -2009
  82. package/docs/en/recipes/durable-local-state/index.html +0 -2009
  83. package/docs/en/recipes/host-profile-integration/index.html +0 -2001
  84. package/docs/en/recipes/index.html +0 -1877
  85. package/docs/en/recipes/safe-filesystem-and-atomic-write/index.html +0 -1994
  86. package/docs/en/reference/api/index.html +0 -1941
  87. package/docs/en/reference/compatibility/index.html +0 -2036
  88. package/docs/en/reference/failure-and-side-effect-matrix/index.html +0 -2216
  89. package/docs/examples-and-fixtures/index.html +0 -1871
  90. package/docs/git-lifecycle/index.html +0 -2029
  91. package/docs/help/index.html +0 -2035
  92. package/docs/index.html +0 -1893
  93. package/docs/integration/audit/baseline/audit-codes.json +0 -62
  94. package/docs/integration/audit/failure-evidence/index.html +0 -1925
  95. package/docs/integration/audit/independence/index.html +0 -1894
  96. package/docs/integration/audit/index.html +0 -1880
  97. package/docs/integration/audit/mutation-taxonomy/index.html +0 -2075
  98. package/docs/integration/audit/schemas/audit-evidence.schema.json +0 -182
  99. package/docs/integration/audit/version-compatibility/index.html +0 -1901
  100. package/docs/licensing/index.html +0 -1917
  101. package/docs/migration/index.html +0 -2335
  102. package/docs/public/status/index.html +0 -1884
  103. package/docs/quickstart/index.html +0 -1919
  104. package/docs/recipes/adapter-text-closure/index.html +0 -2000
  105. package/docs/recipes/adopt-existing-repository/index.html +0 -1987
  106. package/docs/recipes/deterministic-human-report/index.html +0 -1998
  107. package/docs/recipes/domain-schema-validation/index.html +0 -2009
  108. package/docs/recipes/durable-local-state/index.html +0 -2009
  109. package/docs/recipes/host-profile-integration/index.html +0 -2001
  110. package/docs/recipes/index.html +0 -1877
  111. package/docs/recipes/safe-filesystem-and-atomic-write/index.html +0 -1994
  112. package/docs/reference/api/contracts/index.html +0 -2558
  113. package/docs/reference/api/engineering-kit/index.html +0 -2577
  114. package/docs/reference/api/harness/index.html +0 -3029
  115. package/docs/reference/api/index.html +0 -1881
  116. package/docs/reference/compatibility/index.html +0 -2036
  117. package/docs/reference/failure-and-side-effect-matrix/index.html +0 -2216
  118. package/docs/search/search_index.json +0 -1
  119. package/docs/setup/index.html +0 -1995
  120. package/docs/sitemap.xml +0 -207
package/src/check.mjs CHANGED
@@ -5,13 +5,16 @@ import {
5
5
  readFileContained,
6
6
  validateContractDocument,
7
7
  } from "skill-family-harness-node";
8
+ import { readFileSync } from "node:fs";
8
9
  import { invalidParamsError, KIT_ERROR_KINDS } from "./errors.mjs";
9
10
  import { probeGitState } from "./gitprobe.mjs";
10
11
  import {
11
12
  KIT_TOOL_NAME,
12
13
  KIT_VERSION,
13
14
  MANAGED_LOCK_PATH,
15
+ PLATFORM_SUBSET_DECLARATION_PATH,
14
16
  PROJECT_MANIFEST_PATH,
17
+ PUBLIC_BOUNDARY_DECLARATION_PATH,
15
18
  } from "./skeleton.mjs";
16
19
  import {
17
20
  listTargetEntries,
@@ -31,14 +34,44 @@ import {
31
34
  /**
32
35
  * check — diagnosis only, never auto-fix.
33
36
  *
34
- * Seven diagnosis classes, exactly as the FND-030 and FND-045 hand-offs bound them:
37
+ * Nine diagnosis classes. The first seven are exactly as the FND-030 and
38
+ * FND-045 hand-offs bound them; the audit remediation C2 delivery extends
39
+ * the closed set to nine (boundary, platform) and extends the version class
40
+ * with the version single-source consistency facts:
35
41
  * contracts — discovered contract documents against registered schemas;
36
- * drift — managed-file-lock entries vs actual bytes on disk;
42
+ * drift — managed-file-lock entries vs actual bytes on disk (the
43
+ * managed-artifact drift check; C2 "生成物漂移" is carried by
44
+ * this existing class, aligned, not duplicated; SG-37: on a
45
+ * fresh checkout the build outputs simply do not exist yet, so
46
+ * when EVERY well-formed lock entry is absent on disk the
47
+ * target is diagnosed as never-built — one dedicated
48
+ * NEVER_BUILT finding carrying build-first guidance, kept
49
+ * distinct from both content drift and partial loss; the
50
+ * fail-closed semantics are unchanged: it remains a finding);
37
51
  * closure — harness resource closure over declared inputs/outputs;
38
- * version — declared contracts version vs the frozen contracts version;
52
+ * version — declared contracts version vs the frozen contracts version,
53
+ * plus version single-source consistency (SG-13/14, VRG-001/002:
54
+ * package.json is the single version authority; no double-headed
55
+ * VERSION authority; every workspace package.json carries the
56
+ * same single-source version);
39
57
  * docs — documentation facts (README presence, identity consistency);
40
58
  * git — read-only Git pre-state (never a git write);
41
- * identity — licensing and identity drift checks (FND-045).
59
+ * identity — licensing and identity drift checks (FND-045);
60
+ * boundary — public release boundary declaration (SG-17, VRG-005): when a
61
+ * public-boundary-declaration document is present it is schema-
62
+ * verified and mechanically reconciled against the actual file
63
+ * set (required paths present, forbidden paths absent, no
64
+ * undeclared file under a public root); absence is data, not a
65
+ * finding — whether a project owes a boundary declaration is a
66
+ * semantic/release decision;
67
+ * platform — platform subset restriction declaration (SFA-PLAT-002 / B3):
68
+ * when a platform-subset-declaration document is present it is
69
+ * verified against the frozen four-field template with the
70
+ * controlled platform vocabulary of the observation-scope
71
+ * contract, and its declared version is reconciled against the
72
+ * single-source package.json version; absence is data, not a
73
+ * finding (declaration completeness is carried by semantic
74
+ * review until a project declares).
42
75
  *
43
76
  * Every class is independently complete: it loads its own inputs and never
44
77
  * consumes another class's cache, so a full run and a `--only <class>` run
@@ -55,7 +88,17 @@ import {
55
88
  * signal (0 clean, 1 findings, 2 rejected/usage/mechanism error).
56
89
  */
57
90
 
58
- const CHECK_CLASSES = Object.freeze(["contracts", "drift", "closure", "version", "docs", "git", "identity"]);
91
+ const CHECK_CLASSES = Object.freeze([
92
+ "contracts",
93
+ "drift",
94
+ "closure",
95
+ "version",
96
+ "docs",
97
+ "git",
98
+ "identity",
99
+ "boundary",
100
+ "platform",
101
+ ]);
59
102
 
60
103
  export { CHECK_CLASSES };
61
104
 
@@ -199,39 +242,71 @@ async function checkDrift(rootAbs, findings) {
199
242
  return null;
200
243
  }
201
244
  const entries = Array.isArray(lock?.entries) ? lock.entries : [];
245
+ // SG-37: classify every well-formed lock entry before emitting findings, so
246
+ // a target whose managed build outputs were NEVER built is diagnosed with
247
+ // the dedicated never-built finding (plus build-first guidance) instead of
248
+ // N per-file missing findings that are indistinguishable from content
249
+ // drift or partial loss. Fail-closed semantics are unchanged: every branch
250
+ // below remains a finding (exit code 1), never a silent pass.
251
+ const missingPaths = [];
252
+ const driftFindings = [];
253
+ let wellFormed = 0;
254
+ let escaped = 0;
202
255
  for (const entry of entries) {
203
256
  const rel = typeof entry?.path === "string" ? normalizeRelPath(entry.path) : null;
204
257
  const declaredHash = entry?.hash?.value;
205
258
  if (!rel || typeof declaredHash !== "string" || !/^[0-9a-f]{64}$/.test(declaredHash)) {
206
259
  continue; // malformed entries are reported by the contracts class
207
260
  }
261
+ wellFormed += 1;
208
262
  const text = await readManagedCandidate(rootAbs, rel, findings);
209
263
  if (text === null) {
210
- findings.push(
264
+ missingPaths.push(rel);
265
+ continue;
266
+ }
267
+ if (typeof text !== "string") {
268
+ escaped += 1; // escaping path already reported by readManagedCandidate
269
+ continue;
270
+ }
271
+ const actual = digestBytes(Buffer.from(text, "utf8"));
272
+ if (actual !== declaredHash) {
273
+ driftFindings.push(
211
274
  finding(
212
275
  "drift",
213
- KIT_ERROR_KINDS.MANAGED_FILE_MISSING,
276
+ KIT_ERROR_KINDS.MANAGED_FILE_DRIFT,
214
277
  "SFC2004",
215
- `managed file declared in the lock does not exist: ${rel}`,
278
+ `managed file drifted from its locked hash: ${rel} (lock=${declaredHash.slice(0, 12)}… actual=${actual.slice(0, 12)}…)`,
216
279
  { path: rel },
217
280
  ),
218
281
  );
219
- continue;
220
282
  }
221
- if (typeof text !== "string") continue; // escaping path already reported
222
- const actual = digestBytes(Buffer.from(text, "utf8"));
223
- if (actual !== declaredHash) {
283
+ }
284
+ if (wellFormed > 0 && escaped === 0 && missingPaths.length === wellFormed) {
285
+ // Every managed output declared in the lock is absent on disk: the build
286
+ // was never run. This is the fresh-checkout state, not content drift.
287
+ findings.push(
288
+ finding(
289
+ "drift",
290
+ KIT_ERROR_KINDS.NEVER_BUILT,
291
+ "SFC2004",
292
+ `managed build outputs have never been built: all ${wellFormed} managed file(s) declared in the lock are absent on disk; this is a never-built target, not content drift — run the project's build first, then re-run verify/check`,
293
+ { neverBuilt: true, absentPaths: [...missingPaths].sort() },
294
+ ),
295
+ );
296
+ } else {
297
+ for (const rel of missingPaths) {
224
298
  findings.push(
225
299
  finding(
226
300
  "drift",
227
- KIT_ERROR_KINDS.MANAGED_FILE_DRIFT,
301
+ KIT_ERROR_KINDS.MANAGED_FILE_MISSING,
228
302
  "SFC2004",
229
- `managed file drifted from its locked hash: ${rel} (lock=${declaredHash.slice(0, 12)}… actual=${actual.slice(0, 12)}…)`,
303
+ `managed file declared in the lock does not exist: ${rel}`,
230
304
  { path: rel },
231
305
  ),
232
306
  );
233
307
  }
234
308
  }
309
+ findings.push(...driftFindings);
235
310
  return lock;
236
311
  }
237
312
 
@@ -291,7 +366,70 @@ async function checkClosure(rootAbs, findings) {
291
366
  }
292
367
  }
293
368
 
369
+ /**
370
+ * Version single-source consistency (SG-13/14; audit VRG-001/VRG-002).
371
+ *
372
+ * The version truth source of a release unit is the `version` field of its
373
+ * package.json. Two mechanical facts follow:
374
+ * - VRG-001: a VERSION file alongside package.json is a double-headed
375
+ * version authority; the authority must converge on package.json;
376
+ * - VRG-002: the version value is declared once; every other occurrence is
377
+ * mechanically synchronized — here: every nested workspace package.json
378
+ * must carry exactly the root package.json version.
379
+ *
380
+ * When no root package.json version exists there is no single source to
381
+ * compare against and the sub-check reports nothing (never guesses).
382
+ * Returns { rootVersion, versionFilePresent, drifted }.
383
+ */
384
+ async function checkVersionSingleSource(rootAbs, findings) {
385
+ const facts = { rootVersion: null, versionFilePresent: false, drifted: [] };
386
+ const packageDocument = await loadDocument(rootAbs, "package.json");
387
+ if (packageDocument.state !== "ok" || typeof packageDocument.value?.version !== "string") {
388
+ return facts;
389
+ }
390
+ facts.rootVersion = packageDocument.value.version;
391
+
392
+ const versionFile = await readOptionalFile(rootAbs, "VERSION");
393
+ if (versionFile !== null) {
394
+ facts.versionFilePresent = true;
395
+ findings.push(
396
+ finding(
397
+ "version",
398
+ KIT_ERROR_KINDS.VERSION_AUTHORITY_DUAL,
399
+ "SFC2004",
400
+ "VERSION file exists alongside package.json; version authority must converge on the package.json version field as the single source (VRG-001)",
401
+ { path: "VERSION" },
402
+ ),
403
+ );
404
+ }
405
+
406
+ const entries = await listTargetEntries(rootAbs);
407
+ for (const entry of entries) {
408
+ if (entry.kind !== "file" || entry.path === "package.json" || !entry.path.endsWith("package.json")) {
409
+ continue;
410
+ }
411
+ const nested = await loadDocument(rootAbs, entry.path);
412
+ if (nested.state !== "ok" || typeof nested.value?.version !== "string") continue;
413
+ if (nested.value.version !== facts.rootVersion) {
414
+ facts.drifted.push({ path: entry.path, version: nested.value.version });
415
+ findings.push(
416
+ finding(
417
+ "version",
418
+ KIT_ERROR_KINDS.VERSION_SINGLE_SOURCE_DRIFT,
419
+ "SFC2004",
420
+ `${entry.path} declares version ${nested.value.version}; the single version source is the root package.json version ${facts.rootVersion} (VRG-002)`,
421
+ { path: entry.path, declared: nested.value.version, expected: facts.rootVersion },
422
+ ),
423
+ );
424
+ }
425
+ }
426
+ return facts;
427
+ }
428
+
294
429
  async function checkVersion(rootAbs, findings) {
430
+ // The single-source consistency facts do not depend on the project manifest;
431
+ // they are computed on every run (class independence).
432
+ const singleSource = await checkVersionSingleSource(rootAbs, findings);
295
433
  // Loads its own manifest without schema validation: schema conformance is
296
434
  // the contracts class's finding, the version class only needs the fact.
297
435
  const document = await loadDocument(rootAbs, PROJECT_MANIFEST_PATH, {
@@ -307,7 +445,7 @@ async function checkVersion(rootAbs, findings) {
307
445
  { path: PROJECT_MANIFEST_PATH, documentState: document.state },
308
446
  ),
309
447
  );
310
- return { contractsVersion: null };
448
+ return { contractsVersion: null, singleSource };
311
449
  }
312
450
  if (document.state === "parse-failed") {
313
451
  findings.push(
@@ -319,7 +457,7 @@ async function checkVersion(rootAbs, findings) {
319
457
  { path: PROJECT_MANIFEST_PATH, documentState: document.state },
320
458
  ),
321
459
  );
322
- return { contractsVersion: null };
460
+ return { contractsVersion: null, singleSource };
323
461
  }
324
462
  if (document.state === "incomplete") {
325
463
  findings.push(
@@ -331,7 +469,7 @@ async function checkVersion(rootAbs, findings) {
331
469
  { path: PROJECT_MANIFEST_PATH, documentState: document.state },
332
470
  ),
333
471
  );
334
- return { contractsVersion: null };
472
+ return { contractsVersion: null, singleSource };
335
473
  }
336
474
  const declared = document.value.contracts.version;
337
475
  if (declared !== CONTRACTS_VERSION) {
@@ -345,7 +483,7 @@ async function checkVersion(rootAbs, findings) {
345
483
  ),
346
484
  );
347
485
  }
348
- return { contractsVersion: declared };
486
+ return { contractsVersion: declared, singleSource };
349
487
  }
350
488
 
351
489
  async function checkDocs(rootAbs, findings) {
@@ -489,6 +627,284 @@ async function checkIdentity(rootAbs, findings, profilesRoot) {
489
627
  return identityRecord;
490
628
  }
491
629
 
630
+ /**
631
+ * Public boundary verification (SG-17; audit VRG-005, carrier of the C5
632
+ * public-boundary-declaration contract).
633
+ *
634
+ * When the target holds a public-boundary-declaration document it is
635
+ * verified in two steps: (1) the document must pass the registered
636
+ * public-boundary-declaration schema; (2) the declaration is mechanically
637
+ * reconciled against the actual regular file set — every required path must
638
+ * exist, every forbidden path must be absent, every declared public file
639
+ * must exist and live under a declared public root, and every regular file
640
+ * under a public root must be declared (an undeclared file is a leak).
641
+ *
642
+ * Absence of the declaration is data, never a finding: whether a project
643
+ * owes a boundary declaration depends on its release surface and is decided
644
+ * by semantic review / the release process (VRG-005). Returns
645
+ * { declared, declarationId, declaredVersion, documentState }.
646
+ */
647
+ async function checkBoundary(rootAbs, findings) {
648
+ const data = { declared: false, declarationId: null, declaredVersion: null, documentState: "missing" };
649
+ const document = await loadDocument(rootAbs, PUBLIC_BOUNDARY_DECLARATION_PATH, {
650
+ schemaObject: "public-boundary-declaration",
651
+ });
652
+ data.documentState = document.state;
653
+ if (document.state === "missing") return data;
654
+ data.declared = true;
655
+ if (document.state === "parse-failed") {
656
+ findings.push(
657
+ finding(
658
+ "boundary",
659
+ KIT_ERROR_KINDS.CONTRACT_PARSE_FAILED,
660
+ "SFC2004",
661
+ `${PUBLIC_BOUNDARY_DECLARATION_PATH} is not valid JSON`,
662
+ { path: PUBLIC_BOUNDARY_DECLARATION_PATH, documentState: document.state },
663
+ ),
664
+ );
665
+ return data;
666
+ }
667
+ if (document.state === "schema-invalid") {
668
+ findings.push(
669
+ finding(
670
+ "boundary",
671
+ "schema-validation-failed",
672
+ "SFC1001",
673
+ `${PUBLIC_BOUNDARY_DECLARATION_PATH} fails the registered public-boundary-declaration schema: ${document.outcome.errors
674
+ .slice(0, 3)
675
+ .map((entry) => `${entry.instancePath || "/"} ${entry.message}`)
676
+ .join("; ")}`,
677
+ {
678
+ path: PUBLIC_BOUNDARY_DECLARATION_PATH,
679
+ schemaId: findSchemaByObject("public-boundary-declaration").$id,
680
+ documentState: document.state,
681
+ },
682
+ ),
683
+ );
684
+ return data;
685
+ }
686
+
687
+ const declaration = document.value;
688
+ data.declarationId = declaration.declarationId;
689
+ data.declaredVersion = declaration.declaredVersion;
690
+ const violation = (message, extra) =>
691
+ findings.push(
692
+ finding("boundary", KIT_ERROR_KINDS.PUBLIC_BOUNDARY_VIOLATION, "SFC2004", message, {
693
+ path: PUBLIC_BOUNDARY_DECLARATION_PATH,
694
+ ...(extra ?? {}),
695
+ }),
696
+ );
697
+
698
+ const entries = await listTargetEntries(rootAbs);
699
+ const regularFiles = new Set(entries.filter((entry) => entry.kind === "file").map((entry) => entry.path));
700
+ const publicFiles = new Set(declaration.publicFiles);
701
+ const underPublicRoot = (rel) =>
702
+ declaration.publicRoots.some((root) => rel.startsWith(`${root}/`));
703
+
704
+ for (const required of declaration.requiredPaths) {
705
+ if (!regularFiles.has(required)) {
706
+ violation(`required public path is missing from the release set: ${required}`, {
707
+ subject: required,
708
+ rule: "requiredPaths",
709
+ });
710
+ }
711
+ }
712
+ for (const forbidden of declaration.forbiddenPaths) {
713
+ if (regularFiles.has(forbidden)) {
714
+ violation(`forbidden path is present in the release set: ${forbidden}`, {
715
+ subject: forbidden,
716
+ rule: "forbiddenPaths",
717
+ });
718
+ }
719
+ }
720
+ for (const declared of declaration.publicFiles) {
721
+ if (!regularFiles.has(declared)) {
722
+ violation(`declared public file does not exist on disk: ${declared}`, {
723
+ subject: declared,
724
+ rule: "publicFiles",
725
+ });
726
+ }
727
+ if (!underPublicRoot(declared)) {
728
+ violation(`declared public file is outside every declared public root: ${declared}`, {
729
+ subject: declared,
730
+ rule: "publicRoots",
731
+ });
732
+ }
733
+ }
734
+ for (const actual of regularFiles) {
735
+ if (underPublicRoot(actual) && !publicFiles.has(actual)) {
736
+ violation(`file under a public root is not declared in publicFiles (undeclared leak): ${actual}`, {
737
+ subject: actual,
738
+ rule: "publicFiles",
739
+ });
740
+ }
741
+ }
742
+ return data;
743
+ }
744
+
745
+ /** Kind of the kit-owned platform subset restriction declaration template. */
746
+ export const PLATFORM_SUBSET_DECLARATION_KIND = "skill-family.platform-subset-declaration";
747
+
748
+ const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
749
+
750
+ /**
751
+ * Loads the controlled platform vocabulary frozen by the observation-scope
752
+ * contract (standardPlatforms enum). Derived mechanically from the
753
+ * registered schema document of skill-family-contracts — the kit never
754
+ * restates the vocabulary as a second owned copy. Throws when the contract
755
+ * cannot be located (a mechanism failure of the platform class).
756
+ */
757
+ function loadStandardPlatformVocabulary() {
758
+ const registration = findSchemaByObject("observation-scope");
759
+ const contractsIndexUrl = import.meta.resolve("skill-family-contracts");
760
+ const packageRootUrl = new URL("..", contractsIndexUrl);
761
+ const schemaUrl = new URL(registration.file, packageRootUrl);
762
+ const schema = JSON.parse(readFileSync(schemaUrl, "utf8"));
763
+ const vocabulary = schema?.properties?.standardPlatforms?.items?.enum;
764
+ if (!Array.isArray(vocabulary) || vocabulary.length === 0) {
765
+ throw new Error("observation-scope schema does not freeze a standardPlatforms vocabulary");
766
+ }
767
+ return Object.freeze([...vocabulary]);
768
+ }
769
+
770
+ /**
771
+ * Platform subset restriction declaration verification (SFA-PLAT-002 / B3;
772
+ * audit OC-3, decision D-9). Platform coverage is optional; the obligation
773
+ * is an honest, machine-checkable declaration of the released platform
774
+ * subset. The frozen four-field template:
775
+ * project / released_platform_subset / missing_platforms / declared_version.
776
+ *
777
+ * When the target holds a platform-subset-declaration document it is
778
+ * verified mechanically: the template shape, platform ids restricted to the
779
+ * controlled observation-scope vocabulary, no duplicate or overlapping
780
+ * entries, and the declared version reconciled against the single-source
781
+ * root package.json version. Absence is data, never a finding: declaration
782
+ * completeness is carried by semantic review until a project declares.
783
+ * Returns { declared, project, released, missing, declaredVersion, documentState }.
784
+ */
785
+ async function checkPlatform(rootAbs, findings) {
786
+ const data = {
787
+ declared: false,
788
+ project: null,
789
+ released: [],
790
+ missing: [],
791
+ declaredVersion: null,
792
+ documentState: "missing",
793
+ };
794
+ const loaded = await readOptionalJson(rootAbs, PLATFORM_SUBSET_DECLARATION_PATH);
795
+ if (loaded.reason === "missing") return data;
796
+ data.declared = true;
797
+ const invalid = (message, extra) =>
798
+ findings.push(
799
+ finding("platform", KIT_ERROR_KINDS.PLATFORM_SUBSET_INVALID, "SFC2004", message, {
800
+ path: PLATFORM_SUBSET_DECLARATION_PATH,
801
+ ...(extra ?? {}),
802
+ }),
803
+ );
804
+ if (!loaded.ok) {
805
+ data.documentState = "parse-failed";
806
+ findings.push(
807
+ finding(
808
+ "platform",
809
+ KIT_ERROR_KINDS.CONTRACT_PARSE_FAILED,
810
+ "SFC2004",
811
+ `${PLATFORM_SUBSET_DECLARATION_PATH} is not valid JSON`,
812
+ { path: PLATFORM_SUBSET_DECLARATION_PATH, documentState: data.documentState },
813
+ ),
814
+ );
815
+ return data;
816
+ }
817
+ data.documentState = "ok";
818
+ const declaration = loaded.value;
819
+ if (declaration?.schemaVersion !== 1) {
820
+ invalid(`schemaVersion must be exactly 1 (got ${JSON.stringify(declaration?.schemaVersion ?? null)})`, {
821
+ field: "schemaVersion",
822
+ });
823
+ }
824
+ if (declaration?.kind !== PLATFORM_SUBSET_DECLARATION_KIND) {
825
+ invalid(`kind must be "${PLATFORM_SUBSET_DECLARATION_KIND}" (got ${JSON.stringify(declaration?.kind ?? null)})`, {
826
+ field: "kind",
827
+ });
828
+ }
829
+ if (typeof declaration?.project !== "string" || declaration.project.length === 0) {
830
+ invalid("project must be a non-empty string (template field 1/4)", { field: "project" });
831
+ } else {
832
+ data.project = declaration.project;
833
+ }
834
+
835
+ const vocabulary = loadStandardPlatformVocabulary();
836
+ const verifyPlatformList = (fieldName) => {
837
+ const value = declaration?.[fieldName];
838
+ if (!Array.isArray(value)) {
839
+ invalid(`${fieldName} must be an array of platform ids (got ${JSON.stringify(value ?? null)})`, {
840
+ field: fieldName,
841
+ });
842
+ return [];
843
+ }
844
+ const seen = new Set();
845
+ for (const platform of value) {
846
+ if (typeof platform !== "string" || platform.length === 0) {
847
+ invalid(`${fieldName} entries must be non-empty strings`, { field: fieldName });
848
+ continue;
849
+ }
850
+ if (seen.has(platform)) {
851
+ invalid(`${fieldName} contains a duplicate platform id: ${platform}`, { field: fieldName, subject: platform });
852
+ continue;
853
+ }
854
+ seen.add(platform);
855
+ if (!vocabulary.includes(platform)) {
856
+ invalid(
857
+ `${fieldName} entry is outside the controlled platform vocabulary of the observation-scope contract: ${platform}`,
858
+ { field: fieldName, subject: platform },
859
+ );
860
+ }
861
+ }
862
+ return [...seen];
863
+ };
864
+ data.released = verifyPlatformList("released_platform_subset");
865
+ data.missing = verifyPlatformList("missing_platforms");
866
+
867
+ const overlap = data.released.filter((platform) => data.missing.includes(platform));
868
+ if (overlap.length > 0) {
869
+ invalid(
870
+ `released_platform_subset and missing_platforms must be disjoint; overlap: ${overlap.join(", ")}`,
871
+ { field: "released_platform_subset/missing_platforms", subject: overlap },
872
+ );
873
+ }
874
+
875
+ if (typeof declaration?.declared_version !== "string" || !SEMVER_PATTERN.test(declaration.declared_version)) {
876
+ invalid(
877
+ `declared_version must be a semantic version string X.Y.Z (got ${JSON.stringify(declaration?.declared_version ?? null)})`,
878
+ { field: "declared_version" },
879
+ );
880
+ } else {
881
+ data.declaredVersion = declaration.declared_version;
882
+ // The declared version is reconciled against the single version source
883
+ // (root package.json); without one there is nothing to compare.
884
+ const packageDocument = await loadDocument(rootAbs, "package.json");
885
+ if (
886
+ packageDocument.state === "ok" &&
887
+ typeof packageDocument.value?.version === "string" &&
888
+ packageDocument.value.version !== declaration.declared_version
889
+ ) {
890
+ findings.push(
891
+ finding(
892
+ "platform",
893
+ KIT_ERROR_KINDS.PLATFORM_SUBSET_VERSION_DRIFT,
894
+ "SFC2004",
895
+ `platform subset declaration declares version ${declaration.declared_version}; the single version source (package.json) is ${packageDocument.value.version}`,
896
+ {
897
+ path: PLATFORM_SUBSET_DECLARATION_PATH,
898
+ declared: declaration.declared_version,
899
+ expected: packageDocument.value.version,
900
+ },
901
+ ),
902
+ );
903
+ }
904
+ }
905
+ return data;
906
+ }
907
+
492
908
  /**
493
909
  * Runs all check classes over one target.
494
910
  * Options: { root, allowGitSpawn, only, profilesRoot }.
@@ -518,13 +934,17 @@ export async function runChecks({ root, allowGitSpawn = true, only, profilesRoot
518
934
  docs: () => checkDocs(rootAbs, findings),
519
935
  git: () => checkGit(rootAbs, findings, allowGitSpawn),
520
936
  identity: () => checkIdentity(rootAbs, findings, profilesRoot),
937
+ boundary: () => checkBoundary(rootAbs, findings),
938
+ platform: () => checkPlatform(rootAbs, findings),
521
939
  };
522
940
 
523
941
  const classData = {
524
942
  closure: { digest: null, note: "not-selected" },
525
943
  git: null,
526
- version: { contractsVersion: null },
944
+ version: { contractsVersion: null, singleSource: null },
527
945
  identity: null,
946
+ boundary: { declared: false, declarationId: null, declaredVersion: null, documentState: "missing" },
947
+ platform: { declared: false, project: null, released: [], missing: [], declaredVersion: null, documentState: "missing" },
528
948
  };
529
949
 
530
950
  for (const name of CHECK_CLASSES) {
@@ -535,6 +955,8 @@ export async function runChecks({ root, allowGitSpawn = true, only, profilesRoot
535
955
  if (name === "closure") classData.closure = result;
536
956
  if (name === "git") classData.git = result;
537
957
  if (name === "version") classData.version = result;
958
+ if (name === "boundary") classData.boundary = result;
959
+ if (name === "platform") classData.platform = result;
538
960
  if (name === "identity") {
539
961
  classData.identity = result
540
962
  ? { record: result, licensing: result.licensing, authors: result.authors }
@@ -597,6 +1019,8 @@ export async function runChecks({ root, allowGitSpawn = true, only, profilesRoot
597
1019
  version: classData.version,
598
1020
  managedDeclarations,
599
1021
  identity: classData.identity,
1022
+ boundary: classData.boundary,
1023
+ platform: classData.platform,
600
1024
  },
601
1025
  policy:
602
1026
  "check is diagnosis only: it never writes, never fixes, and never calls git write commands; findings must be resolved by a human or an authorized generator",