@nimiplatform/nimi-coding 0.3.1 → 0.5.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 (115) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +297 -41
  4. package/README.zh-CN.md +306 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +804 -0
  7. package/cli/commands/clear.mjs +27 -7
  8. package/cli/commands/doctor.mjs +10 -1
  9. package/cli/commands/start.mjs +23 -27
  10. package/cli/commands/sync.mjs +21 -101
  11. package/cli/constants.mjs +7 -31
  12. package/cli/dev/check-authority-surface.mjs +182 -0
  13. package/cli/dev/check-product-conformance.mjs +889 -0
  14. package/cli/help.mjs +10 -17
  15. package/cli/index.mjs +6 -47
  16. package/cli/lib/authority/anchors.mjs +491 -0
  17. package/cli/lib/authority/audit.mjs +567 -0
  18. package/cli/lib/authority/change-candidates.mjs +807 -0
  19. package/cli/lib/authority/closed-sets.mjs +645 -0
  20. package/cli/lib/authority/compile.mjs +92 -0
  21. package/cli/lib/authority/compiled-snapshot.mjs +143 -0
  22. package/cli/lib/authority/consumers.mjs +520 -0
  23. package/cli/lib/authority/diagnostics.mjs +93 -0
  24. package/cli/lib/authority/diff.mjs +310 -0
  25. package/cli/lib/authority/discover.mjs +447 -0
  26. package/cli/lib/authority/format.mjs +169 -0
  27. package/cli/lib/authority/git-snapshot.mjs +720 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +125 -0
  30. package/cli/lib/authority/lexicon.mjs +55 -0
  31. package/cli/lib/authority/query.mjs +162 -0
  32. package/cli/lib/authority/repository-corpus.mjs +37 -0
  33. package/cli/lib/authority/repository-inventory.mjs +34 -0
  34. package/cli/lib/authority/review.mjs +212 -0
  35. package/cli/lib/authority/sarif.mjs +174 -0
  36. package/cli/lib/authority/scope-bindings.mjs +225 -0
  37. package/cli/lib/authority/source-map.mjs +105 -0
  38. package/cli/lib/authority/source-markdown.mjs +167 -0
  39. package/cli/lib/authority/source-yaml.mjs +308 -0
  40. package/cli/lib/authority/surface.mjs +97 -0
  41. package/cli/lib/authority/terms.mjs +422 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +300 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +104 -105
  47. package/cli/lib/fs-helpers.mjs +139 -15
  48. package/cli/lib/internal/governance/config.mjs +0 -36
  49. package/cli/lib/shared.mjs +8 -21
  50. package/cli/lib/sync.mjs +81 -92
  51. package/cli/lib/value-helpers.mjs +0 -26
  52. package/cli/lib/yaml-helpers.mjs +1 -124
  53. package/cli/seeds/bootstrap.mjs +31 -87
  54. package/cli/seeds/seed-policy.yaml +9 -63
  55. package/contracts/authority-scope-bindings.schema.yaml +15 -0
  56. package/contracts/authority-source.schema.yaml +58 -0
  57. package/contracts/authority-verifier-bindings.schema.yaml +31 -0
  58. package/methodology/authority-authoring.yaml +127 -0
  59. package/methodology/core.yaml +34 -27
  60. package/package.json +10 -6
  61. package/spec/authority-anchors.yaml +99 -0
  62. package/spec/authority-audit.yaml +89 -0
  63. package/spec/authority-authoring.yaml +137 -0
  64. package/spec/authority-change-candidates.yaml +146 -0
  65. package/spec/authority-closed-sets.yaml +65 -0
  66. package/spec/authority-consumers.yaml +77 -0
  67. package/spec/authority-context.yaml +50 -0
  68. package/spec/authority-discovery.yaml +119 -0
  69. package/spec/authority-graph.yaml +59 -0
  70. package/spec/authority-impact.yaml +61 -0
  71. package/spec/authority-review.yaml +71 -0
  72. package/spec/authority-terms.yaml +63 -0
  73. package/spec/product-scope.yaml +37 -23
  74. package/cli/commands/blueprint-audit.mjs +0 -91
  75. package/cli/commands/classify-spec-tree.mjs +0 -5
  76. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  77. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  78. package/cli/commands/surface-validator-command.mjs +0 -49
  79. package/cli/commands/validate-domain-admission.mjs +0 -5
  80. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  81. package/cli/commands/validate-placement.mjs +0 -5
  82. package/cli/commands/validate-projection-edges.mjs +0 -5
  83. package/cli/commands/validate-spec-audit.mjs +0 -27
  84. package/cli/commands/validate-spec-governance.mjs +0 -144
  85. package/cli/commands/validate-spec-tree.mjs +0 -27
  86. package/cli/commands/validate-table-family.mjs +0 -5
  87. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  88. package/cli/lib/blueprint-audit.mjs +0 -363
  89. package/cli/lib/contracts.mjs +0 -21
  90. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  91. package/cli/lib/internal/contracts-parse.mjs +0 -192
  92. package/cli/lib/internal/governance/runner.mjs +0 -35
  93. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  94. package/cli/lib/internal/validators-shared.mjs +0 -12
  95. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  96. package/cli/lib/internal/validators-spec.mjs +0 -380
  97. package/cli/lib/validators.mjs +0 -30
  98. package/config/bootstrap.yaml +0 -6
  99. package/config/spec-generation-inputs.yaml +0 -41
  100. package/contracts/domain-admission.schema.yaml +0 -56
  101. package/contracts/migration-inventory.schema.yaml +0 -15
  102. package/contracts/negative-fixtures.yaml +0 -26
  103. package/contracts/placement-contract.schema.yaml +0 -127
  104. package/contracts/projection-edge.schema.yaml +0 -46
  105. package/contracts/shared-enums.yaml +0 -63
  106. package/contracts/spec-generation-audit.schema.yaml +0 -46
  107. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  108. package/contracts/spec-layout.schema.yaml +0 -32
  109. package/contracts/surface-taxonomy.schema.yaml +0 -139
  110. package/contracts/table-family.schema.yaml +0 -133
  111. package/contracts/tracked-output-admission.schema.yaml +0 -17
  112. package/methodology/four-closure-policy.yaml +0 -20
  113. package/methodology/role-separation-policy.yaml +0 -22
  114. package/methodology/spec-reconstruction.yaml +0 -69
  115. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -0,0 +1,807 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ import { compareText, makeDiagnostic, portablePath } from "./diagnostics.mjs";
