@ecoma-io/archkeep 0.23.0 → 0.24.1

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 (83) hide show
  1. package/package.json +1 -1
  2. package/src/analysis/csharp.mjs +3 -1
  3. package/src/analysis/dotnet/csproj.mjs +5 -1
  4. package/src/analysis/dotnet/namespaces.mjs +1 -0
  5. package/src/analysis/go.mjs +6 -0
  6. package/src/analysis/java.mjs +2 -0
  7. package/src/analysis/jvm/gradle.mjs +3 -1
  8. package/src/analysis/jvm/maven.mjs +6 -1
  9. package/src/analysis/jvm/packages.mjs +1 -0
  10. package/src/analysis/jvm/resolve.mjs +4 -2
  11. package/src/analysis/kotlin.mjs +2 -0
  12. package/src/analysis/markdown.mjs +341 -0
  13. package/src/analysis/python.mjs +8 -0
  14. package/src/analysis/rust.mjs +5 -1
  15. package/src/analysis/source-util.mjs +6 -5
  16. package/src/analysis/typescript.mjs +148 -0
  17. package/src/architecture-intent/model.mjs +13 -8
  18. package/src/architecture-intent/selectors.mjs +2 -1
  19. package/src/commands/change-intent.mjs +10 -9
  20. package/src/commands/change.mjs +17 -1
  21. package/src/commands/check.mjs +177 -17
  22. package/src/commands/completeness.mjs +7 -6
  23. package/src/commands/context-command.mjs +12 -20
  24. package/src/commands/coverage-acceptance.mjs +46 -0
  25. package/src/commands/coverage-verdict.mjs +12 -2
  26. package/src/commands/custom-rules.mjs +1 -0
  27. package/src/commands/delta-classify.mjs +3 -0
  28. package/src/commands/delta-snapshot.mjs +3 -6
  29. package/src/commands/delta.mjs +35 -21
  30. package/src/commands/drift.mjs +1 -1
  31. package/src/commands/evaluation-primitives.mjs +4 -4
  32. package/src/commands/evolution.mjs +2 -0
  33. package/src/commands/explain.mjs +19 -20
  34. package/src/commands/graph.mjs +44 -15
  35. package/src/commands/health.mjs +4 -0
  36. package/src/commands/history.mjs +2 -0
  37. package/src/commands/plan-context-command.mjs +9 -2
  38. package/src/commands/policy.mjs +8 -5
  39. package/src/commands/provenance.mjs +8 -2
  40. package/src/commands/scenario-evaluation.mjs +1 -1
  41. package/src/commands/trajectory.mjs +2 -1
  42. package/src/config.mjs +170 -2
  43. package/src/custom-rules/host.mjs +3 -3
  44. package/src/errors.mjs +23 -1
  45. package/src/eslint-config.mjs +3 -5
  46. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  47. package/src/go-work.mjs +1 -1
  48. package/src/governance/adr-registry.mjs +6 -2
  49. package/src/governance/debt-ledger.mjs +1 -1
  50. package/src/governance/decision-fitness.mjs +2 -0
  51. package/src/governance/decision-graph.mjs +1 -0
  52. package/src/governance/discovery-proposal.mjs +8 -2
  53. package/src/governance/evolution-event.mjs +42 -0
  54. package/src/governance/evolution-store.mjs +3 -2
  55. package/src/governance/fitness-registry.mjs +2 -3
  56. package/src/governance/preset-fingerprints.json +14 -14
  57. package/src/governance/profile-registry.mjs +22 -3
  58. package/src/governance/provenance-record.mjs +4 -1
  59. package/src/governance/reconcile-score.mjs +4 -0
  60. package/src/governance/row-schema.mjs +1 -3
  61. package/src/governance/verdict.mjs +1 -0
  62. package/src/governance/waiver.mjs +1 -0
  63. package/src/intent/intent-manifest.json +6 -6
  64. package/src/intent/mask-non-code.mjs +1 -0
  65. package/src/lsp/diagnostics.mjs +3 -2
  66. package/src/lsp/protocol.mjs +2 -1
  67. package/src/lsp/server.mjs +3 -0
  68. package/src/lsp/workspace-index.mjs +3 -1
  69. package/src/providers/moon.mjs +5 -5
  70. package/src/providers/native/differential.fixtures.mjs +29 -11
  71. package/src/providers/native/index.mjs +2 -1
  72. package/src/providers/native/model.mjs +4 -0
  73. package/src/report/envelope-shape.mjs +2 -0
  74. package/src/report/sarif.mjs +21 -8
  75. package/src/report/snapshot-text.mjs +3 -3
  76. package/src/report/text.mjs +10 -2
  77. package/src/rules/index.mjs +30 -0
  78. package/src/rules/match.mjs +7 -5
  79. package/src/rules/specifiers.mjs +2 -0
  80. package/src/rules/tags.mjs +3 -2
  81. package/src/rules/topology.mjs +6 -1
  82. package/src/verdict.mjs +33 -2
  83. package/src/workspace.mjs +1 -0
