@nimiplatform/nimi-coding 0.4.0 → 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 (47) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +47 -35
  3. package/README.zh-CN.md +48 -36
  4. package/cli/commands/authority.mjs +80 -125
  5. package/cli/constants.mjs +1 -1
  6. package/cli/dev/check-authority-surface.mjs +17 -8
  7. package/cli/dev/check-product-conformance.mjs +4 -32
  8. package/cli/lib/authority/audit.mjs +262 -185
  9. package/cli/lib/authority/change-candidates.mjs +807 -0
  10. package/cli/lib/authority/closed-sets.mjs +7 -8
  11. package/cli/lib/authority/compile.mjs +6 -24
  12. package/cli/lib/authority/compiled-snapshot.mjs +143 -0
  13. package/cli/lib/authority/consumers.mjs +69 -19
  14. package/cli/lib/authority/diagnostics.mjs +7 -1
  15. package/cli/lib/authority/diff.mjs +77 -54
  16. package/cli/lib/authority/format.mjs +4 -32
  17. package/cli/lib/authority/git-snapshot.mjs +34 -379
  18. package/cli/lib/authority/graph.mjs +10 -10
  19. package/cli/lib/authority/impact.mjs +17 -144
  20. package/cli/lib/authority/review.mjs +33 -43
  21. package/cli/lib/authority/sarif.mjs +1 -8
  22. package/cli/lib/authority/source-yaml.mjs +0 -4
  23. package/cli/lib/authority/surface.mjs +19 -1
  24. package/cli/lib/authority/terms.mjs +10 -9
  25. package/cli/lib/authority/validate.mjs +6 -14
  26. package/cli/lib/entrypoints.mjs +2 -1
  27. package/contracts/authority-source.schema.yaml +0 -6
  28. package/contracts/authority-verifier-bindings.schema.yaml +21 -10
  29. package/methodology/authority-authoring.yaml +9 -26
  30. package/methodology/core.yaml +5 -3
  31. package/package.json +1 -1
  32. package/spec/authority-audit.yaml +56 -52
  33. package/spec/authority-authoring.yaml +3 -16
  34. package/spec/authority-change-candidates.yaml +146 -0
  35. package/spec/authority-consumers.yaml +7 -0
  36. package/spec/authority-discovery.yaml +2 -2
  37. package/spec/authority-graph.yaml +1 -1
  38. package/spec/authority-impact.yaml +16 -39
  39. package/spec/authority-review.yaml +29 -47
  40. package/spec/authority-terms.yaml +5 -1
  41. package/spec/product-scope.yaml +8 -7
  42. package/cli/lib/authority/evidence-snapshot.mjs +0 -1
  43. package/cli/lib/authority/evidence.mjs +0 -1432
  44. package/contracts/authority-evidence-bindings.schema.yaml +0 -53
  45. package/contracts/authority-evidence-probe-results.schema.yaml +0 -38
  46. package/contracts/authority-impact-dispositions.schema.yaml +0 -13
  47. package/spec/authority-evidence.yaml +0 -200
@@ -65,11 +65,6 @@ function refused(diagnostics, { fileCount = 0 } = {}) {
65
65
  };
66
66
  }
67
67
 
68
- function isWithin(root, candidate) {
69
- const relative = path.relative(root, candidate);
70
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
71
- }
72
-
73
68
  function portableRelative(repository, candidate) {
74
69
  return path.relative(repository, candidate).split(path.sep).join(path.posix.sep);
75
70
  }