4
+ import { diffAuthorityPathsWithSnapshots, diffAuthoritySnapshots } from "./diff.mjs";
5
+ import { normalizeDiscoveryTerms } from "./discover.mjs";
6
+
7
+ const CHANGE_CANDIDATES_FORMAT = "nimicoding.authority-change-candidates/v1";
8
+ const SIDES = ["before", "after"];
9
+ export const CHANGE_CANDIDATE_CHANNELS = Object.freeze([
10
+ "owner",
11
+ "scope",
12
+ "changed_term",
13
+ "direct_relation",
14
+ "definition_identity",
15
+ "definition_title",
16
+ ]);
17
+ const CHANNEL_RANK = new Map(CHANGE_CANDIDATE_CHANNELS.map((channel, index) => [channel, index]));
18
+ const RECALL_RELATION_TYPES = new Set(["applies_to", "supersedes"]);
19
+ const TEXT_FIELD_POINTERS = new Map([
20
+ ["/metadata/title", "title"],
21
+ ["/semantic/meaning", "meaning"],
22
+ ["/semantic/statement", "statement"],
23
+ ["/semantic/condition", "condition"],
24
+ ["/semantic/failure", "failure"],
25
+ ]);
26
+ const IDENTIFIER_TOKEN_CHARACTER = /[A-Za-z0-9_.-]/u;
27
+ const WORD_CHARACTER = /[\p{L}\p{N}]/u;
28
+
29
+ function portableLocation(mapped, basePath) {
30
+ return {
31
+ file: portablePath(mapped.file, basePath),
32
+ range: mapped.range,
33
+ sourcePointer: mapped.sourcePointer,
34
+ };
35
+ }
36
+
37
+ function mappedAt(side, unitIndex, pointer) {
38
+ return portableLocation(side.compiled.sourceMap.fields[`/units/${unitIndex}${pointer}`], side.inputRoot);
39
+ }
40
+
41
+ function validBudget(value) {
42
+ return Number.isSafeInteger(value) && value > 0;
43
+ }
44
+
45
+ function diagnostic(code, reason, repair) {
46
+ return makeDiagnostic({
47
+ code,
48
+ file: ".",
49
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
50
+ pointer: "",
51
+ reason,
52
+ repair,
53
+ });
54
+ }
55
+
56
+ function refused(diagnostics, { fileCount = 0, candidateBytes = 0 } = {}) {
57
+ return {
58
+ ok: false,
59
+ diagnostics,
60
+ fileCount,
61
+ unitCount: 0,
62
+ candidateBytes,
63
+ changeCandidates: null,
64
+ partial: false,
65
+ };
66
+ }
67
+
68
+ function budgetDiagnostic(kind, required, admitted) {
69
+ const option = kind === "candidates" ? "max-candidates" : kind === "reasons" ? "max-reasons" : "max-bytes";
70
+ return diagnostic(
71
+ "AUTH_CHANGE_CANDIDATES_BUDGET",
72
+ `complete change-derived candidate discovery requires ${required} ${kind} but ${option} is ${admitted}`,
73
+ "increase the explicit budget; partial candidate unions, reasons, and payloads are forbidden",
74
+ );
75
+ }
76
+
77
+ function validateChannels(channels) {
78
+ if (!Array.isArray(channels) || channels.length === 0) return {
79
+ error: diagnostic(
80
+ "AUTH_CHANGE_CANDIDATES_CHANNELS",
81
+ "change-derived candidate discovery requires one non-empty channel array",
82
+ `provide one or more unique exact channels from: ${CHANGE_CANDIDATE_CHANNELS.join(", ")}`,
83
+ ),
84
+ };
85
+ const selected = new Set();
86
+ for (const channel of channels) {
87
+ if (typeof channel !== "string" || !CHANNEL_RANK.has(channel)) return {
88
+ error: diagnostic(
89
+ "AUTH_CHANGE_CANDIDATES_CHANNELS",
90
+ `change-derived candidate discovery channel is not admitted: ${String(channel)}`,
91
+ `provide only unique exact channels from: ${CHANGE_CANDIDATE_CHANNELS.join(", ")}`,
92
+ ),
93
+ };
94
+ if (selected.has(channel)) return {
95
+ error: diagnostic(
96
+ "AUTH_CHANGE_CANDIDATES_CHANNELS",
97
+ `change-derived candidate discovery channel is duplicated: ${channel}`,
98
+ "provide every requested channel exactly once",
99
+ ),
100
+ };
101
+ selected.add(channel);
102
+ }
103
+ return {
104
+ channels: CHANGE_CANDIDATE_CHANNELS.filter((channel) => selected.has(channel)),
105
+ };
106
+ }
107
+
108
+ function rawTextFields(entry) {
109
+ const fields = [{ field: "title", raw: entry.unit.metadata.title, pointer: "/metadata/title" }];
110
+ if (entry.unit.lifecycle === "removed") return fields;
111
+ if (entry.unit.kind === "definition") {
112
+ fields.push({ field: "meaning", raw: entry.unit.semantic.meaning, pointer: "/semantic/meaning" });
113
+ } else {
114
+ fields.push(
115
+ { field: "statement", raw: entry.unit.semantic.statement, pointer: "/semantic/statement" },
116
+ { field: "condition", raw: entry.unit.semantic.condition, pointer: "/semantic/condition" },
117
+ { field: "failure", raw: entry.unit.semantic.failure, pointer: "/semantic/failure" },
118
+ );
119
+ }
120
+ return fields;
121
+ }
122
+
123
+ function rawFieldEndpoint(side, entry, field, raw, pointer) {
124
+ return { field, raw, location: mappedAt(side, entry.unitIndex, pointer) };
125
+ }
126
+
127
+ function compareReasons(left, right) {
128
+ return CHANNEL_RANK.get(left.channel) - CHANNEL_RANK.get(right.channel)
129
+ || SIDES.indexOf(left.side) - SIDES.indexOf(right.side)
130
+ || compareText(left.rootId, right.rootId)
131
+ || compareText(JSON.stringify(left.witness), JSON.stringify(right.witness));
132
+ }
133
+
134
+ function buildRootLexicalFields(diffResult, side) {
135
+ const selected = new Map();
136
+ for (const change of diffResult.diff.changes) {
137
+ const entry = side.byId.get(change.unitId);
138
+ if (!entry) continue;
139
+ if (change.operation === "unit_added") {
140
+ if (side.name !== "after") continue;
141
+ for (const field of rawTextFields(entry)) {
142
+ const terms = normalizeDiscoveryTerms(field.raw);
143
+ if (terms.length === 0) continue;
144
+ const key = JSON.stringify([change.unitId, field.field, field.raw]);
145
+ selected.set(key, { key, rootId: change.unitId, ...field, terms });
146
+ }
147
+ continue;
148
+ }
149
+ const field = TEXT_FIELD_POINTERS.get(change.pointer);
150
+ const raw = side.name === "before" ? change.before : change.after;
151
+ if (field === undefined || typeof raw !== "string") continue;
152
+ const otherRaw = side.name === "before" ? change.after : change.before;
153
+ const otherTerms = new Set(typeof otherRaw === "string" ? normalizeDiscoveryTerms(otherRaw) : []);
154
+ const terms = normalizeDiscoveryTerms(raw).filter((term) => !otherTerms.has(term));
155
+ if (terms.length === 0) continue;
156
+ const key = JSON.stringify([change.unitId, field, raw]);
157
+ selected.set(key, {
158
+ key,
159
+ rootId: change.unitId,
160
+ field,
161
+ raw,
162
+ pointer: change.pointer,
163
+ terms,
164
+ });
165
+ }
166
+ return [...selected.values()].sort((left, right) => (
167
+ compareText(left.rootId, right.rootId)
168
+ || compareText(left.field, right.field)
169
+ || compareText(left.raw, right.raw)
170
+ ));
171
+ }
172
+
173
+ function buildDefinitionAnchors(roots, side) {
174
+ const anchors = new Map();
175
+ function add(rootId, definition, rootReference) {
176
+ const key = JSON.stringify([
177
+ rootId,
178
+ definition.unit.id,
179
+ rootReference.kind,
180
+ rootReference.relationIndex ?? null,
181
+ ]);
182
+ anchors.set(key, { key, rootId, definition, rootReference });
183
+ }
184
+ for (const rootId of roots) {
185
+ const root = side.byId.get(rootId);
186
+ if (!root) continue;
187
+ if (root.unit.kind === "definition") add(rootId, root, { kind: "root_definition" });
188
+ root.unit.relations.forEach((relation, relationIndex) => {
189
+ if (!RECALL_RELATION_TYPES.has(relation.type)) return;
190
+ const target = side.byId.get(relation.target);
191
+ if (target.unit.kind !== "definition") return;
192
+ add(rootId, target, { kind: "root_relation", relationIndex });
193
+ });
194
+ }
195
+ return [...anchors.values()].sort((left, right) => compareText(left.key, right.key));
196
+ }
197
+
198
+ function compareOccurrence(left, right) {
199
+ return compareText(left.candidateId, right.candidateId)
200
+ || compareText(left.field.pointer, right.field.pointer);
201
+ }
202
+
203
+ function buildCandidateTextIndex(side, candidates, { terms, patterns }) {
204
+ const fieldsByCandidate = new Map();
205
+ const termOccurrences = new Map();
206
+ for (const afterEntry of candidates) {
207
+ const entry = side.byId.get(afterEntry.unit.id);
208
+ const fields = rawTextFields(entry).map((field) => {
209
+ const indexed = { ...field };
210
+ if (patterns) {
211
+ indexed.normalized = field.raw.normalize("NFKC");
212
+ indexed.symbols = Array.from(indexed.normalized);
213
+ }
214
+ if (terms) {
215
+ indexed.terms = normalizeDiscoveryTerms(field.raw);
216
+ indexed.termSet = new Set(indexed.terms);
217
+ }
218
+ return indexed;
219
+ });
220
+ fieldsByCandidate.set(entry.unit.id, fields);
221
+ if (!terms) continue;
222
+ for (const field of fields) {
223
+ for (const term of field.terms) {
224
+ const occurrences = termOccurrences.get(term) ?? [];
225
+ occurrences.push({ candidateId: entry.unit.id, field });
226
+ termOccurrences.set(term, occurrences);
227
+ }
228
+ }
229
+ }
230
+ for (const occurrences of termOccurrences.values()) occurrences.sort(compareOccurrence);
231
+ return { fieldsByCandidate, termOccurrences };
232
+ }
233
+
234
+ function buildPatternMatcher(anchors, requested) {
235
+ const patterns = new Map();
236
+ function add(channel, anchor, raw, boundary) {
237
+ const normalized = raw.normalize("NFKC");
238
+ const pattern = patterns.get(normalized) ?? {
239
+ normalized,
240
+ symbols: Array.from(normalized),
241
+ records: [],
242
+ };
243
+ pattern.records.push({ channel, anchor, boundary });
244
+ patterns.set(normalized, pattern);
245
+ }
246
+ for (const anchor of anchors) {
247
+ if (requested.has("definition_identity")) {
248
+ add("definition_identity", anchor, anchor.definition.unit.id, IDENTIFIER_TOKEN_CHARACTER);
249
+ }
250
+ if (requested.has("definition_title")) {
251
+ add("definition_title", anchor, anchor.definition.unit.metadata.title, WORD_CHARACTER);
252
+ }
253
+ }
254
+ if (patterns.size === 0) return null;
255
+
256
+ const nodes = [{ next: new Map(), failure: 0, outputs: [] }];
257
+ for (const pattern of [...patterns.values()].sort((left, right) => compareText(left.normalized, right.normalized))) {
258
+ let state = 0;
259
+ for (const symbol of pattern.symbols) {
260
+ let next = nodes[state].next.get(symbol);
261
+ if (next === undefined) {
262
+ next = nodes.length;
263
+ nodes[state].next.set(symbol, next);
264
+ nodes.push({ next: new Map(), failure: 0, outputs: [] });
265
+ }
266
+ state = next;
267
+ }
268
+ nodes[state].outputs.push(pattern.normalized);
269
+ }
270
+
271
+ const pending = [];
272
+ for (const next of nodes[0].next.values()) pending.push(next);
273
+ for (let cursor = 0; cursor < pending.length; cursor += 1) {
274
+ const state = pending[cursor];
275
+ for (const [symbol, next] of nodes[state].next) {
276
+ pending.push(next);
277
+ let failure = nodes[state].failure;
278
+ while (failure !== 0 && !nodes[failure].next.has(symbol)) failure = nodes[failure].failure;
279
+ if (nodes[failure].next.has(symbol)) failure = nodes[failure].next.get(symbol);
280
+ nodes[next].failure = failure;
281
+ nodes[next].outputs = [...new Set([...nodes[next].outputs, ...nodes[failure].outputs])].sort(compareText);
282
+ }
283
+ }
284
+
285
+ return {
286
+ patterns,
287
+ scan(symbols, visit) {
288
+ let state = 0;
289
+ symbols.forEach((symbol, index) => {
290
+ while (state !== 0 && !nodes[state].next.has(symbol)) state = nodes[state].failure;
291
+ if (nodes[state].next.has(symbol)) state = nodes[state].next.get(symbol);
292
+ for (const patternId of nodes[state].outputs) {
293
+ const pattern = patterns.get(patternId);
294
+ const start = index - pattern.symbols.length + 1;
295
+ for (const record of pattern.records) {
296
+ const before = start === 0 ? "" : symbols[start - 1];
297
+ const after = index + 1 === symbols.length ? "" : symbols[index + 1];
298
+ if (!record.boundary.test(before) && !record.boundary.test(after)) {
299
+ visit(record);
300
+ }
301
+ }
302
+ }
303
+ });
304
+ },
305
+ };
306
+ }
307
+
308
+ function buildSideSearch(side, candidates, roots, diffResult, requested) {
309
+ const needsTerms = requested.has("changed_term");
310
+ const needsPatterns = requested.has("definition_identity") || requested.has("definition_title");
311
+ const anchors = needsPatterns ? buildDefinitionAnchors(roots, side) : [];
312
+ return {
313
+ side,
314
+ text: buildCandidateTextIndex(side, candidates, { terms: needsTerms, patterns: needsPatterns }),
315
+ rootLexicalFields: needsTerms ? buildRootLexicalFields(diffResult, side) : [],
316
+ anchors,
317
+ matcher: needsPatterns ? buildPatternMatcher(anchors, requested) : null,
318
+ };
319
+ }
320
+
321
+ function buildContext(diffResult, beforeSnapshot, afterSnapshot, requestedChannels) {
322
+ const sides = {
323
+ before: {
324
+ name: "before",
325
+ ...beforeSnapshot,
326
+ },
327
+ after: {
328
+ name: "after",
329
+ ...afterSnapshot,
330
+ },
331
+ };
332
+ const roots = [...new Set(diffResult.diff.changes.map((change) => change.unitId))].sort(compareText);
333
+ const rootSet = new Set(roots);
334
+ const candidates = [...sides.after.byId.values()]
335
+ .filter(({ unit }) => unit.lifecycle === "active" && !rootSet.has(unit.id))
336
+ .sort((left, right) => compareText(left.unit.id, right.unit.id));
337
+ const candidateSet = new Set(candidates.map(({ unit }) => unit.id));
338
+ const requested = new Set(requestedChannels);
339
+ return {
340
+ diffResult,
341
+ sides,
342
+ roots,
343
+ rootSet,
344
+ candidates,
345
+ candidateSet,
346
+ requestedChannels,
347
+ requested,
348
+ searches: {
349
+ before: buildSideSearch(sides.before, candidates, roots, diffResult, requested),
350
+ after: buildSideSearch(sides.after, candidates, roots, diffResult, requested),
351
+ },
352
+ };
353
+ }
354
+
355
+ function visitOwnerMatches(context, search, visit) {
356
+ const { side } = search;
357
+ for (const rootId of context.roots) {
358
+ const root = side.byId.get(rootId);
359
+ if (!root) continue;
360
+ for (const entry of side.byOwner.get(root.unit.owner) ?? []) {
361
+ if (!context.candidateSet.has(entry.unit.id)) continue;
362
+ visit({ channel: "owner", side, rootId, candidateId: entry.unit.id });
363
+ }
364
+ }
365
+ }
366
+
367
+ function visitScopeMatches(context, search, visit) {
368
+ const { side } = search;
369
+ for (const rootId of context.roots) {
370
+ const root = side.byId.get(rootId);
371
+ for (const value of root?.unit.semantic?.scope ?? []) {
372
+ for (const entry of side.byScope.get(value) ?? []) {
373
+ if (!context.candidateSet.has(entry.unit.id)) continue;
374
+ visit({ channel: "scope", side, rootId, candidateId: entry.unit.id, value });
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ function visitChangedTermMatches(context, search, visit) {
381
+ for (const rootField of search.rootLexicalFields) {
382
+ const matchedFields = new Map();
383
+ for (const term of rootField.terms) {
384
+ for (const occurrence of search.text.termOccurrences.get(term) ?? []) {
385
+ const key = JSON.stringify([occurrence.candidateId, occurrence.field.pointer]);
386
+ if (matchedFields.has(key)) continue;
387
+ matchedFields.set(key, {
388
+ channel: "changed_term",
389
+ side: search.side,
390
+ rootId: rootField.rootId,
391
+ candidateId: occurrence.candidateId,
392
+ rootField,
393
+ candidateField: occurrence.field,
394
+ });
395
+ }
396
+ }
397
+ for (const match of [...matchedFields.values()].sort((left, right) => (
398
+ compareText(left.candidateId, right.candidateId)
399
+ || compareText(left.candidateField.pointer, right.candidateField.pointer)
400
+ ))) visit(match);
401
+ }
402
+ }
403
+
404
+ function visitDirectRelationMatches(context, search, visit) {
405
+ const { side } = search;
406
+ for (const rootId of context.roots) {
407
+ if (!side.byId.has(rootId)) continue;
408
+ for (const edge of side.outgoing.get(rootId) ?? []) {
409
+ if (!RECALL_RELATION_TYPES.has(edge.type) || !context.candidateSet.has(edge.target)) continue;
410
+ visit({ channel: "direct_relation", side, rootId, candidateId: edge.target, direction: "outgoing", edge });
411
+ }
412
+ for (const edge of side.incoming.get(rootId) ?? []) {
413
+ if (!RECALL_RELATION_TYPES.has(edge.type) || !context.candidateSet.has(edge.source)) continue;
414
+ visit({ channel: "direct_relation", side, rootId, candidateId: edge.source, direction: "incoming", edge });
415
+ }
416
+ }
417
+ }
418
+
419
+ function visitDefinitionRelationMatches(context, search, visit) {
420
+ const { side } = search;
421
+ for (const anchor of search.anchors) {
422
+ for (const edge of side.incoming.get(anchor.definition.unit.id) ?? []) {
423
+ if (!RECALL_RELATION_TYPES.has(edge.type) || !context.candidateSet.has(edge.source)) continue;
424
+ visit({
425
+ channel: "definition_identity",
426
+ side,
427
+ rootId: anchor.rootId,
428
+ candidateId: edge.source,
429
+ match: "shared_exact_authored_definition_target",
430
+ anchor,
431
+ edge,
432
+ });
433
+ }
434
+ }
435
+ }
436
+
437
+ function visitDefinitionTextMatches(search, visit) {
438
+ if (search.matcher === null) return;
439
+ for (const [candidateId, fields] of search.text.fieldsByCandidate) {
440
+ for (const field of fields) {
441
+ const matchedPatterns = new Map();
442
+ search.matcher.scan(field.symbols, (record) => {
443
+ const key = JSON.stringify([record.channel, record.anchor.key]);
444
+ if (matchedPatterns.has(key)) return;
445
+ matchedPatterns.set(key, {
446
+ channel: record.channel,
447
+ side: search.side,
448
+ rootId: record.anchor.rootId,
449
+ candidateId,
450
+ match: record.channel === "definition_identity"
451
+ ? "exact_definition_identity_text"
452
+ : "exact_NFKC_case_sensitive_definition_title_phrase",
453
+ anchor: record.anchor,
454
+ candidateField: field,
455
+ });
456
+ });
457
+ for (const match of [...matchedPatterns.values()].sort((left, right) => (
458
+ CHANNEL_RANK.get(left.channel) - CHANNEL_RANK.get(right.channel)
459
+ || compareText(left.anchor.key, right.anchor.key)
460
+ ))) visit(match);
461
+ }
462
+ }
463
+ }
464
+
465
+ function visitMatches(context, visit) {
466
+ for (const sideName of SIDES) {
467
+ const search = context.searches[sideName];
468
+ if (context.requested.has("owner")) visitOwnerMatches(context, search, visit);
469
+ if (context.requested.has("scope")) visitScopeMatches(context, search, visit);
470
+ if (context.requested.has("changed_term")) visitChangedTermMatches(context, search, visit);
471
+ if (context.requested.has("direct_relation")) visitDirectRelationMatches(context, search, visit);
472
+ if (context.requested.has("definition_identity")) visitDefinitionRelationMatches(context, search, visit);
473
+ if (context.requested.has("definition_identity") || context.requested.has("definition_title")) {
474
+ visitDefinitionTextMatches(search, visit);
475
+ }
476
+ }
477
+ }
478
+
479
+ function reasonKey(match) {
480
+ if (match.channel === "owner") return JSON.stringify([
481
+ match.channel, match.side.name, match.rootId, match.candidateId,
482
+ ]);
483
+ if (match.channel === "scope") return JSON.stringify([
484
+ match.channel, match.side.name, match.rootId, match.candidateId, match.value,
485
+ ]);
486
+ if (match.channel === "changed_term") return JSON.stringify([
487
+ match.channel, match.side.name, match.rootField.key, match.candidateId, match.candidateField.pointer,
488
+ ]);
489
+ if (match.channel === "direct_relation") return JSON.stringify([
490
+ match.channel, match.side.name, match.rootId, match.candidateId, match.direction,
491
+ match.edge.source, match.edge.type, match.edge.target,
492
+ ]);
493
+ if (match.match === "shared_exact_authored_definition_target") return JSON.stringify([
494
+ match.channel, match.side.name, match.anchor.key, match.candidateId, match.match,
495
+ match.edge.source, match.edge.type, match.edge.target,
496
+ ]);
497
+ return JSON.stringify([
498
+ match.channel, match.side.name, match.anchor.key, match.candidateId, match.match, match.candidateField.pointer,
499
+ ]);
500
+ }
501
+
502
+ // Stage 1 deliberately records only IDs. It computes the exact union before
503
+ // any SourceMap witness, candidate record, proximity hint, or payload exists.
504
+ function discoverCandidateIds(context) {
505
+ const selected = new Set();
506
+ visitMatches(context, (match) => selected.add(match.candidateId));
507
+ return [...selected].sort(compareText);
508
+ }
509
+
510
+ // Every channel visitor emits one record per unique logical reason. Stage 2
511
+ // can therefore obtain the exact count without retaining keys or witnesses.
512
+ function countUniqueReasons(context) {
513
+ let count = 0;
514
+ visitMatches(context, () => { count += 1; });
515
+ return count;
516
+ }
517
+
518
+ function relationRecord(side, edge) {
519
+ const source = side.byId.get(edge.source);
520
+ const target = side.byId.get(edge.target);
521
+ return {
522
+ source: edge.source,
523
+ type: edge.type,
524
+ target: edge.target,
525
+ sourceLocation: mappedAt(side, source.unitIndex, "/id"),
526
+ relationLocation: mappedAt(side, edge.sourceUnitIndex, `/relations/${edge.relationIndex}/target`),
527
+ targetLocation: mappedAt(side, target.unitIndex, "/id"),
528
+ };
529
+ }
530
+
531
+ function materializeRootReference(side, anchor) {
532
+ const root = side.byId.get(anchor.rootId);
533
+ if (anchor.rootReference.kind === "root_definition") {
534
+ return rawFieldEndpoint(side, root, "id", anchor.rootId, "/id");
535
+ }
536
+ const relationIndex = anchor.rootReference.relationIndex;
537
+ return rawFieldEndpoint(
538
+ side,
539
+ root,
540
+ "relations",
541
+ root.unit.relations[relationIndex],
542
+ `/relations/${relationIndex}/target`,
543
+ );
544
+ }
545
+
546
+ function definitionRecord(side, anchor) {
547
+ return {
548
+ id: anchor.definition.unit.id,
549
+ title: anchor.definition.unit.metadata.title,
550
+ idLocation: mappedAt(side, anchor.definition.unitIndex, "/id"),
551
+ titleLocation: mappedAt(side, anchor.definition.unitIndex, "/metadata/title"),
552
+ };
553
+ }
554
+
555
+ function materializeReason(match) {
556
+ const { side, rootId, candidateId } = match;
557
+ const root = side.byId.get(rootId);
558
+ const candidate = side.byId.get(candidateId);
559
+ if (match.channel === "owner") return {
560
+ channel: match.channel,
561
+ side: side.name,
562
+ rootId,
563
+ witness: {
564
+ match: "exact_owner",
565
+ value: root.unit.owner,
566
+ root: rawFieldEndpoint(side, root, "owner", root.unit.owner, "/owner"),
567
+ candidate: rawFieldEndpoint(side, candidate, "owner", candidate.unit.owner, "/owner"),
568
+ },
569
+ };
570
+ if (match.channel === "scope") {
571
+ const rootIndex = root.unit.semantic.scope.indexOf(match.value);
572
+ const candidateIndex = candidate.unit.semantic.scope.indexOf(match.value);
573
+ return {
574
+ channel: match.channel,
575
+ side: side.name,
576
+ rootId,
577
+ witness: {
578
+ match: "exact_scope",
579
+ value: match.value,
580
+ root: rawFieldEndpoint(side, root, "scope", match.value, `/semantic/scope/${rootIndex}`),
581
+ candidate: rawFieldEndpoint(side, candidate, "scope", match.value, `/semantic/scope/${candidateIndex}`),
582
+ },
583
+ };
584
+ }
585
+ if (match.channel === "changed_term") {
586
+ const terms = match.rootField.terms
587
+ .filter((term) => match.candidateField.termSet.has(term))
588
+ .sort(compareText);
589
+ return {
590
+ channel: match.channel,
591
+ side: side.name,
592
+ rootId,
593
+ witness: {
594
+ match: "exact_normalized_term",
595
+ terms,
596
+ root: {
597
+ field: match.rootField.field,
598
+ raw: match.rootField.raw,
599
+ location: mappedAt(side, root.unitIndex, match.rootField.pointer),
600
+ },
601
+ candidate: {
602
+ field: match.candidateField.field,
603
+ raw: match.candidateField.raw,
604
+ location: mappedAt(side, candidate.unitIndex, match.candidateField.pointer),
605
+ },
606
+ },
607
+ };
608
+ }
609
+ if (match.channel === "direct_relation") return {
610
+ channel: match.channel,
611
+ side: side.name,
612
+ rootId,
613
+ witness: {
614
+ match: "direct_authored_relation",
615
+ rootTraversal: match.direction,
616
+ relation: relationRecord(side, match.edge),
617
+ root: rawFieldEndpoint(side, root, "id", rootId, "/id"),
618
+ candidate: rawFieldEndpoint(side, candidate, "id", candidateId, "/id"),
619
+ },
620
+ };
621
+ const shared = {
622
+ match: match.match,
623
+ definition: definitionRecord(side, match.anchor),
624
+ root: materializeRootReference(side, match.anchor),
625
+ };
626
+ if (match.match === "shared_exact_authored_definition_target") {
627
+ const relation = candidate.unit.relations[match.edge.relationIndex];
628
+ shared.candidate = rawFieldEndpoint(
629
+ side,
630
+ candidate,
631
+ "relations",
632
+ relation,
633
+ `/relations/${match.edge.relationIndex}/target`,
634
+ );
635
+ } else {
636
+ shared.candidate = {
637
+ field: match.candidateField.field,
638
+ raw: match.candidateField.raw,
639
+ location: mappedAt(side, candidate.unitIndex, match.candidateField.pointer),
640
+ };
641
+ }
642
+ return { channel: match.channel, side: side.name, rootId, witness: shared };
643
+ }
644
+
645
+ function candidateRecord(entry, reasons, afterSide) {
646
+ const { unit } = entry;
647
+ return {
648
+ id: unit.id,
649
+ kind: unit.kind,
650
+ owner: unit.owner,
651
+ lifecycle: unit.lifecycle,
652
+ title: unit.metadata.title,
653
+ modality: unit.kind === "rule" ? unit.semantic.modality : null,
654
+ scope: unit.kind === "rule" ? [...unit.semantic.scope] : [],
655
+ rawFields: {
656
+ meaning: unit.kind === "definition" ? unit.semantic.meaning : null,
657
+ statement: unit.kind === "rule" ? unit.semantic.statement : null,
658
+ condition: unit.kind === "rule" ? unit.semantic.condition : null,
659
+ failure: unit.kind === "rule" ? unit.semantic.failure : null,
660
+ },
661
+ relations: unit.relations.map((relation) => ({ ...relation })),
662
+ primaryIdLocation: mappedAt(afterSide, entry.unitIndex, "/id"),
663
+ reasons,
664
+ };
665
+ }
666
+
667
+ // Stage 3 is the only path that materializes SourceMap witnesses, full
668
+ // candidate records, roots, or a product payload.
669
+ function materializeCandidateRecords(context, selectedIds, expectedReasonCount) {
670
+ const selected = new Map(selectedIds.map((id) => [id, new Map()]));
671
+ visitMatches(context, (match) => {
672
+ const reasons = selected.get(match.candidateId);
673
+ const key = reasonKey(match);
674
+ if (!reasons.has(key)) reasons.set(key, materializeReason(match));
675
+ });
676
+ const actualReasonCount = [...selected.values()].reduce((total, reasons) => total + reasons.size, 0);
677
+ if (actualReasonCount !== expectedReasonCount) {
678
+ throw new Error(`change-candidate reason materialization drifted: counted ${expectedReasonCount}, materialized ${actualReasonCount}`);
679
+ }
680
+ return selectedIds.map((id) => {
681
+ const entry = context.sides.after.byId.get(id);
682
+ const reasons = [...selected.get(id).values()].sort(compareReasons);
683
+ return candidateRecord(entry, reasons, context.sides.after);
684
+ });
685
+ }
686
+
687
+ function rootSideRecord(side, rootId) {
688
+ const entry = side.byId.get(rootId);
689
+ if (!entry) return null;
690
+ return {
691
+ kind: entry.unit.kind,
692
+ owner: entry.unit.owner,
693
+ lifecycle: entry.unit.lifecycle,
694
+ title: entry.unit.metadata.title,
695
+ location: mappedAt(side, entry.unitIndex, "/id"),
696
+ };
697
+ }
698
+
699
+ function rootRecords(context) {
700
+ return context.roots.map((id) => ({
701
+ id,
702
+ changes: context.diffResult.diff.changes
703
+ .filter((change) => change.unitId === id)
704
+ .map(({ operation, pointer }) => ({ operation, pointer })),
705
+ before: rootSideRecord(context.sides.before, id),
706
+ after: rootSideRecord(context.sides.after, id),
707
+ }));
708
+ }
709
+
710
+ export function canonicalChangeCandidatesBytes(changeCandidates) {
711
+ return Buffer.byteLength(JSON.stringify(changeCandidates), "utf8");
712
+ }
713
+
714
+ function validateRequest({
715
+ channels,
716
+ maxCandidates,
717
+ maxReasons,
718
+ maxBytes,
719
+ }) {
720
+ const channelValidation = validateChannels(channels);
721
+ if (channelValidation.error) return { failure: refused([channelValidation.error]) };
722
+ if (![maxCandidates, maxReasons, maxBytes].every(validBudget)) return { failure: refused([diagnostic(
723
+ "AUTH_CHANGE_CANDIDATES_BUDGET",
724
+ "change-derived candidate discovery budgets must be positive safe integers",
725
+ "provide explicit positive safe max-candidates, max-reasons, and max-bytes budgets",
726
+ )]) };
727
+ return { channelValidation };
728
+ }
729
+
730
+ function changeCandidatesAuthorityDiffSnapshots(diffResult, beforeSnapshot, afterSnapshot, request) {
731
+ const { channelValidation, maxCandidates, maxReasons, maxBytes } = request;
732
+ if (!diffResult.ok) return refused(diffResult.diagnostics, { fileCount: diffResult.fileCount });
733
+ const context = buildContext(diffResult, beforeSnapshot, afterSnapshot, channelValidation.channels);
734
+
735
+ const selectedIds = discoverCandidateIds(context);
736
+ if (selectedIds.length > maxCandidates) return refused([
737
+ budgetDiagnostic("candidates", selectedIds.length, maxCandidates),
738
+ ], { fileCount: diffResult.fileCount });
739
+
740
+ const reasonCount = countUniqueReasons(context);
741
+ if (reasonCount > maxReasons) return refused([
742
+ budgetDiagnostic("reasons", reasonCount, maxReasons),
743
+ ], { fileCount: diffResult.fileCount });
744
+
745
+ const candidateRecords = materializeCandidateRecords(context, selectedIds, reasonCount);
746
+ const changeCandidates = {
747
+ format: CHANGE_CANDIDATES_FORMAT,
748
+ complete: true,
749
+ requestedChannels: context.requestedChannels,
750
+ roots: rootRecords(context),
751
+ candidates: candidateRecords,
752
+ counts: {
753
+ beforeUnits: context.sides.before.compiled.ir.units.length,
754
+ afterUnits: context.sides.after.compiled.ir.units.length,
755
+ changedRoots: context.roots.length,
756
+ eligibleAfterUnits: context.candidates.length,
757
+ candidates: candidateRecords.length,
758
+ reasons: reasonCount,
759
+ },
760
+ budgets: { maxCandidates, maxReasons, maxBytes },
761
+ policy: {
762
+ roots: "all_units_named_by_the_complete_semantic_diff",
763
+ candidateUniverse: "after_active_non_root_units_only",
764
+ recall: "complete_union_of_requested_channels_only",
765
+ changedTerms: "only_before_removed_and_after_added_normalized_term_set_deltas_from_changed_title_or_normative_text_values",
766
+ ordering: "candidate_ID_then_channel_side_root_and_exact_witness",
767
+ interpretation: "candidate_recall_only_without_conflict_retirement_authority_or_conformance_judgment",
768
+ },
769
+ };
770
+ const candidateBytes = canonicalChangeCandidatesBytes(changeCandidates);
771
+ if (candidateBytes > maxBytes) return refused([
772
+ budgetDiagnostic("UTF-8 bytes", candidateBytes, maxBytes),
773
+ ], { fileCount: diffResult.fileCount, candidateBytes });
774
+ return {
775
+ ok: true,
776
+ diagnostics: [],
777
+ fileCount: diffResult.fileCount,
778
+ unitCount: candidateRecords.length,
779
+ candidateBytes,
780
+ changeCandidates,
781
+ partial: false,
782
+ };
783
+ }
784
+
785
+ export function changeCandidatesAuthoritySnapshots(beforeSnapshot, afterSnapshot, options) {
786
+ const validation = validateRequest(options);
787
+ if (validation.failure) return validation.failure;
788
+ return changeCandidatesAuthorityDiffSnapshots(
789
+ diffAuthoritySnapshots(beforeSnapshot, afterSnapshot),
790
+ beforeSnapshot,
791
+ afterSnapshot,
792
+ { ...options, ...validation },
793
+ );
794
+ }
795
+
796
+ export async function changeCandidatesAuthorityPaths(beforePath, afterPath, options) {
797
+ const validation = validateRequest(options);
798
+ if (validation.failure) return validation.failure;
799
+ const admitted = await diffAuthorityPathsWithSnapshots(beforePath, afterPath);
800
+ if (!admitted.result.ok) return refused(admitted.result.diagnostics, { fileCount: admitted.result.fileCount });
801
+ return changeCandidatesAuthorityDiffSnapshots(
802
+ admitted.result,
803
+ admitted.beforeSnapshot,
804
+ admitted.afterSnapshot,
805
+ { ...options, ...validation },
806
+ );
807
+ }