@@ -227,17 +227,20 @@ import {
227
227
 
228
228
  /** PEP 503 name normalization: case-insensitive, runs of `-_.` collapse to `-`. */
229
229
  export function normalizePackageName(name) {
230
+ // used by its own test
230
231
  return name.toLowerCase().replace(/[-_.]+/g, "-");
231
232
  }
232
233
 
233
234
  /** The package name a PEP 508 requirement string refers to, or null. */
234
235
  export function parseRequirementName(requirement) {
236
+ // used by its own test
235
237
  const match = requirement.trim().match(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/);
236
238
  return match ? normalizePackageName(match[0]) : null;
237
239
  }
238
240
 
239
241
  /** Every dependency name a pyproject manifest declares, deduped. */
240
242
  export function collectDeclaredDependencies(manifest) {
243
+ // used by its own test
241
244
  const names = new Set();
242
245
  const groups = [
243
246
  manifest.project?.dependencies ?? [],
@@ -630,6 +633,7 @@ const READ_BUILD_BACKENDS = new Set([
630
633
  * @returns {{ directories: string[], unmodelled: string[] }}
631
634
  */
632
635
  export function pythonPackageLayout(manifestText) {
636
+ // used by its own test
633
637
  if (manifestText === null) return { directories: [], unmodelled: [] };
634
638
  const manifest = parseManifest(manifestText);
635
639
  if (manifest === null) {
@@ -730,6 +734,7 @@ function ownPackageOf(file, projectRoot, directories) {
730
734
  * @returns {Map<string, { file: string|null, namespace: boolean }>}
731
735
  */
732
736
  export function pythonModuleIndex(projectRoot, files, directories = []) {
737
+ // used by its own test
733
738
  const index = new Map();
734
739
  for (const file of files) {
735
740
  if (!file.endsWith(".py")) continue;
@@ -755,6 +760,7 @@ export function pythonModuleIndex(projectRoot, files, directories = []) {
755
760
  * @returns {string[]}
756
761
  */
757
762
  export function pythonImportRoots(projectRoot, files, directories = []) {
763
+ // used by its own test
758
764
  return [...pythonModuleIndex(projectRoot, files, directories).keys()]
759
765
  .filter((name) => !name.includes("."))
760
766
  .sort();
@@ -1059,6 +1065,7 @@ function joinContinuedStatement(physicalLines, lineOffsets, index) {
1059
1065
  * continuation?: boolean }[]}
1060
1066
  */
1061
1067
  export function parsePythonImportSites(pythonText) {
1068
+ // used by its own test
1062
1069
  const sites = [];
1063
1070
  // Byte tolerance (`contract.md`): the lines a CRLF file splits into still
1064
1071
  // carry their `\r`, and a BOM-prefixed file's first line starts with
@@ -1166,6 +1173,7 @@ export function parsePythonImportSites(pythonText) {
1166
1173
  * @returns {string[]} Reasons, at most one per malformation kind.
1167
1174
  */
1168
1175
  export function pythonImportMalformations(pythonText) {
1176
+ // used by its own test
1169
1177
  /** @type {string[]} */
1170
1178
  const reasons = [];
1171
1179
  // A bare `import` or `from` at the end of the file, or on a line with
@@ -238,6 +238,7 @@ export function resolveRustDependencies(projects, filesOf, readFile) {
238
238
 
239
239
  /** Cargo's identifier spelling of a crate name: `-` and `.` become `_`. */
240
240
  export function crateIdentifier(name) {
241
+ // used by its own test
241
242
  return name.replace(/[-.]/g, "_");
242
243
  }
243
244
 
@@ -250,6 +251,7 @@ export function crateIdentifier(name) {
250
251
  * @returns {string|null}
251
252
  */
252
253
  export function crateImportName(manifest) {
254
+ // used by its own test
253
255
  const declared = manifest?.lib?.name ?? manifest?.package?.name;
254
256
  return typeof declared === "string" && declared !== "" ? crateIdentifier(declared) : null;
255
257
  }
@@ -471,7 +473,7 @@ function isOwnProjectPath(root, owner, byCrate) {
471
473
  * @param {string} path The `use` path, from the first non-space to the `;`.
472
474
  * @returns {{text: string, offset: number}[] | null}
473
475
  */
474
- export function braceGroupArms(path) {
476
+ function braceGroupArms(path) {
475
477
  const open = path.indexOf("{");
476
478
  if (open === -1 || path.slice(0, open).trim() !== "") return null;
477
479
  /** @type {{text: string, offset: number}[]} */
@@ -520,6 +522,7 @@ export function braceGroupArms(path) {
520
522
  * with a brace group and names none.
521
523
  */
522
524
  export function useRootSegment(path) {
525
+ // used by its own test
523
526
  const match = /^\s*(?:::\s*)?([A-Za-z_]\w*)/.exec(path);
524
527
  return match ? match[1] : null;
525
528
  }
@@ -560,6 +563,7 @@ export function useRootSegment(path) {
560
563
  * produces from reading as a clean file.
561
564
  */
562
565
  export function parseRustUseSites(rustText, knownCrates = new Set(), options = {}) {
566
+ // used by its own test
563
567
  const { returnMetrics = false } = options;
564
568
  /** The offset of the first `use` opener no `;` terminates, `null` before one is seen. */
565
569
  let unterminatedUseAt = null;
@@ -153,13 +153,14 @@ function ownershipIndexOf(projects) {
153
153
  /**
154
154
  * Root comparisons `projectOwning` has performed since the module loaded.
155
155
  *
156
- * Nothing in production reads it. It exists so the complexity test counts
157
- * deterministic operations instead of milliseconds — the wall-clock this
158
- * repository does not trust in a test (cf. #359, #369). Every comparison the
159
- * lookup makes is counted: one per binary-search step, one per equality probe.
156
+ * A test-support export: nothing in production reads it. It exists so the
157
+ * complexity test counts deterministic operations instead of milliseconds —
158
+ * the wall-clock this repository does not trust in a test (cf. #359, #369).
159
+ * Every comparison the lookup makes is counted: one per binary-search step,
160
+ * one per equality probe.
160
161
  */
161
162
  let rootComparisons = 0;
162
- export const ownershipRootComparisons = () => rootComparisons;
163
+ export const ownershipRootComparisons = () => rootComparisons; // used by its own test
163
164
 
164
165
  /**
165
166
  * The first index in `roots` (sorted ascending) whose value is at or after
@@ -318,6 +318,7 @@ const SCRIPT_KIND_BY_LANG = Object.freeze({
318
318
  * @returns {{ path: boolean, relative: boolean, namesOnly: boolean }}
319
319
  */
320
320
  export function specifierSpelling(specifier) {
321
+ // used by its own test
321
322
  const relative =
322
323
  specifier === "." ||
323
324
  specifier === ".." ||
@@ -340,6 +341,7 @@ export function specifierSpelling(specifier) {
340
341
  * @returns {string|null}
341
342
  */
342
343
  export function packageNameOf(specifier) {
344
+ // used by its own test
343
345
  if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/")) return null;
344
346
  const segments = specifier.split("/");
345
347
  if (specifier.startsWith("@"))
@@ -1121,3 +1123,149 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
1121
1123
  }
1122
1124
  return result;
1123
1125
  }
1126
+
1127
+ /**
1128
+ * The names one TypeScript-language file EXPORTS, split by how the export was
1129
+ * written — the two facts `../analysis/markdown.mjs`'s resolution needs when a
1130
+ * document marker names a symbol and the engine must answer "which project
1131
+ * publishes this".
1132
+ *
1133
+ * The split is load-bearing rather than bookkeeping. A name a file DECLARES
1134
+ * here (`export const Button`, `export { Button }` over a local binding,
1135
+ * `export default`) is a symbol whose home this file's project is: a marker
1136
+ * naming it resolves to that project even when five other projects re-export
1137
+ * it, because the re-exporters are downstream of the declaration, not
1138
+ * alternative homes for it. A name a file RE-EXPORTS from another module
1139
+ * (`export { Button } from "@scope/ui-button"`, `export * as ui from …`) is a
1140
+ * name this module passes through, and the project that declares it owns the
1141
+ * resolution. Resolution prefers the declared tier for exactly this reason —
1142
+ * an umbrella barrel that re-exports a whole library must not turn every one
1143
+ * of its symbols into an ambiguous claim.
1144
+ *
1145
+ * Only top-level statements are read, and `export * from "…"` is deliberately
1146
+ * absent from both tiers: a star names no symbol, and enumerating one would
1147
+ * mean resolving the starred module — a module-resolution walk this function's
1148
+ * caller never needs, because a star's targets are themselves scanned as the
1149
+ * files they are. A project whose public surface is star-re-exported from
1150
+ * another project's files resolves those markers through the declaring files'
1151
+ * own projects, which is the honest answer at project grain.
1152
+ *
1153
+ * Never throws: a malformed file yields whatever TypeScript could parse plus a
1154
+ * failure per syntax error, the same posture `analyzeTypeScript` above holds —
1155
+ * one unreadable file must not blank the index every marker resolves against.
1156
+ *
1157
+ * @param {{ sourceFile: string, text: string, workspace: object, lang?: string }} request
1158
+ * The same request shape `analyzeTypeScript` takes; `lang` is a Vue block's
1159
+ * `<script lang>` and is omitted for a real file.
1160
+ * @returns {{ declared: string[], reexported: string[], failures: object[] }}
1161
+ */
1162
+ export function exportedNamesOf({ sourceFile, text, workspace, lang }) {
1163
+ /** @type {string[]} */
1164
+ const declared = [];
1165
+ /** @type {string[]} */
1166
+ const reexported = [];
1167
+ /** @type {object[]} */
1168
+ const failures = [];
1169
+ try {
1170
+ const parsed = ts.createSourceFile(
1171
+ `${workspace.root}/${sourceFile}`,
1172
+ text,
1173
+ ts.ScriptTarget.Latest,
1174
+ false,
1175
+ scriptKindFor(sourceFile, lang),
1176
+ );
1177
+ failures.push(...parseFailures(parsed, sourceFile));
1178
+
1179
+ const hasModifier = (node, kind) =>
1180
+ (node.modifiers ?? []).some((modifier) => modifier.kind === kind);
1181
+
1182
+ for (const statement of parsed.statements) {
1183
+ // `export { a, b as c }` with no `from` — a local binding list. The
1184
+ // EXPORTED name is the alias side: plain `a` exports `a`, `b as c`
1185
+ // exports `c`. Element name text is read defensively so a
1186
+ // string-literal alias (`export { a as "x y" }`) is carried as written
1187
+ // rather than undefined.
1188
+ if (ts.isExportDeclaration(statement) && statement.exportClause) {
1189
+ const clause = statement.exportClause;
1190
+ if (ts.isNamedExports(clause)) {
1191
+ const target = statement.moduleSpecifier ? reexported : declared;
1192
+ for (const element of clause.elements) {
1193
+ target.push(element.name?.text ?? "");
1194
+ }
1195
+ } else if (clause.name) {
1196
+ // `export * as ns from "…"` — a re-export wearing a new name.
1197
+ reexported.push(clause.name.text);
1198
+ }
1199
+ continue;
1200
+ }
1201
+ if (ts.isExportAssignment(statement)) {
1202
+ // `export default <expression>` and `export = <identifier>` — the
1203
+ // module's own default/exports binding, declared here whatever it
1204
+ // wraps. The `export =` form carries the identifier it aliases; a
1205
+ // default is the name every consumer writes, not the expression's.
1206
+ declared.push(
1207
+ statement.isExportEquals && ts.isIdentifier(statement.expression)
1208
+ ? statement.expression.text
1209
+ : "default",
1210
+ );
1211
+ continue;
1212
+ }
1213
+ const exported = hasModifier(statement, ts.SyntaxKind.ExportKeyword);
1214
+ if (!exported) continue;
1215
+ if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
1216
+ declared.push("default");
1217
+ continue;
1218
+ }
1219
+ // The one-name statements — function, class, enum, namespace, type,
1220
+ // interface — all carry the exported identifier as `.name`. Enumerated
1221
+ // kind by kind so the type checker's statement union narrows to the
1222
+ // members that actually have one; a future statement kind with a name
1223
+ // is a new arm here, which is the point.
1224
+ const named =
1225
+ ts.isFunctionDeclaration(statement) ||
1226
+ ts.isClassDeclaration(statement) ||
1227
+ ts.isEnumDeclaration(statement) ||
1228
+ ts.isTypeAliasDeclaration(statement) ||
1229
+ ts.isInterfaceDeclaration(statement) ||
1230
+ ts.isModuleDeclaration(statement);
1231
+ if (named) {
1232
+ const name = statement.name?.text ?? "";
1233
+ if (name !== "") {
1234
+ declared.push(name);
1235
+ continue;
1236
+ }
1237
+ }
1238
+ if (ts.isVariableStatement(statement)) {
1239
+ for (const declaration of statement.declarationList.declarations) {
1240
+ bindingNames(declaration.name, declared);
1241
+ }
1242
+ }
1243
+ }
1244
+ } catch (cause) {
1245
+ failures.push(fileFailure(sourceFile, `export scan failed: ${cause?.message ?? cause}`));
1246
+ }
1247
+ return { declared, reexported, failures };
1248
+ }
1249
+
1250
+ /**
1251
+ * Every identifier a binding pattern introduces, in source order — `a`,
1252
+ * `{ a, b: c }`'s `a` and `c`, `[x, ...rest]`'s `x` and `rest`. Computed
1253
+ * properties (`{ [key]: value }`) introduce nothing nameable and are skipped,
1254
+ * the same call a minifier would make: a name no source text carries is a name
1255
+ * no marker can claim.
1256
+ *
1257
+ * @param {ts.Node} node A binding name or pattern.
1258
+ * @param {string[]} out Accumulator, mutated in place.
1259
+ */
1260
+ function bindingNames(node, out) {
1261
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node)) {
1262
+ out.push(node.text);
1263
+ return;
1264
+ }
1265
+ if (ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node)) {
1266
+ for (const element of node.elements) {
1267
+ if (ts.isOmittedExpression(element)) continue;
1268
+ bindingNames(element.name ?? element, out);
1269
+ }
1270
+ }
1271
+ }
@@ -41,6 +41,7 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
41
41
  import { resolve } from "node:path";
42
42
 
43
43
  import { containmentViolation } from "../containment.mjs";
44
+ import { isEnoent } from "../errors.mjs";
44
45
 
45
46
  import { isValidSelector, splitSelector } from "./selectors.mjs";
46
47
  import { describe, isPlainObject } from "../values.mjs";
@@ -50,7 +51,7 @@ import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "../governance/row-sche
50
51
  export const INTENT_FILE = "architecture-intent.json";
51
52
 
52
53
  /** The one supported `version`. A different value is a load error. */
53
- export const INTENT_VERSION = "1";
54
+ export const INTENT_VERSION = "1"; // used by its own test
54
55
 
55
56
  /**
56
57
  * The only keys a valid intent file may carry at the top level.
@@ -65,6 +66,7 @@ export const INTENT_VERSION = "1";
65
66
  * engine judges them.
66
67
  */
67
68
  export const TOP_LEVEL_KEYS = Object.freeze([
69
+ // used by its own test
68
70
  "version",
69
71
  "boundaries",
70
72
  "allowed",
@@ -75,17 +77,17 @@ export const TOP_LEVEL_KEYS = Object.freeze([
75
77
  ]);
76
78
 
77
79
  /** The sub-keys a `projects` section may carry. */
78
- export const PROJECT_SECTION_KEYS = Object.freeze(["required", "forbidden"]);
80
+ const PROJECT_SECTION_KEYS = Object.freeze(["required", "forbidden"]);
79
81
  /** The sub-keys a `dependencies` section may carry. */
80
- export const DEPENDENCY_SECTION_KEYS = Object.freeze(["allowed", "forbidden"]);
82
+ const DEPENDENCY_SECTION_KEYS = Object.freeze(["allowed", "forbidden"]);
81
83
  /** The keys a `projects.required[]` row may carry. */
82
- export const REQUIRED_PROJECT_KEYS = Object.freeze(["name", "tags", "decisionRef"]);
84
+ const REQUIRED_PROJECT_KEYS = Object.freeze(["name", "tags", "decisionRef"]);
83
85
  /** The keys a `projects.forbidden[]` row may carry. */
84
- export const FORBIDDEN_PROJECT_KEYS = Object.freeze(["name", "decisionRef"]);
86
+ const FORBIDDEN_PROJECT_KEYS = Object.freeze(["name", "decisionRef"]);
85
87
  /** The keys a `dependencies.allowed[]` / `dependencies.forbidden[]` row may carry. */
86
- export const DEPENDENCY_ROW_KEYS = Object.freeze(["source", "target", "decisionRef"]);
88
+ const DEPENDENCY_ROW_KEYS = Object.freeze(["source", "target", "decisionRef"]);
87
89
  /** The keys a `forbiddenTags[]` row may carry. */
88
- export const TAG_ROW_KEYS = Object.freeze(["from", "to", "decisionRef"]);
90
+ const TAG_ROW_KEYS = Object.freeze(["from", "to", "decisionRef"]);
89
91
 
90
92
  /**
91
93
  * The shared governance-block check for any intent row (Contract 2): when the
@@ -156,6 +158,7 @@ function isSingleProjectSelector(selector) {
156
158
  * resolve against.
157
159
  */
158
160
  export function boundaryNames(intent) {
161
+ // used by its own test
159
162
  return (intent.boundaries ?? []).map((b) => b.name);
160
163
  }
161
164
 
@@ -167,6 +170,7 @@ export function boundaryNames(intent) {
167
170
  * @returns {string[]}
168
171
  */
169
172
  export function findIntentViolations(raw) {
173
+ // used by its own test
170
174
  const violations = [];
171
175
 
172
176
  if (!isPlainObject(raw)) {
@@ -602,6 +606,7 @@ export function findIntentViolations(raw) {
602
606
  * @returns {object} The normalized model: `{version, boundaries: {name, match, members?}[], allowed, forbidden, projects?, dependencies?, forbiddenTags?}`.
603
607
  */
604
608
  export function normalizeIntent(intent) {
609
+ // used by its own test
605
610
  return {
606
611
  version: intent.version,
607
612
  boundaries: intent.boundaries.map((b) => ({ name: b.name, match: [...b.match] })),
@@ -665,7 +670,7 @@ export async function loadIntent(root, { read = readFileFromDisk, tracked } = {}
665
670
  // neighbours on the identical tree are both loud: an escaping symlink
666
671
  // throws at the containment check above, and EACCES throws below. Only
667
672
  // this one was silent (`../../../../AGENTS.md`).
668
- if (cause?.code === "ENOENT") {
673
+ if (isEnoent(cause)) {
669
674
  if (tracked === undefined) return undefined;
670
675
  throw new Error(
671
676
  `${INTENT_FILE}: is tracked but could not be read: ${cause?.message ?? cause} — ` +
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  /** The three selector labels. `unlabeled` is a bare project name (equals `name:`). */
33
- export const SELECTOR_LABELS = Object.freeze(["name", "tag", "directory"]);
33
+ const SELECTOR_LABELS = Object.freeze(["name", "tag", "directory"]);
34
34
 
35
35
  /**
36
36
  * Split a selector into `{exclude, label, value}` — `!` prefix removed, `*`
@@ -71,6 +71,7 @@ export function isValidSelector(value) {
71
71
  * @returns {string[]}
72
72
  */
73
73
  export function selectProjects(selector, nodes) {
74
+ // used by its own test
74
75
  const { label, value } = splitSelector(selector);
75
76
 
76
77
  let found;
@@ -44,10 +44,10 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
44
44
  import { describe, isPlainObject } from "../values.mjs";
45
45
 
46
46
  /** The only `version` this module accepts. A different value is a load error. */
47
- export const CHANGE_INTENT_VERSION = "1";
47
+ const CHANGE_INTENT_VERSION = "1";
48
48
 
49
49
  /** The only keys a valid change-intent file may carry at the top level. */
50
- export const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
50
+ const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
51
51
  "version",
52
52
  "base",
53
53
  "summary",
@@ -57,16 +57,16 @@ export const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
57
57
  ]);
58
58
 
59
59
  /** The keys the `base` section may carry. */
60
- export const CHANGE_INTENT_BASE_KEYS = Object.freeze(["commit"]);
60
+ const CHANGE_INTENT_BASE_KEYS = Object.freeze(["commit"]);
61
61
 
62
62
  /** The sub-keys the `projects` section may carry. */
63
- export const CHANGE_INTENT_PROJECT_SECTION_KEYS = Object.freeze(["add", "remove"]);
63
+ const CHANGE_INTENT_PROJECT_SECTION_KEYS = Object.freeze(["add", "remove"]);
64
64
  /** The sub-keys the `edges` section may carry. */
65
- export const CHANGE_INTENT_EDGE_SECTION_KEYS = Object.freeze(["add", "remove"]);
65
+ const CHANGE_INTENT_EDGE_SECTION_KEYS = Object.freeze(["add", "remove"]);
66
66
  /** The keys an edge row may carry. */
67
- export const CHANGE_INTENT_EDGE_ROW_KEYS = Object.freeze(["from", "to"]);
67
+ const CHANGE_INTENT_EDGE_ROW_KEYS = Object.freeze(["from", "to"]);
68
68
  /** The keys the `constraints` section may carry. */
69
- export const CHANGE_INTENT_CONSTRAINT_KEYS = Object.freeze(["noNewViolations", "noNewCycles"]);
69
+ const CHANGE_INTENT_CONSTRAINT_KEYS = Object.freeze(["noNewViolations", "noNewCycles"]);
70
70
 
71
71
  /**
72
72
  * The constraints the reconciliation judges, in the fixed order their verdict
@@ -204,7 +204,7 @@ function sectionListViolations(section, spec) {
204
204
  * @param {unknown} raw The parsed JSON value.
205
205
  * @returns {string[]}
206
206
  */
207
- export function findChangeIntentViolations(raw) {
207
+ function findChangeIntentViolations(raw) {
208
208
  const violations = [];
209
209
  if (!isPlainObject(raw)) {
210
210
  return [`top level: must be an object, got ${describe(raw)}`];
@@ -351,7 +351,7 @@ export function findChangeIntentViolations(raw) {
351
351
  * with absent raw sections already normalized to empty arrays.
352
352
  * @returns {string[]} Messages; empty when the intent is not a catch-all.
353
353
  */
354
- export function findChangeIntentBreadthViolations(intent) {
354
+ function findChangeIntentBreadthViolations(intent) {
355
355
  const declaredRows =
356
356
  intent.projects.add.length +
357
357
  intent.projects.remove.length +
@@ -389,6 +389,7 @@ export function findChangeIntentBreadthViolations(intent) {
389
389
  * silently would reconcile against an expectation nobody wrote.
390
390
  */
391
391
  export function parseChangeIntent(text, path) {
392
+ // used by its own test
392
393
  let parsed;
393
394
  try {
394
395
  parsed = JSON.parse(text);
@@ -112,6 +112,7 @@ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
112
112
  import { resolveProvenance } from "./provenance.mjs";
113
113
  import { referenceTime } from "../governance/clock.mjs";
114
114
  import {
115
+ assertReproducibleEventIdentity,
115
116
  classifyEvolution,
116
117
  declarationDigest,
117
118
  escapeIdentityField,
@@ -170,6 +171,7 @@ function cmpFacts(a, b) {
170
171
  * @returns {string}
171
172
  */
172
173
  export function violationFindingId(entry) {
174
+ // used by its own test
173
175
  const sourceProject =
174
176
  entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
175
177
  return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
@@ -250,6 +252,7 @@ function observedFrom(structural, meta) {
250
252
  * @returns {{matched: object[], unexpected: object[], missingExpected: object[]}}
251
253
  */
252
254
  export function reconcileMaterialDelta(intent, delta) {
255
+ // used by its own test
253
256
  const addedProjectNames = new Set(intent.projects.add);
254
257
  const removedProjectNames = new Set(intent.projects.remove);
255
258
  // The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
@@ -358,7 +361,7 @@ export function reconcileMaterialDelta(intent, delta) {
358
361
  * @param {string[]} unprovenReasons Why the base identity could not be proven.
359
362
  * @returns {"matched"|"undeclared"|"unfulfilled"|"unproven"}
360
363
  */
361
- export function reconciliationVerdict(lists, unprovenReasons) {
364
+ function reconciliationVerdict(lists, unprovenReasons) {
362
365
  if (unprovenReasons.length > 0) return "unproven";
363
366
  if (lists.unexpected.length > 0) return "undeclared";
364
367
  if (lists.missingExpected.length > 0) return "unfulfilled";
@@ -389,6 +392,7 @@ export function reconciliationVerdict(lists, unprovenReasons) {
389
392
  * @returns {"accepted"|"rejected"|"no-verdict"}
390
393
  */
391
394
  export function reconcileDisposition(verdict, constraints = []) {
395
+ // used by its own test
392
396
  if (verdict === "unproven") return "no-verdict";
393
397
  if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
394
398
  if (constraints.some((row) => row.verdict === "unknown")) return "no-verdict";
@@ -898,6 +902,18 @@ export async function changeCommand(
898
902
  /** @type {{dir: string, id: string, duplicate: boolean}|null} */
899
903
  let eventWritten = null;
900
904
  if (eventOut !== undefined && eventOut !== null && eventOut !== "") {
905
+ // F-delta-event-id on the reconcile event: the event's identity names
906
+ // base and head revisions, so a commitless head or a dirty tree collapses
907
+ // distinct evidence states onto one event id — a later transition is
908
+ // silently lost or aliased, the silent direction. `delta` holds the same
909
+ // gate; the shared law and its frozen wording live in
910
+ // `../governance/evolution-event.mjs`.
911
+ assertReproducibleEventIdentity({
912
+ label: "change",
913
+ headCommit,
914
+ baseDirty: baseline.provenance?.dirty === true,
915
+ headDirty: headProvenance?.dirty === true,
916
+ });
901
917
  // The store's io.root is the workspace root: the write must be provable
902
918
  // to stay inside the workspace (the same containment `--output` obeys).
903
919
  // A write failure throws — exit 3 upstream, the could-not-look lane —