@@ -540,12 +535,16 @@ export async function closedSetsAuthorityRepository(repositoryPath, specPath, {
540
535
  }
541
536
  const canonical = canonicalGroups(compiled);
542
537
 
538
+ const gitlinks = new Set(inventory.gitlinks);
543
539
  const classifiedFiles = await classifyTrackedTextFiles(
544
540
  inventory.repository,
545
541
  inventory.files,
546
- (file) => file === ".nimi/spec" || file.startsWith(".nimi/spec/")
547
- ? "authority-corpus"
548
- : implementationFileKind(file) === null ? "not-a-scanned-kind" : null,
542
+ (file) => {
543
+ if (gitlinks.has(file)) return "gitlink";
544
+ return file === ".nimi/spec" || file.startsWith(".nimi/spec/")
545
+ ? "authority-corpus"
546
+ : implementationFileKind(file) === null ? "not-a-scanned-kind" : null;
547
+ },
549
548
  );
550
549
  if (classifiedFiles.error !== undefined) {
551
550
  return refused([inputDiagnostic("AUTH_CLOSED_SETS_IMPLEMENTATION_INPUT_INVALID", classifiedFiles.error)], { fileCount: compiled.fileCount });
@@ -1,4 +1,4 @@
1
- import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
1
+ import { compareText } from "./diagnostics.mjs";
2
2
  import { formattingDiagnostics, parseAuthorityPath } from "./format.mjs";
3
3
  import { validateAuthorityScopeBindings } from "./scope-bindings.mjs";
4
4
  import { buildSourceMap } from "./source-map.mjs";
@@ -39,31 +39,13 @@ async function admittedSources(inputPath) {
39
39
  return { ok: true, diagnostics: [], parsed };
40
40
  }
41
41
 
42
- function requiredFormatDiagnostics(documents, requiredFormat) {
43
- if (requiredFormat === null) return [];
44
- return sortDiagnostics(documents.flatMap((document) => {
45
- const found = document.data?.format;
46
- if (found === requiredFormat) return [];
47
- return [makeDiagnostic({
48
- code: "AUTH_FORMAT_REQUIRED",
49
- file: document.file,
50
- range: document.locations.get("/format") ?? document.locations.get("") ?? document.locator.range(0),
51
- pointer: "/format",
52
- reason: `authority check requires format ${requiredFormat}, but ${document.file} declares ${found}`,
53
- repair: "rewrite the container in the explicitly required admitted format without changing product semantics",
54
- })];
55
- }));
56
- }
57
-
58
- export async function checkAuthorityPath(inputPath, { scopeBindings = null, requireFormat = null } = {}) {
42
+ export async function checkAuthorityPath(inputPath, { scopeBindings = null } = {}) {
59
43
  const result = await admittedSources(inputPath);
60
- const formatDiagnostics = requiredFormatDiagnostics(result.parsed.documents, requireFormat);
61
- const diagnostics = sortDiagnostics([...result.diagnostics, ...formatDiagnostics]);
62
- if (!result.ok || formatDiagnostics.length > 0 || scopeBindings === null) return {
63
- ok: result.ok && formatDiagnostics.length === 0,
64
- diagnostics,
44
+ if (!result.ok || scopeBindings === null) return {
45
+ ok: result.ok,
46
+ diagnostics: result.diagnostics,
65
47
  fileCount: result.parsed.files.length,
66
- unitCount: result.ok && formatDiagnostics.length === 0 ? result.parsed.sources.length : 0,
48
+ unitCount: result.ok ? result.parsed.sources.length : 0,
67
49
  };
68
50
  const scopeValidation = await validateAuthorityScopeBindings(result.parsed.sources, scopeBindings);
69
51
  return {
@@ -0,0 +1,143 @@
1
+ import path from "node:path";
2
+
3
+ import { compareText, portablePath } from "./diagnostics.mjs";
4
+
5
+ class ReadonlyIndex {
6
+ #values;
7
+
8
+ constructor(entries) {
9
+ this.#values = new Map(entries);
10
+ Object.freeze(this);
11
+ }
12
+
13
+ get size() {
14
+ return this.#values.size;
15
+ }
16
+
17
+ get(key) {
18
+ return this.#values.get(key);
19
+ }
20
+
21
+ has(key) {
22
+ return this.#values.has(key);
23
+ }
24
+
25
+ *entries() {
26
+ for (const [key, value] of this.#values) yield Object.freeze([key, value]);
27
+ }
28
+
29
+ keys() {
30
+ return this.#values.keys();
31
+ }
32
+
33
+ values() {
34
+ return this.#values.values();
35
+ }
36
+
37
+ forEach(callback, thisArg = undefined) {
38
+ for (const [key, value] of this.#values) callback.call(thisArg, value, key, this);
39
+ }
40
+
41
+ [Symbol.iterator]() {
42
+ return this.entries();
43
+ }
44
+
45
+ get [Symbol.toStringTag]() {
46
+ return "ReadonlyIndex";
47
+ }
48
+ }
49
+
50
+ function freezeTree(value, seen = new Set()) {
51
+ if (value === null || typeof value !== "object" || Object.isFrozen(value) || seen.has(value)) return value;
52
+ seen.add(value);
53
+ for (const key of Reflect.ownKeys(value)) freezeTree(value[key], seen);
54
+ return Object.freeze(value);
55
+ }
56
+
57
+ function readonlyIndex(entries) {
58
+ return new ReadonlyIndex([...entries].sort(([left], [right]) => compareText(left, right)));
59
+ }
60
+
61
+ function groupedIndex(entries, keys) {
62
+ const groups = new Map();
63
+ for (const entry of entries) {
64
+ for (const key of keys(entry)) {
65
+ const values = groups.get(key) ?? [];
66
+ values.push(entry);
67
+ groups.set(key, values);
68
+ }
69
+ }
70
+ return readonlyIndex([...groups.entries()].map(([key, values]) => [
71
+ key,
72
+ Object.freeze(values.sort((left, right) => compareText(left.unit.id, right.unit.id))),
73
+ ]));
74
+ }
75
+
76
+ function compareEdge(left, right) {
77
+ return compareText(left.source, right.source)
78
+ || compareText(left.type, right.type)
79
+ || compareText(left.target, right.target);
80
+ }
81
+
82
+ function adjacencyIndexes(entries) {
83
+ const outgoing = new Map(entries.map((entry) => [entry.unit.id, []]));
84
+ const incoming = new Map(entries.map((entry) => [entry.unit.id, []]));
85
+ for (const entry of entries) {
86
+ entry.unit.relations.forEach((relation, relationIndex) => {
87
+ const edge = Object.freeze({
88
+ source: entry.unit.id,
89
+ type: relation.type,
90
+ target: relation.target,
91
+ sourceUnitIndex: entry.unitIndex,
92
+ relationIndex,
93
+ });
94
+ outgoing.get(edge.source).push(edge);
95
+ incoming.get(edge.target).push(edge);
96
+ });
97
+ }
98
+ const finish = (index) => readonlyIndex([...index.entries()].map(([id, edges]) => [
99
+ id,
100
+ Object.freeze(edges.sort(compareEdge)),
101
+ ]));
102
+ return { outgoing: finish(outgoing), incoming: finish(incoming) };
103
+ }
104
+
105
+ export function createCompiledAuthoritySnapshot(compiled, inputRoot) {
106
+ if (
107
+ compiled?.ok !== true
108
+ || !Array.isArray(compiled?.ir?.units)
109
+ || compiled?.sourceMap?.fields === null
110
+ || typeof compiled?.sourceMap?.fields !== "object"
111
+ ) {
112
+ throw new TypeError("compiled authority snapshot requires one successful compiler result");
113
+ }
114
+ if (typeof inputRoot !== "string" || inputRoot.length === 0) {
115
+ throw new TypeError("compiled authority snapshot requires one non-empty input root");
116
+ }
117
+ const resolvedRoot = path.resolve(inputRoot);
118
+ const entries = compiled.ir.units.map((unit, unitIndex) => {
119
+ const mapped = compiled.sourceMap.fields[`/units/${unitIndex}/id`];
120
+ if (!mapped) throw new TypeError(`compiled authority snapshot cannot locate unit identity: ${unit.id}`);
121
+ return Object.freeze({
122
+ unit,
123
+ unitIndex,
124
+ container: portablePath(mapped.file, resolvedRoot),
125
+ });
126
+ });
127
+ const byId = readonlyIndex(entries.map((entry) => [entry.unit.id, entry]));
128
+ const byOwner = groupedIndex(entries, (entry) => [entry.unit.owner]);
129
+ const byScope = groupedIndex(entries, (entry) => entry.unit.semantic?.scope ?? []);
130
+ const byContainer = groupedIndex(entries, (entry) => [entry.container]);
131
+ const { incoming, outgoing } = adjacencyIndexes(entries);
132
+ freezeTree(compiled);
133
+ return Object.freeze({
134
+ inputRoot: resolvedRoot,
135
+ compiled,
136
+ byId,
137
+ byOwner,
138
+ byScope,
139
+ byContainer,
140
+ incoming,
141
+ outgoing,
142
+ });
143
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import YAML from "yaml";
5
5
 
6
- import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
6
+ import { compareText, isPathWithin, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
7
7
  import { AuthorityReviewRefusal } from "./git-snapshot.mjs";
8
8
  import { resolveRepositoryCorpus } from "./repository-corpus.mjs";
9
9
  import { repositoryInventory } from "./repository-inventory.mjs";
@@ -15,7 +15,10 @@ const CONSUMERS_FORMAT = "nimicoding.authority-consumers/v1";
15
15
  // requiring a trailing segment reported it as no reference at all.
16
16
  // A separator run rather than one character: inside a source string literal a
17
17
  // Windows path is written escaped, so the file text holds ".nimi\\spec".
18
- const SPEC_REFERENCE = /\.nimi[/\\]+spec(?:[/\\]+[A-Za-z0-9_.\\/*<>-]*[A-Za-z0-9_*<>-])?/gu;
18
+ // A single backslash followed by a common source-string escape starter ends a
19
+ // local path instead of creating another path segment. Escaped Windows
20
+ // separators remain visible in source as a run of at least two backslashes.
21
+ const SPEC_REFERENCE = /\.nimi(?:\/+|\\{2,}|\\(?![nrtbfv0xu'"`$]))spec(?:(?:\/+|\\{2,}|\\(?![nrtbfv0xu'"`$]))[A-Za-z0-9_.*<>-]+)*/gu;
19
22
  // A path built from adjacent string literals, e.g. path.join(root, '.nimi',
20
23
  // 'spec', 'runtime', 'x.yaml'). Reconstructed only while every segment is a
21
24
  // literal; the first non-literal segment makes the target unresolved.
@@ -84,11 +87,6 @@ function repositoryDiagnostic(error) {
84
87
  return inputDiagnostic("AUTH_CONSUMERS_REPOSITORY_INVALID", error.message, error.file ?? ".");
85
88
  }
86
89
 
87
- function isWithin(root, candidate) {
88
- const relative = path.relative(root, candidate);
89
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
90
- }
91
-
92
90
  async function resolveCorpus(repository, specPath) {
93
91
  return resolveRepositoryCorpus(repository, specPath, "authority consumer");
94
92
  }
@@ -115,29 +113,66 @@ function lineCounter(text) {
115
113
  };
116
114
  }
117
115
 
116
+ function collapseEscapedLineContinuations(text) {
117
+ const chunks = [];
118
+ const continuations = [];
119
+ let segmentStart = 0;
120
+ let searchFrom = 0;
121
+ let normalizedLength = 0;
122
+ for (let index = text.indexOf("\\", searchFrom); index !== -1; index = text.indexOf("\\", searchFrom)) {
123
+ const width = text[index + 1] === "\n"
124
+ ? 2
125
+ : text[index + 1] === "\r" && text[index + 2] === "\n" ? 3 : 0;
126
+ if (width === 0) {
127
+ searchFrom = index + 1;
128
+ continue;
129
+ }
130
+ const chunk = text.slice(segmentStart, index);
131
+ chunks.push(chunk);
132
+ normalizedLength += chunk.length;
133
+ continuations.push({ offset: normalizedLength, width });
134
+ segmentStart = index + width;
135
+ searchFrom = segmentStart;
136
+ }
137
+ if (continuations.length === 0) return { text, originalOffset: (index) => index };
138
+ chunks.push(text.slice(segmentStart));
139
+ return {
140
+ text: chunks.join(""),
141
+ originalOffset(index) {
142
+ let removedWidth = 0;
143
+ for (const continuation of continuations) {
144
+ if (continuation.offset > index) break;
145
+ removedWidth += continuation.width;
146
+ }
147
+ return index + removedWidth;
148
+ },
149
+ };
150
+ }
151
+
118
152
  function extractReferences(consumer, text) {
153
+ const logical = collapseEscapedLineContinuations(text);
119
154
  const found = [];
120
- for (const match of text.matchAll(SPEC_REFERENCE)) {
155
+ for (const match of logical.text.matchAll(SPEC_REFERENCE)) {
121
156
  const target = match[0].replace(/[/\\]+/gu, "/");
122
157
  if (target.includes("*") || target.includes("<") || target.includes(">")) continue;
123
- found.push({ index: match.index, target, reason: null });
158
+ found.push({ index: logical.originalOffset(match.index), target, reason: null });
124
159
  }
125
- for (const match of text.matchAll(SPEC_CONSTRUCTOR)) {
160
+ for (const match of logical.text.matchAll(SPEC_CONSTRUCTOR)) {
126
161
  const segments = [...match[3].matchAll(CONSTRUCTOR_SEGMENT)].map((segment) => segment[2]);
127
162
  const target = [".nimi", "spec", ...segments].join("/");
128
163
  // A further segment that is not a string literal makes the built path
129
164
  // unknowable here. Reporting it as unresolved keeps the scan from
130
165
  // presenting silence as proof that nothing consumes the corpus.
131
- const continues = CONSTRUCTOR_CONTINUES.test(text.slice(match.index + match[0].length));
132
- found.push({ index: match.index, target, reason: continues ? "non-literal-path-segment" : null });
166
+ const continues = CONSTRUCTOR_CONTINUES.test(logical.text.slice(match.index + match[0].length));
167
+ found.push({ index: logical.originalOffset(match.index), target, reason: continues ? "non-literal-path-segment" : null });
133
168
  }
134
- for (const match of text.matchAll(EXTERNAL_REFERENCE)) {
169
+ for (const match of logical.text.matchAll(EXTERNAL_REFERENCE)) {
135
170
  const externalPath = match[2];
136
171
  const fragment = match[3] ?? null;
137
172
  const coordinate = `${match[1]}:${externalPath}${fragment === null ? "" : `#${fragment}`}`;
138
173
  const nonCanonical = externalPath.split("/").some((segment) => segment === "." || segment === "..");
139
174
  found.push({
140
- index: match.index + match[0].indexOf(`${match[1]}:`),
175
+ index: logical.originalOffset(match.index + match[0].indexOf(`${match[1]}:`)),
141
176
  target: coordinate,
142
177
  scheme: match[1],
143
178
  path: externalPath,
@@ -173,18 +208,28 @@ function targetExists(target, trackedFiles) {
173
208
  // own. Without a root for that scheme the target is unknowable here, which is
174
209
  // not the same claim as dead.
175
210
  function classifyLocalReference(reference, trackedFiles) {
176
- return targetExists(reference.target, trackedFiles) ? "canonical" : "canonicalDead";
211
+ if (targetExists(reference.target, trackedFiles)) return { classification: "canonical", reference };
212
+ if (reference.target.endsWith(".")) {
213
+ const withoutSentencePeriod = reference.target.slice(0, -1);
214
+ if (targetExists(withoutSentencePeriod, trackedFiles)) {
215
+ return {
216
+ classification: "canonical",
217
+ reference: { ...reference, target: withoutSentencePeriod },
218
+ };
219
+ }
220
+ }
221
+ return { classification: "canonicalDead", reference };
177
222
  }
178
223
 
179
224
  async function externalTargetStatus(root, relative) {
180
225
  const candidate = path.resolve(root, ...relative.split("/"));
181
- if (!isWithin(root, candidate)) return "non-canonical";
226
+ if (!isPathWithin(root, candidate)) return "non-canonical";
182
227
 
183
228
  let probe = candidate;
184
229
  while (true) {
185
230
  try {
186
231
  const resolved = await realpath(probe);
187
- if (!isWithin(root, resolved)) return "non-canonical";
232
+ if (!isPathWithin(root, resolved)) return "non-canonical";
188
233
  return probe === candidate ? "authority" : "dead";
189
234
  } catch (error) {
190
235
  if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
@@ -354,10 +399,14 @@ export async function consumersAuthorityRepository(repositoryPath, specPath, { m
354
399
  }
355
400
 
356
401
  const candidates = [...new Set([...inventory.files, "package.json"])].sort(compareText);
402
+ const gitlinks = new Set(inventory.gitlinks);
357
403
  const classifiedFiles = await classifyTrackedTextFiles(
358
404
  inventory.repository,
359
405
  candidates,
360
- (file) => isSpecSource(file) ? "authority-corpus" : null,
406
+ (file) => {
407
+ if (gitlinks.has(file)) return "gitlink";
408
+ return isSpecSource(file) ? "authority-corpus" : null;
409
+ },
361
410
  );
362
411
  if (classifiedFiles.error !== undefined) {
363
412
  return refused([inputDiagnostic("AUTH_CONSUMERS_INPUT_INVALID", classifiedFiles.error)]);
@@ -401,7 +450,8 @@ export async function consumersAuthorityRepository(repositoryPath, specPath, { m
401
450
  try {
402
451
  for (const reference of references) {
403
452
  if (reference.external === undefined) {
404
- classified[classifyLocalReference(reference, trackedFiles)].push(reference);
453
+ const local = classifyLocalReference(reference, trackedFiles);
454
+ classified[local.classification].push(local.reference);
405
455
  continue;
406
456
  }
407
457
  const root = roots.get(reference.external.scheme);
@@ -6,9 +6,15 @@ export function compareText(left, right) {
6
6
  return a === b ? 0 : a < b ? -1 : 1;
7
7
  }
8
8
 
9
+ export function isPathWithin(root, candidate) {
10
+ const relative = path.relative(root, candidate);
11
+ return relative === ""
12
+ || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
13
+ }
14
+
9
15
  export function portablePath(filePath, cwd = process.cwd()) {
10
16
  const relative = path.relative(cwd, filePath);
11
- const value = relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : filePath;
17
+ const value = relative && isPathWithin(cwd, filePath) ? relative : filePath;
12
18
  return value.split(path.sep).join(path.posix.sep);
13
19
  }
14
20
 
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { compileAuthorityPath } from "./compile.mjs";
6
+ import { createCompiledAuthoritySnapshot } from "./compiled-snapshot.mjs";
6
7
  import { compareText, makeDiagnostic, portablePath, sortDiagnostics } from "./diagnostics.mjs";
7
8
 
8
9
  const DIFF_FORMAT = "nimicoding.authority-diff/v1";
@@ -46,14 +47,14 @@ function portableMapping(mapping, basePath) {
46
47
  return { ...mapping, file: portablePath(mapping.file, basePath) };
47
48
  }
48
49
 
49
- function mappedAt(compiled, unitIndex, pointer, basePath) {
50
- const exact = compiled.sourceMap.fields[`/units/${unitIndex}${pointer}`];
51
- if (exact) return portableMapping(exact, basePath);
50
+ function mappedAt(snapshot, unitIndex, pointer) {
51
+ const exact = snapshot.compiled.sourceMap.fields[`/units/${unitIndex}${pointer}`];
52
+ if (exact) return portableMapping(exact, snapshot.inputRoot);
52
53
  const prefix = `/units/${unitIndex}${pointer}/`;
53
- const nested = Object.entries(compiled.sourceMap.fields)
54
+ const nested = Object.entries(snapshot.compiled.sourceMap.fields)
54
55
  .filter(([candidate]) => candidate.startsWith(prefix))
55
56
  .sort(([left], [right]) => compareText(left, right))[0]?.[1];
56
- return portableMapping(nested, basePath);
57
+ return portableMapping(nested, snapshot.inputRoot);
57
58
  }
58
59
 
59
60
  function addChange(changes, { unitId, operation = "modified", pointer, before, after, beforeSource = null, afterSource = null }) {
@@ -68,7 +69,7 @@ function addChange(changes, { unitId, operation = "modified", pointer, before, a
68
69
  });
69
70
  }
70
71
 
71
- function compareScalar(changes, beforeEntry, afterEntry, pointer, beforeBase, afterBase) {
72
+ function compareScalar(changes, beforeSnapshot, afterSnapshot, beforeEntry, afterEntry, pointer) {
72
73
  const before = beforeEntry.unit;
73
74
  const after = afterEntry.unit;
74
75
  const read = (value, parts) => parts.reduce((current, part) => current?.[part], value);
@@ -81,21 +82,21 @@ function compareScalar(changes, beforeEntry, afterEntry, pointer, beforeBase, af
81
82
  pointer,
82
83
  before: left,
83
84
  after: right,
84
- beforeSource: mappedAt(beforeEntry.compiled, beforeEntry.index, pointer, beforeBase),
85
- afterSource: mappedAt(afterEntry.compiled, afterEntry.index, pointer, afterBase),
85
+ beforeSource: mappedAt(beforeSnapshot, beforeEntry.unitIndex, pointer),
86
+ afterSource: mappedAt(afterSnapshot, afterEntry.unitIndex, pointer),
86
87
  });
87
88
  }
88
89
 
89
- function compareValueSet(changes, beforeEntry, afterEntry, pointer, beforeBase, afterBase) {
90
+ function compareValueSet(changes, beforeSnapshot, afterSnapshot, beforeEntry, afterEntry, pointer) {
90
91
  const parts = pointer.slice(1).split("/");
91
92
  const read = (unit) => parts.reduce((current, part) => current?.[part], unit) ?? [];
92
93
  const beforeValues = read(beforeEntry.unit);
93
94
  const afterValues = read(afterEntry.unit);
94
95
  const beforeSet = new Set(beforeValues.map((value) => JSON.stringify(value)));
95
96
  const afterSet = new Set(afterValues.map((value) => JSON.stringify(value)));
96
- for (const [operation, values, otherSet, entry, basePath, sourceSide] of [
97
- ["removed", beforeValues, afterSet, beforeEntry, beforeBase, "beforeSource"],
98
- ["added", afterValues, beforeSet, afterEntry, afterBase, "afterSource"],
97
+ for (const [operation, values, otherSet, snapshot, entry, sourceSide] of [
98
+ ["removed", beforeValues, afterSet, beforeSnapshot, beforeEntry, "beforeSource"],
99
+ ["added", afterValues, beforeSet, afterSnapshot, afterEntry, "afterSource"],
99
100
  ]) {
100
101
  values.forEach((value, index) => {
101
102
  if (otherSet.has(JSON.stringify(value))) return;
@@ -105,20 +106,20 @@ function compareValueSet(changes, beforeEntry, afterEntry, pointer, beforeBase,
105
106
  pointer,
106
107
  before: operation === "removed" ? value : null,
107
108
  after: operation === "added" ? value : null,
108
- [sourceSide]: mappedAt(entry.compiled, entry.index, `${pointer}/${index}`, basePath),
109
+ [sourceSide]: mappedAt(snapshot, entry.unitIndex, `${pointer}/${index}`),
109
110
  });
110
111
  });
111
112
  }
112
113
  }
113
114
 
114
- function compareRelations(changes, beforeEntry, afterEntry, beforeBase, afterBase) {
115
+ function compareRelations(changes, beforeSnapshot, afterSnapshot, beforeEntry, afterEntry) {
115
116
  const beforeRelations = beforeEntry.unit.relations;
116
117
  const afterRelations = afterEntry.unit.relations;
117
118
  const beforeSet = new Set(beforeRelations.map((edge) => `${edge.type}\0${edge.target}`));
118
119
  const afterSet = new Set(afterRelations.map((edge) => `${edge.type}\0${edge.target}`));
119
- for (const [operation, edges, otherSet, entry, basePath, sourceSide] of [
120
- ["removed", beforeRelations, afterSet, beforeEntry, beforeBase, "beforeSource"],
121
- ["added", afterRelations, beforeSet, afterEntry, afterBase, "afterSource"],
120
+ for (const [operation, edges, otherSet, snapshot, entry, sourceSide] of [
121
+ ["removed", beforeRelations, afterSet, beforeSnapshot, beforeEntry, "beforeSource"],
122
+ ["added", afterRelations, beforeSet, afterSnapshot, afterEntry, "afterSource"],
122
123
  ]) {
123
124
  edges.forEach((edge, index) => {
124
125
  if (otherSet.has(`${edge.type}\0${edge.target}`)) return;
@@ -128,23 +129,23 @@ function compareRelations(changes, beforeEntry, afterEntry, beforeBase, afterBas
128
129
  pointer: "/relations",
129
130
  before: operation === "removed" ? edge : null,
130
131
  after: operation === "added" ? edge : null,
131
- [sourceSide]: mappedAt(entry.compiled, entry.index, `/relations/${index}`, basePath),
132
+ [sourceSide]: mappedAt(snapshot, entry.unitIndex, `/relations/${index}`),
132
133
  });
133
134
  });
134
135
  }
135
136
  }
136
137
 
137
- function sideMappedAt(compiled, unitIndex, pointer, basePath, side) {
138
- const mapped = mappedAt(compiled, unitIndex, pointer, basePath);
138
+ function sideMappedAt(snapshot, unitIndex, pointer, side) {
139
+ const mapped = mappedAt(snapshot, unitIndex, pointer);
139
140
  return { ...mapped, file: path.posix.join(side, mapped.file) };
140
141
  }
141
142
 
142
- function transitionDiagnostics(before, after, beforeBase, afterBase) {
143
+ function transitionDiagnostics(before, after) {
143
144
  const diagnostics = [];
144
- const afterById = new Map(after.ir.units.map((unit) => [unit.id, unit]));
145
- before.ir.units.forEach((unit, index) => {
146
- if (afterById.has(unit.id)) return;
147
- const mapped = sideMappedAt(before, index, "/id", beforeBase, "before");
145
+ for (const entry of before.byId.values()) {
146
+ const { unit, unitIndex } = entry;
147
+ if (after.byId.has(unit.id)) continue;
148
+ const mapped = sideMappedAt(before, unitIndex, "/id", "before");
148
149
  diagnostics.push(makeDiagnostic({
149
150
  code: "AUTH_DIFF_TRANSITION_INVALID",
150
151
  file: mapped.file,
@@ -155,12 +156,12 @@ function transitionDiagnostics(before, after, beforeBase, afterBase) {
155
156
  ? "retain the removed tombstone; permanent reserved-ID storage is not admitted"
156
157
  : "replace the active unit with a valid removed tombstone carrying an explicit reason",
157
158
  }));
158
- });
159
- const beforeById = new Map(before.ir.units.map((unit) => [unit.id, unit]));
160
- after.ir.units.forEach((unit, index) => {
161
- const previous = beforeById.get(unit.id);
162
- if (!previous) return;
163
- const mapped = sideMappedAt(after, index, "/id", afterBase, "after");
159
+ }
160
+ for (const entry of after.byId.values()) {
161
+ const { unit, unitIndex } = entry;
162
+ const previous = before.byId.get(unit.id)?.unit;
163
+ if (!previous) continue;
164
+ const mapped = sideMappedAt(after, unitIndex, "/id", "after");
164
165
  if (previous.lifecycle === "removed" && unit.lifecycle !== "removed") diagnostics.push(makeDiagnostic({
165
166
  code: "AUTH_DIFF_TRANSITION_INVALID",
166
167
  file: mapped.file,
@@ -177,18 +178,16 @@ function transitionDiagnostics(before, after, beforeBase, afterBase) {
177
178
  reason: `authority identity cannot change kind from ${previous.kind} to ${unit.kind}: ${unit.id}`,
178
179
  repair: "retain the original identity and declare a distinct ID for the new kind",
179
180
  }));
180
- });
181
+ }
181
182
  return sortDiagnostics(diagnostics);
182
183
  }
183
184
 
184
- function compareUnits(before, after, beforeBase, afterBase) {
185
+ function compareUnits(before, after) {
185
186
  const changes = [];
186
- const beforeById = new Map(before.ir.units.map((unit, index) => [unit.id, { unit, index, compiled: before }]));
187
- const afterById = new Map(after.ir.units.map((unit, index) => [unit.id, { unit, index, compiled: after }]));
188
- const ids = [...new Set([...beforeById.keys(), ...afterById.keys()])].sort(compareText);
187
+ const ids = [...new Set([...before.byId.keys(), ...after.byId.keys()])].sort(compareText);
189
188
  for (const id of ids) {
190
- const left = beforeById.get(id);
191
- const right = afterById.get(id);
189
+ const left = before.byId.get(id);
190
+ const right = after.byId.get(id);
192
191
  if (!left) {
193
192
  addChange(changes, {
194
193
  unitId: id,
@@ -196,16 +195,16 @@ function compareUnits(before, after, beforeBase, afterBase) {
196
195
  pointer: "",
197
196
  before: null,
198
197
  after: right.unit,
199
- afterSource: mappedAt(after, right.index, "/id", afterBase),
198
+ afterSource: mappedAt(after, right.unitIndex, "/id"),
200
199
  });
201
200
  continue;
202
201
  }
203
202
  if (!right) continue;
204
203
  for (const pointer of ["/kind", "/owner", "/lifecycle", "/metadata/title", "/metadata/reason", "/semantic/meaning", "/semantic/modality", "/semantic/statement", "/semantic/condition", "/semantic/failure"]) {
205
- compareScalar(changes, left, right, pointer, beforeBase, afterBase);
204
+ compareScalar(changes, before, after, left, right, pointer);
206
205
  }
207
- compareValueSet(changes, left, right, "/semantic/scope", beforeBase, afterBase);
208
- compareRelations(changes, left, right, beforeBase, afterBase);
206
+ compareValueSet(changes, before, after, left, right, "/semantic/scope");
207
+ compareRelations(changes, before, after, left, right);
209
208
  }
210
209
  return changes.sort((left, right) => (
211
210
  compareText(left.unitId, right.unitId)
@@ -231,7 +230,7 @@ function budgetDiagnostic(requiredBytes, maxBytes) {
231
230
  });
232
231
  }
233
232
 
234
- export async function diffAuthorityPaths(beforePath, afterPath, { maxBytes = null } = {}) {
233
+ export async function diffAuthorityPathsWithSnapshots(beforePath, afterPath, { maxBytes = null } = {}) {
235
234
  const [before, after, beforeBase, afterBase] = await Promise.all([
236
235
  compileAuthorityPath(beforePath),
237
236
  compileAuthorityPath(afterPath),
@@ -239,24 +238,46 @@ export async function diffAuthorityPaths(beforePath, afterPath, { maxBytes = nul
239
238
  sourceRoot(afterPath),
240
239
  ]);
241
240
  if (!before.ok || !after.ok) return {
242
- ok: false,
243
- diagnostics: sortDiagnostics([
244
- ...sideDiagnostics(before.diagnostics, beforeBase, "before"),
245
- ...sideDiagnostics(after.diagnostics, afterBase, "after"),
246
- ]),
247
- fileCount: before.fileCount + after.fileCount,
248
- unitCount: 0,
249
- diff: null,
241
+ beforeSnapshot: null,
242
+ afterSnapshot: null,
243
+ result: {
244
+ ok: false,
245
+ diagnostics: sortDiagnostics([
246
+ ...sideDiagnostics(before.diagnostics, beforeBase, "before"),
247
+ ...sideDiagnostics(after.diagnostics, afterBase, "after"),
248
+ ]),
249
+ fileCount: before.fileCount + after.fileCount,
250
+ unitCount: 0,
251
+ diff: null,
252
+ partial: false,
253
+ },
254
+ };
255
+ const beforeSnapshot = createCompiledAuthoritySnapshot(before, beforeBase);
256
+ const afterSnapshot = createCompiledAuthoritySnapshot(after, afterBase);
257
+ return {
258
+ beforeSnapshot,
259
+ afterSnapshot,
260
+ result: diffAuthoritySnapshots(beforeSnapshot, afterSnapshot, { maxBytes }),
250
261
  };
251
- const invalidTransitions = transitionDiagnostics(before, after, beforeBase, afterBase);
262
+ }
263
+
264
+ export async function diffAuthorityPaths(beforePath, afterPath, options = {}) {
265
+ return (await diffAuthorityPathsWithSnapshots(beforePath, afterPath, options)).result;
266
+ }
267
+
268
+ export function diffAuthoritySnapshots(beforeSnapshot, afterSnapshot, { maxBytes = null } = {}) {
269
+ const before = beforeSnapshot.compiled;
270
+ const after = afterSnapshot.compiled;
271
+ const invalidTransitions = transitionDiagnostics(beforeSnapshot, afterSnapshot);
252
272
  if (invalidTransitions.length > 0) return {
253
273
  ok: false,
254
274
  diagnostics: invalidTransitions,
255
275
  fileCount: before.fileCount + after.fileCount,
256
276
  unitCount: 0,
257
277
  diff: null,
278
+ partial: false,
258
279
  };
259
- const changes = compareUnits(before, after, beforeBase, afterBase);
280
+ const changes = compareUnits(beforeSnapshot, afterSnapshot);
260
281
  const diff = {
261
282
  format: DIFF_FORMAT,
262
283
  changes,
@@ -273,15 +294,17 @@ export async function diffAuthorityPaths(beforePath, afterPath, { maxBytes = nul
273
294
  unitCount: 0,
274
295
  payloadBytes,
275
296
  diff: null,
297
+ partial: false,
276
298
  };
277
299
  return {
278
300
  ok: true,
279
301
  diagnostics: [],
280
302
  fileCount: before.fileCount + after.fileCount,
281
- unitCount: new Set([...before.ir.units, ...after.ir.units].map((unit) => unit.id)).size,
303
+ unitCount: new Set([...beforeSnapshot.byId.keys(), ...afterSnapshot.byId.keys()]).size,
282
304
  payloadBytes,
283
305
  diff,
284
306
  before,
285
307
  after,
308
+ partial: false,
286
309
  };
287
310
  }