@mortice/mapwright 0.8.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 (166) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +262 -0
  4. package/dist/catalogue/canonicalisation.d.ts +36 -0
  5. package/dist/catalogue/canonicalisation.js +262 -0
  6. package/dist/catalogue/config.d.ts +13 -0
  7. package/dist/catalogue/config.js +77 -0
  8. package/dist/catalogue/deterministic.d.ts +3 -0
  9. package/dist/catalogue/deterministic.js +25 -0
  10. package/dist/catalogue/diagnostics.d.ts +2 -0
  11. package/dist/catalogue/diagnostics.js +34 -0
  12. package/dist/catalogue/evidence.d.ts +1 -0
  13. package/dist/catalogue/evidence.js +10 -0
  14. package/dist/catalogue/examples.d.ts +3 -0
  15. package/dist/catalogue/examples.js +20 -0
  16. package/dist/catalogue/identity.d.ts +61 -0
  17. package/dist/catalogue/identity.js +114 -0
  18. package/dist/catalogue/ignore.d.ts +4 -0
  19. package/dist/catalogue/ignore.js +29 -0
  20. package/dist/catalogue/indexer.d.ts +20 -0
  21. package/dist/catalogue/indexer.js +1393 -0
  22. package/dist/catalogue/linkage.d.ts +16 -0
  23. package/dist/catalogue/linkage.js +419 -0
  24. package/dist/catalogue/moduleResolution.d.ts +4 -0
  25. package/dist/catalogue/moduleResolution.js +67 -0
  26. package/dist/catalogue/ordering.d.ts +13 -0
  27. package/dist/catalogue/ordering.js +86 -0
  28. package/dist/catalogue/packages.d.ts +12 -0
  29. package/dist/catalogue/packages.js +475 -0
  30. package/dist/catalogue/pythonReExports.d.ts +7 -0
  31. package/dist/catalogue/pythonReExports.js +113 -0
  32. package/dist/catalogue/resolveRefs.d.ts +40 -0
  33. package/dist/catalogue/resolveRefs.js +284 -0
  34. package/dist/catalogue/scanner.d.ts +8 -0
  35. package/dist/catalogue/scanner.js +99 -0
  36. package/dist/catalogue/schema.d.ts +289 -0
  37. package/dist/catalogue/schema.js +1 -0
  38. package/dist/catalogue/store.d.ts +41 -0
  39. package/dist/catalogue/store.js +209 -0
  40. package/dist/catalogue/templates.d.ts +18 -0
  41. package/dist/catalogue/templates.js +158 -0
  42. package/dist/catalogue/version.d.ts +12 -0
  43. package/dist/catalogue/version.js +30 -0
  44. package/dist/cli/commands/adoptionPlan.d.ts +8 -0
  45. package/dist/cli/commands/adoptionPlan.js +24 -0
  46. package/dist/cli/commands/auditDocs.d.ts +8 -0
  47. package/dist/cli/commands/auditDocs.js +24 -0
  48. package/dist/cli/commands/changeImpact.d.ts +10 -0
  49. package/dist/cli/commands/changeImpact.js +36 -0
  50. package/dist/cli/commands/changes.d.ts +12 -0
  51. package/dist/cli/commands/changes.js +31 -0
  52. package/dist/cli/commands/check.d.ts +9 -0
  53. package/dist/cli/commands/check.js +4 -0
  54. package/dist/cli/commands/compatibility.d.ts +10 -0
  55. package/dist/cli/commands/compatibility.js +43 -0
  56. package/dist/cli/commands/diff.d.ts +31 -0
  57. package/dist/cli/commands/diff.js +118 -0
  58. package/dist/cli/commands/dogfood.d.ts +8 -0
  59. package/dist/cli/commands/dogfood.js +24 -0
  60. package/dist/cli/commands/drift.d.ts +8 -0
  61. package/dist/cli/commands/drift.js +13 -0
  62. package/dist/cli/commands/index.d.ts +9 -0
  63. package/dist/cli/commands/index.js +9 -0
  64. package/dist/cli/commands/indexWorkspace.d.ts +8 -0
  65. package/dist/cli/commands/indexWorkspace.js +23 -0
  66. package/dist/cli/commands/recommendConfig.d.ts +7 -0
  67. package/dist/cli/commands/recommendConfig.js +20 -0
  68. package/dist/cli/commands/schema.d.ts +1 -0
  69. package/dist/cli/commands/schema.js +37 -0
  70. package/dist/cli/commands/template.d.ts +5 -0
  71. package/dist/cli/commands/template.js +44 -0
  72. package/dist/cli/commands/validate.d.ts +9 -0
  73. package/dist/cli/commands/validate.js +85 -0
  74. package/dist/cli/index.d.ts +2 -0
  75. package/dist/cli/index.js +211 -0
  76. package/dist/comparison/artefactValidate.d.ts +19 -0
  77. package/dist/comparison/artefactValidate.js +334 -0
  78. package/dist/comparison/candidates.d.ts +12 -0
  79. package/dist/comparison/candidates.js +391 -0
  80. package/dist/comparison/compare.d.ts +15 -0
  81. package/dist/comparison/compare.js +240 -0
  82. package/dist/comparison/compatibility.d.ts +26 -0
  83. package/dist/comparison/compatibility.js +316 -0
  84. package/dist/comparison/drift.d.ts +34 -0
  85. package/dist/comparison/drift.js +71 -0
  86. package/dist/comparison/identity.d.ts +30 -0
  87. package/dist/comparison/identity.js +73 -0
  88. package/dist/comparison/impact.d.ts +14 -0
  89. package/dist/comparison/impact.js +291 -0
  90. package/dist/comparison/index.d.ts +6 -0
  91. package/dist/comparison/index.js +6 -0
  92. package/dist/comparison/integrity.d.ts +12 -0
  93. package/dist/comparison/integrity.js +199 -0
  94. package/dist/comparison/ordering.d.ts +4 -0
  95. package/dist/comparison/ordering.js +69 -0
  96. package/dist/comparison/projections.d.ts +19 -0
  97. package/dist/comparison/projections.js +185 -0
  98. package/dist/comparison/relationshipPolicy.d.ts +19 -0
  99. package/dist/comparison/relationshipPolicy.js +98 -0
  100. package/dist/comparison/schema.d.ts +178 -0
  101. package/dist/comparison/schema.js +16 -0
  102. package/dist/comparison/store.d.ts +58 -0
  103. package/dist/comparison/store.js +462 -0
  104. package/dist/comparison/validate.d.ts +23 -0
  105. package/dist/comparison/validate.js +137 -0
  106. package/dist/comparison/version.d.ts +9 -0
  107. package/dist/comparison/version.js +11 -0
  108. package/dist/extractors/docTags.d.ts +9 -0
  109. package/dist/extractors/docTags.js +106 -0
  110. package/dist/extractors/generic.d.ts +5 -0
  111. package/dist/extractors/generic.js +3 -0
  112. package/dist/extractors/javascript.d.ts +3 -0
  113. package/dist/extractors/javascript.js +4 -0
  114. package/dist/extractors/markdown.d.ts +7 -0
  115. package/dist/extractors/markdown.js +269 -0
  116. package/dist/extractors/python.d.ts +7 -0
  117. package/dist/extractors/python.js +314 -0
  118. package/dist/extractors/typescript.d.ts +7 -0
  119. package/dist/extractors/typescript.js +387 -0
  120. package/dist/index.d.ts +2 -0
  121. package/dist/index.js +2 -0
  122. package/dist/mcp/resources.d.ts +18 -0
  123. package/dist/mcp/resources.js +57 -0
  124. package/dist/mcp/server.d.ts +2 -0
  125. package/dist/mcp/server.js +95 -0
  126. package/dist/mcp/tools.d.ts +29 -0
  127. package/dist/mcp/tools.js +902 -0
  128. package/dist/queries/adoption.d.ts +62 -0
  129. package/dist/queries/adoption.js +366 -0
  130. package/dist/queries/capabilities.d.ts +45 -0
  131. package/dist/queries/capabilities.js +124 -0
  132. package/dist/queries/changes.d.ts +33 -0
  133. package/dist/queries/changes.js +53 -0
  134. package/dist/queries/dependencies.d.ts +17 -0
  135. package/dist/queries/dependencies.js +94 -0
  136. package/dist/queries/dogfood.d.ts +50 -0
  137. package/dist/queries/dogfood.js +116 -0
  138. package/dist/queries/editingContext.d.ts +84 -0
  139. package/dist/queries/editingContext.js +240 -0
  140. package/dist/queries/examples.d.ts +8 -0
  141. package/dist/queries/examples.js +16 -0
  142. package/dist/queries/files.d.ts +8 -0
  143. package/dist/queries/files.js +14 -0
  144. package/dist/queries/lists.d.ts +94 -0
  145. package/dist/queries/lists.js +184 -0
  146. package/dist/queries/overview.d.ts +2 -0
  147. package/dist/queries/overview.js +8 -0
  148. package/dist/queries/publicApis.d.ts +60 -0
  149. package/dist/queries/publicApis.js +120 -0
  150. package/dist/queries/ranking.d.ts +67 -0
  151. package/dist/queries/ranking.js +387 -0
  152. package/dist/queries/recommendConfig.d.ts +47 -0
  153. package/dist/queries/recommendConfig.js +123 -0
  154. package/dist/queries/relationships.d.ts +5 -0
  155. package/dist/queries/relationships.js +15 -0
  156. package/dist/queries/reuseCandidates.d.ts +34 -0
  157. package/dist/queries/reuseCandidates.js +181 -0
  158. package/dist/queries/symbols.d.ts +40 -0
  159. package/dist/queries/symbols.js +41 -0
  160. package/dist/security/pathPolicy.d.ts +12 -0
  161. package/dist/security/pathPolicy.js +46 -0
  162. package/dist/security/secretPatterns.d.ts +1 -0
  163. package/dist/security/secretPatterns.js +4 -0
  164. package/dist/security/sourceAccess.d.ts +12 -0
  165. package/dist/security/sourceAccess.js +35 -0
  166. package/package.json +60 -0
@@ -0,0 +1,462 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, rmSync, statSync, readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { stableStringify } from "../catalogue/deterministic.js";
5
+ import { COMPARISON_SCHEMA_VERSION, CHANGE_CONTRACT_VERSION, COMPATIBILITY_RULE_VERSION, COMPARISON_IDENTITY_SCHEME, COMPARISON_PROJECTION_VERSION, CANDIDATE_DETECTION_POLICY_VERSION } from "./version.js";
6
+ import { isCanonicalWithinWorkspace, isWithinWorkspace } from "../security/pathPolicy.js";
7
+ import { validateArtefactSemantics } from "./artefactValidate.js";
8
+ export const COMPARISON_OUTPUT_FILES = {
9
+ comparison: "comparison.json",
10
+ changes: "changes.json",
11
+ compatibility: "compatibility.json",
12
+ impactSummary: "impact-summary.json",
13
+ graphIndex: "graph-index.json",
14
+ lock: "comparison.lock.json"
15
+ };
16
+ export const ALLOWED_ARTEFACT_FILENAMES = new Set([
17
+ COMPARISON_OUTPUT_FILES.comparison,
18
+ COMPARISON_OUTPUT_FILES.changes,
19
+ COMPARISON_OUTPUT_FILES.compatibility,
20
+ COMPARISON_OUTPUT_FILES.impactSummary,
21
+ COMPARISON_OUTPUT_FILES.graphIndex
22
+ ]);
23
+ export const MAX_ARTEFACT_FILE_BYTES = 32 * 1024 * 1024;
24
+ export const MAX_ARTEFACT_TOTAL_BYTES = 96 * 1024 * 1024;
25
+ export const MAX_CHANGE_RECORDS = 500_000;
26
+ export const MAX_COMPAT_FINDINGS = 200_000;
27
+ export const MAX_CANDIDATES = 100_000;
28
+ export const MAX_GRAPH_EDGES = 1_000_000;
29
+ export const defaultArtefactFs = {
30
+ mkdirSync,
31
+ writeFileSync: (filePath, content) => writeFileSync(filePath, content, "utf8"),
32
+ existsSync,
33
+ renameSync,
34
+ rmSync
35
+ };
36
+ function sha256Bytes(content) {
37
+ return createHash("sha256").update(content, "utf8").digest("hex");
38
+ }
39
+ function isSafeLockFilename(name) {
40
+ if (!name || typeof name !== "string")
41
+ return false;
42
+ if (name !== path.basename(name))
43
+ return false;
44
+ if (name.includes("..") || name.includes("/") || name.includes("\\"))
45
+ return false;
46
+ if (/^[a-zA-Z]:/.test(name))
47
+ return false;
48
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(name))
49
+ return false;
50
+ return ALLOWED_ARTEFACT_FILENAMES.has(name);
51
+ }
52
+ export function assertComparisonPathContained(workspaceRoot, relativeOrAbsolute) {
53
+ const resolvedWorkspace = path.resolve(workspaceRoot);
54
+ const resolved = path.isAbsolute(relativeOrAbsolute)
55
+ ? path.resolve(relativeOrAbsolute)
56
+ : path.resolve(resolvedWorkspace, relativeOrAbsolute);
57
+ if (!isWithinWorkspace(resolvedWorkspace, resolved)) {
58
+ return {
59
+ code: "comparison_path_rejected",
60
+ message: "Comparison path escapes declared workspace root."
61
+ };
62
+ }
63
+ if (existsSync(resolved) && !isCanonicalWithinWorkspace(resolvedWorkspace, resolved)) {
64
+ return {
65
+ code: "comparison_path_rejected",
66
+ message: "Comparison path is not canonically within workspace root."
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+ function uniqueSibling(outputRoot, label) {
72
+ return `${outputRoot}.${label}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
73
+ }
74
+ /**
75
+ * Transactional / rollback-safe directory replacement.
76
+ * Not claimed as single-syscall filesystem-atomic across platforms.
77
+ */
78
+ export function replaceDirectoryTransactionally(outputRoot, tmpRoot, fsImpl = defaultArtefactFs) {
79
+ const backupRoot = uniqueSibling(outputRoot, "bak");
80
+ let backedUp = false;
81
+ try {
82
+ if (!fsImpl.existsSync(outputRoot)) {
83
+ fsImpl.renameSync(tmpRoot, outputRoot);
84
+ return { ok: true };
85
+ }
86
+ fsImpl.renameSync(outputRoot, backupRoot);
87
+ backedUp = true;
88
+ try {
89
+ fsImpl.renameSync(tmpRoot, outputRoot);
90
+ }
91
+ catch (error) {
92
+ // Rollback: restore backup
93
+ try {
94
+ if (fsImpl.existsSync(outputRoot)) {
95
+ fsImpl.rmSync(outputRoot, { recursive: true, force: true });
96
+ }
97
+ fsImpl.renameSync(backupRoot, outputRoot);
98
+ backedUp = false;
99
+ }
100
+ catch (rollbackError) {
101
+ return {
102
+ ok: false,
103
+ error: {
104
+ code: "comparison_artifact_invalid",
105
+ message: `Failed to install comparison artefact and rollback failed: ${String(error)}; rollback: ${String(rollbackError)}`
106
+ }
107
+ };
108
+ }
109
+ if (fsImpl.existsSync(tmpRoot)) {
110
+ try {
111
+ fsImpl.rmSync(tmpRoot, { recursive: true, force: true });
112
+ }
113
+ catch {
114
+ /* ignore */
115
+ }
116
+ }
117
+ return {
118
+ ok: false,
119
+ error: {
120
+ code: "comparison_artifact_invalid",
121
+ message: `Failed to install comparison artefact; previous artefact restored. ${String(error)}`
122
+ }
123
+ };
124
+ }
125
+ let cleanupWarning;
126
+ try {
127
+ fsImpl.rmSync(backupRoot, { recursive: true, force: true });
128
+ }
129
+ catch (cleanupError) {
130
+ cleanupWarning = `Backup cleanup failed after successful replacement: ${String(cleanupError)}`;
131
+ }
132
+ return cleanupWarning ? { ok: true, cleanupWarning } : { ok: true };
133
+ }
134
+ catch (error) {
135
+ if (fsImpl.existsSync(tmpRoot)) {
136
+ try {
137
+ fsImpl.rmSync(tmpRoot, { recursive: true, force: true });
138
+ }
139
+ catch {
140
+ /* ignore */
141
+ }
142
+ }
143
+ if (backedUp && fsImpl.existsSync(backupRoot) && !fsImpl.existsSync(outputRoot)) {
144
+ try {
145
+ fsImpl.renameSync(backupRoot, outputRoot);
146
+ }
147
+ catch {
148
+ /* ignore */
149
+ }
150
+ }
151
+ return {
152
+ ok: false,
153
+ error: {
154
+ code: "comparison_artifact_invalid",
155
+ message: `Failed transactional artefact replacement: ${String(error)}`
156
+ }
157
+ };
158
+ }
159
+ }
160
+ export function writeComparisonArtefact(outputRoot, comparison, pretty = true, fsImpl = defaultArtefactFs) {
161
+ const tmpRoot = uniqueSibling(outputRoot, "tmp");
162
+ try {
163
+ if (comparison.changes.length > MAX_CHANGE_RECORDS) {
164
+ return {
165
+ ok: false,
166
+ error: {
167
+ code: "comparison_artifact_too_large",
168
+ message: `Change record count ${comparison.changes.length} exceeds limit.`
169
+ }
170
+ };
171
+ }
172
+ if (comparison.compatibilityFindings.length > MAX_COMPAT_FINDINGS) {
173
+ return {
174
+ ok: false,
175
+ error: {
176
+ code: "comparison_artifact_too_large",
177
+ message: `Compatibility finding count exceeds limit.`
178
+ }
179
+ };
180
+ }
181
+ if (comparison.candidates.length > MAX_CANDIDATES) {
182
+ return {
183
+ ok: false,
184
+ error: {
185
+ code: "comparison_artifact_too_large",
186
+ message: `Candidate count exceeds limit.`
187
+ }
188
+ };
189
+ }
190
+ const edgeCount = comparison.graphIndex.base.length + comparison.graphIndex.head.length;
191
+ if (edgeCount > MAX_GRAPH_EDGES) {
192
+ return {
193
+ ok: false,
194
+ error: {
195
+ code: "comparison_artifact_too_large",
196
+ message: `Graph edge count ${edgeCount} exceeds limit.`
197
+ }
198
+ };
199
+ }
200
+ fsImpl.mkdirSync(tmpRoot, { recursive: true });
201
+ const comparisonJson = stableStringify(comparison.manifest, pretty);
202
+ const changesJson = stableStringify({ items: comparison.changes }, pretty);
203
+ const compatibilityJson = stableStringify({
204
+ aggregate: comparison.manifest.aggregateCompatibility,
205
+ findings: comparison.compatibilityFindings,
206
+ candidates: comparison.candidates
207
+ }, pretty);
208
+ const impactSummaryJson = stableStringify({
209
+ note: "Impact is computed from graph-index at query time (Model A).",
210
+ changeIds: comparison.changes.filter((c) => c.status !== "unchanged").map((c) => c.id)
211
+ }, pretty);
212
+ const graphIndexJson = stableStringify(comparison.graphIndex, pretty);
213
+ const files = {
214
+ [COMPARISON_OUTPUT_FILES.comparison]: comparisonJson,
215
+ [COMPARISON_OUTPUT_FILES.changes]: changesJson,
216
+ [COMPARISON_OUTPUT_FILES.compatibility]: compatibilityJson,
217
+ [COMPARISON_OUTPUT_FILES.impactSummary]: impactSummaryJson,
218
+ [COMPARISON_OUTPUT_FILES.graphIndex]: graphIndexJson
219
+ };
220
+ let totalBytes = 0;
221
+ for (const [name, content] of Object.entries(files)) {
222
+ const bytes = Buffer.byteLength(content, "utf8");
223
+ totalBytes += bytes;
224
+ if (bytes > MAX_ARTEFACT_FILE_BYTES) {
225
+ throw Object.assign(new Error(`Artefact file ${name} too large`), { code: "comparison_artifact_too_large" });
226
+ }
227
+ fsImpl.writeFileSync(path.join(tmpRoot, name), content);
228
+ }
229
+ if (totalBytes > MAX_ARTEFACT_TOTAL_BYTES) {
230
+ throw Object.assign(new Error("Total artefact size too large"), { code: "comparison_artifact_too_large" });
231
+ }
232
+ const lock = {
233
+ comparisonSchemaVersion: comparison.manifest.comparisonSchemaVersion,
234
+ comparisonId: comparison.manifest.comparisonId,
235
+ baseCatalogueId: comparison.manifest.baseCatalogueId,
236
+ headCatalogueId: comparison.manifest.headCatalogueId,
237
+ fileHashes: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, sha256Bytes(content)]))
238
+ };
239
+ fsImpl.writeFileSync(path.join(tmpRoot, COMPARISON_OUTPUT_FILES.lock), stableStringify(lock, pretty));
240
+ return replaceDirectoryTransactionally(outputRoot, tmpRoot, fsImpl);
241
+ }
242
+ catch (error) {
243
+ if (fsImpl.existsSync(tmpRoot)) {
244
+ try {
245
+ fsImpl.rmSync(tmpRoot, { recursive: true, force: true });
246
+ }
247
+ catch {
248
+ /* ignore */
249
+ }
250
+ }
251
+ const code = error && typeof error === "object" && "code" in error
252
+ ? String(error.code)
253
+ : "comparison_artifact_invalid";
254
+ return {
255
+ ok: false,
256
+ error: {
257
+ code: (code === "comparison_artifact_too_large"
258
+ ? "comparison_artifact_too_large"
259
+ : "comparison_artifact_invalid"),
260
+ message: `Failed to write comparison artefact: ${String(error)}`
261
+ }
262
+ };
263
+ }
264
+ }
265
+ function validateLoadedComparison(comparison, lockMeta) {
266
+ const m = comparison.manifest;
267
+ if (m.comparisonSchemaVersion !== COMPARISON_SCHEMA_VERSION) {
268
+ return {
269
+ code: "comparison_schema_unsupported",
270
+ message: `Unsupported comparisonSchemaVersion ${String(m.comparisonSchemaVersion)}.`
271
+ };
272
+ }
273
+ if (m.changeContractVersion !== CHANGE_CONTRACT_VERSION) {
274
+ return {
275
+ code: "comparison_artifact_invalid",
276
+ message: `Unsupported changeContractVersion ${String(m.changeContractVersion)}.`
277
+ };
278
+ }
279
+ if (m.compatibilityRuleVersion !== COMPATIBILITY_RULE_VERSION) {
280
+ return {
281
+ code: "comparison_artifact_invalid",
282
+ message: `Unsupported compatibilityRuleVersion ${String(m.compatibilityRuleVersion)}.`
283
+ };
284
+ }
285
+ if (m.comparisonIdentityScheme !== COMPARISON_IDENTITY_SCHEME) {
286
+ return {
287
+ code: "comparison_identity_scheme_unsupported",
288
+ message: `Unsupported comparisonIdentityScheme ${String(m.comparisonIdentityScheme)}.`
289
+ };
290
+ }
291
+ if (m.comparisonProjectionVersion !== COMPARISON_PROJECTION_VERSION) {
292
+ return {
293
+ code: "comparison_artifact_invalid",
294
+ message: `Unsupported comparisonProjectionVersion ${String(m.comparisonProjectionVersion)}.`
295
+ };
296
+ }
297
+ if (m.candidateDetectionPolicyVersion !== CANDIDATE_DETECTION_POLICY_VERSION) {
298
+ return {
299
+ code: "comparison_artifact_invalid",
300
+ message: `Unsupported candidateDetectionPolicyVersion ${String(m.candidateDetectionPolicyVersion)}.`
301
+ };
302
+ }
303
+ if (!m.comparisonId || !m.baseCatalogueId || !m.headCatalogueId) {
304
+ return {
305
+ code: "comparison_artifact_invalid",
306
+ message: "Manifest missing comparisonId or catalogue IDs."
307
+ };
308
+ }
309
+ if (!Array.isArray(comparison.changes) || !Array.isArray(comparison.compatibilityFindings)) {
310
+ return { code: "comparison_artifact_invalid", message: "Changes/findings must be arrays." };
311
+ }
312
+ if (!Array.isArray(comparison.candidates)) {
313
+ return { code: "comparison_artifact_invalid", message: "Candidates must be an array." };
314
+ }
315
+ if (!comparison.graphIndex || !Array.isArray(comparison.graphIndex.base) || !Array.isArray(comparison.graphIndex.head)) {
316
+ return { code: "comparison_artifact_invalid", message: "Malformed graph index." };
317
+ }
318
+ const added = comparison.changes.filter((c) => c.status === "added").length;
319
+ const removed = comparison.changes.filter((c) => c.status === "removed").length;
320
+ const modified = comparison.changes.filter((c) => c.status === "modified").length;
321
+ if (m.counts.added !== added ||
322
+ m.counts.removed !== removed ||
323
+ m.counts.modified !== modified ||
324
+ m.counts.compatibilityFindings !== comparison.compatibilityFindings.length ||
325
+ m.counts.candidates !== comparison.candidates.length) {
326
+ return {
327
+ code: "comparison_artifact_invalid",
328
+ message: "Manifest counts do not match artefact contents."
329
+ };
330
+ }
331
+ return validateArtefactSemantics(comparison, lockMeta);
332
+ }
333
+ export function loadComparisonArtefact(outputRoot) {
334
+ const requiredContent = [
335
+ COMPARISON_OUTPUT_FILES.comparison,
336
+ COMPARISON_OUTPUT_FILES.changes,
337
+ COMPARISON_OUTPUT_FILES.compatibility,
338
+ COMPARISON_OUTPUT_FILES.graphIndex,
339
+ COMPARISON_OUTPUT_FILES.lock
340
+ ];
341
+ for (const file of requiredContent) {
342
+ if (!existsSync(path.join(outputRoot, file))) {
343
+ return {
344
+ ok: false,
345
+ error: {
346
+ code: "comparison_artifact_invalid",
347
+ message: `Missing required comparison artefact file: ${file}`
348
+ }
349
+ };
350
+ }
351
+ }
352
+ try {
353
+ const lockRaw = readFileSync(path.join(outputRoot, COMPARISON_OUTPUT_FILES.lock), "utf8");
354
+ if (Buffer.byteLength(lockRaw, "utf8") > MAX_ARTEFACT_FILE_BYTES) {
355
+ return {
356
+ ok: false,
357
+ error: { code: "comparison_artifact_too_large", message: "Lock file exceeds size limit." }
358
+ };
359
+ }
360
+ const lock = JSON.parse(lockRaw);
361
+ const hashes = lock.fileHashes ?? {};
362
+ const hashKeys = Object.keys(hashes);
363
+ for (const key of hashKeys) {
364
+ if (!isSafeLockFilename(key)) {
365
+ return {
366
+ ok: false,
367
+ error: {
368
+ code: "comparison_path_rejected",
369
+ message: `Lock filename rejected: ${key}`
370
+ }
371
+ };
372
+ }
373
+ }
374
+ for (const required of ALLOWED_ARTEFACT_FILENAMES) {
375
+ if (!(required in hashes)) {
376
+ return {
377
+ ok: false,
378
+ error: {
379
+ code: "comparison_artifact_invalid",
380
+ message: `Missing required lock hash for ${required}`
381
+ }
382
+ };
383
+ }
384
+ }
385
+ if (hashKeys.length !== ALLOWED_ARTEFACT_FILENAMES.size) {
386
+ return {
387
+ ok: false,
388
+ error: {
389
+ code: "comparison_artifact_invalid",
390
+ message: "Lock fileHashes must contain exactly the required artefact filenames."
391
+ }
392
+ };
393
+ }
394
+ let total = 0;
395
+ for (const file of hashKeys) {
396
+ const full = path.join(outputRoot, file);
397
+ const st = statSync(full);
398
+ total += st.size;
399
+ if (st.size > MAX_ARTEFACT_FILE_BYTES) {
400
+ return {
401
+ ok: false,
402
+ error: { code: "comparison_artifact_too_large", message: `Artefact file ${file} too large.` }
403
+ };
404
+ }
405
+ const actual = sha256Bytes(readFileSync(full, "utf8"));
406
+ if (actual !== hashes[file]) {
407
+ return {
408
+ ok: false,
409
+ error: {
410
+ code: "comparison_lock_mismatch",
411
+ message: `Hash mismatch for ${file}.`
412
+ }
413
+ };
414
+ }
415
+ }
416
+ if (total > MAX_ARTEFACT_TOTAL_BYTES) {
417
+ return {
418
+ ok: false,
419
+ error: { code: "comparison_artifact_too_large", message: "Total artefact size too large." }
420
+ };
421
+ }
422
+ const manifest = JSON.parse(readFileSync(path.join(outputRoot, COMPARISON_OUTPUT_FILES.comparison), "utf8"));
423
+ const changes = JSON.parse(readFileSync(path.join(outputRoot, COMPARISON_OUTPUT_FILES.changes), "utf8"));
424
+ const compatibility = JSON.parse(readFileSync(path.join(outputRoot, COMPARISON_OUTPUT_FILES.compatibility), "utf8"));
425
+ const graphIndex = JSON.parse(readFileSync(path.join(outputRoot, COMPARISON_OUTPUT_FILES.graphIndex), "utf8"));
426
+ const comparison = {
427
+ manifest,
428
+ changes: changes.items ?? [],
429
+ compatibilityFindings: compatibility.findings ?? [],
430
+ candidates: compatibility.candidates ?? [],
431
+ graphIndex
432
+ };
433
+ const validated = validateLoadedComparison(comparison, {
434
+ comparisonSchemaVersion: lock.comparisonSchemaVersion,
435
+ comparisonId: lock.comparisonId,
436
+ baseCatalogueId: lock.baseCatalogueId,
437
+ headCatalogueId: lock.headCatalogueId
438
+ });
439
+ if (validated)
440
+ return { ok: false, error: validated };
441
+ return { ok: true, comparison };
442
+ }
443
+ catch (error) {
444
+ return {
445
+ ok: false,
446
+ error: {
447
+ code: "comparison_artifact_invalid",
448
+ message: `Failed to load comparison artefact: ${String(error)}`
449
+ }
450
+ };
451
+ }
452
+ }
453
+ export function removeComparisonArtefact(outputRoot) {
454
+ if (existsSync(outputRoot)) {
455
+ rmSync(outputRoot, { recursive: true, force: true });
456
+ }
457
+ }
458
+ export function listArtefactFiles(outputRoot) {
459
+ if (!existsSync(outputRoot))
460
+ return [];
461
+ return readdirSync(outputRoot);
462
+ }
@@ -0,0 +1,23 @@
1
+ import type { CatalogueSnapshot } from "../catalogue/schema.js";
2
+ import type { ComparisonError } from "./schema.js";
3
+ export type CataloguePairResult = {
4
+ ok: true;
5
+ base: CatalogueSnapshot;
6
+ head: CatalogueSnapshot;
7
+ warnings: string[];
8
+ } | {
9
+ ok: false;
10
+ error: ComparisonError;
11
+ };
12
+ /**
13
+ * Load and validate a base/head catalogue pair from committed directories.
14
+ * Does not index source.
15
+ */
16
+ export declare function loadCataloguePair(input: {
17
+ baseWorkspaceRoot: string;
18
+ baseCatalogueDir: string;
19
+ headWorkspaceRoot: string;
20
+ headCatalogueDir: string;
21
+ }): CataloguePairResult;
22
+ /** Validate an in-memory catalogue pair (already loaded snapshots). */
23
+ export declare function validateCataloguePair(base: CatalogueSnapshot, head: CatalogueSnapshot): CataloguePairResult;
@@ -0,0 +1,137 @@
1
+ import path from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { loadCommittedCatalogue } from "../catalogue/store.js";
4
+ import { isCanonicalWithinWorkspace, isWithinWorkspace } from "../security/pathPolicy.js";
5
+ function mapLoadError(side, result) {
6
+ const code = result.error.code;
7
+ if (code === "committed_catalogue_not_found") {
8
+ return {
9
+ code: side === "base" ? "comparison_base_not_found" : "comparison_head_not_found",
10
+ message: result.error.message
11
+ };
12
+ }
13
+ if (code === "catalogue_upgrade_required" || code === "catalogue_schema_unsupported") {
14
+ return { code: "comparison_schema_unsupported", message: result.error.message };
15
+ }
16
+ if (code === "catalogue_identity_scheme_unsupported") {
17
+ return { code: "comparison_identity_scheme_unsupported", message: result.error.message };
18
+ }
19
+ if (code === "catalogue_lock_mismatch") {
20
+ return { code: "comparison_lock_mismatch", message: result.error.message };
21
+ }
22
+ return { code: "comparison_catalogue_invalid", message: result.error.message };
23
+ }
24
+ function assertCatalogueRootContained(workspaceRoot, catalogueDir, side) {
25
+ const resolvedWorkspace = path.resolve(workspaceRoot);
26
+ const resolvedCatalogue = path.resolve(resolvedWorkspace, catalogueDir);
27
+ if (!isWithinWorkspace(resolvedWorkspace, resolvedCatalogue)) {
28
+ return {
29
+ code: "comparison_path_rejected",
30
+ message: `${side} catalogue path escapes workspace root.`
31
+ };
32
+ }
33
+ if (existsSync(resolvedCatalogue) && !isCanonicalWithinWorkspace(resolvedWorkspace, resolvedCatalogue)) {
34
+ return {
35
+ code: "comparison_path_rejected",
36
+ message: `${side} catalogue path is not canonically within workspace root.`
37
+ };
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * Load and validate a base/head catalogue pair from committed directories.
43
+ * Does not index source.
44
+ */
45
+ export function loadCataloguePair(input) {
46
+ const baseContainment = assertCatalogueRootContained(input.baseWorkspaceRoot, input.baseCatalogueDir, "base");
47
+ if (baseContainment) {
48
+ return { ok: false, error: baseContainment };
49
+ }
50
+ const headContainment = assertCatalogueRootContained(input.headWorkspaceRoot, input.headCatalogueDir, "head");
51
+ if (headContainment) {
52
+ return { ok: false, error: headContainment };
53
+ }
54
+ const baseRoot = path.resolve(input.baseWorkspaceRoot, input.baseCatalogueDir);
55
+ const headRoot = path.resolve(input.headWorkspaceRoot, input.headCatalogueDir);
56
+ if (!existsSync(baseRoot)) {
57
+ return {
58
+ ok: false,
59
+ error: {
60
+ code: "comparison_base_not_found",
61
+ message: `Base catalogue directory not found: ${input.baseCatalogueDir}`
62
+ }
63
+ };
64
+ }
65
+ if (!existsSync(headRoot)) {
66
+ return {
67
+ ok: false,
68
+ error: {
69
+ code: "comparison_head_not_found",
70
+ message: `Head catalogue directory not found: ${input.headCatalogueDir}`
71
+ }
72
+ };
73
+ }
74
+ const baseLoaded = loadCommittedCatalogue(input.baseWorkspaceRoot, input.baseCatalogueDir);
75
+ if (!baseLoaded.ok) {
76
+ return { ok: false, error: mapLoadError("base", baseLoaded) };
77
+ }
78
+ const headLoaded = loadCommittedCatalogue(input.headWorkspaceRoot, input.headCatalogueDir);
79
+ if (!headLoaded.ok) {
80
+ return { ok: false, error: mapLoadError("head", headLoaded) };
81
+ }
82
+ if (baseLoaded.snapshot.identityScheme !== headLoaded.snapshot.identityScheme) {
83
+ return {
84
+ ok: false,
85
+ error: {
86
+ code: "comparison_identity_scheme_mismatch",
87
+ message: `Base identityScheme ${baseLoaded.snapshot.identityScheme} does not match head ${headLoaded.snapshot.identityScheme}.`
88
+ }
89
+ };
90
+ }
91
+ return {
92
+ ok: true,
93
+ base: baseLoaded.snapshot,
94
+ head: headLoaded.snapshot,
95
+ warnings: [...baseLoaded.warnings, ...headLoaded.warnings]
96
+ };
97
+ }
98
+ /** Validate an in-memory catalogue pair (already loaded snapshots). */
99
+ export function validateCataloguePair(base, head) {
100
+ if (base.schemaVersion !== 2 || head.schemaVersion !== 2) {
101
+ return {
102
+ ok: false,
103
+ error: {
104
+ code: "comparison_schema_unsupported",
105
+ message: `Unsupported catalogue schemaVersion (base=${base.schemaVersion}, head=${head.schemaVersion}).`
106
+ }
107
+ };
108
+ }
109
+ if (!base.identityScheme || !head.identityScheme) {
110
+ return {
111
+ ok: false,
112
+ error: {
113
+ code: "comparison_identity_scheme_unsupported",
114
+ message: "Catalogue identityScheme is missing."
115
+ }
116
+ };
117
+ }
118
+ if (base.identityScheme !== head.identityScheme) {
119
+ return {
120
+ ok: false,
121
+ error: {
122
+ code: "comparison_identity_scheme_mismatch",
123
+ message: `Base identityScheme ${base.identityScheme} does not match head ${head.identityScheme}.`
124
+ }
125
+ };
126
+ }
127
+ if (base.identityScheme !== "stable-sha256-v1") {
128
+ return {
129
+ ok: false,
130
+ error: {
131
+ code: "comparison_identity_scheme_unsupported",
132
+ message: `Unsupported identityScheme ${base.identityScheme}.`
133
+ }
134
+ };
135
+ }
136
+ return { ok: true, base, head, warnings: [] };
137
+ }
@@ -0,0 +1,9 @@
1
+ /** Comparison contract versions for Mortice Mapwright v0.8. */
2
+ export declare const COMPARISON_SCHEMA_VERSION: 1;
3
+ export declare const CHANGE_CONTRACT_VERSION: "1";
4
+ export declare const COMPATIBILITY_RULE_VERSION: "1";
5
+ export declare const COMPARISON_IDENTITY_SCHEME: "comparison-sha256-v1";
6
+ export declare const COMPARISON_PROJECTION_VERSION: "1";
7
+ export declare const CANDIDATE_DETECTION_POLICY_VERSION: "1";
8
+ export declare const COMPARISON_NAMESPACE = "code-atlas-comparison";
9
+ export declare const CHANGE_NAMESPACE = "code-atlas-change";
@@ -0,0 +1,11 @@
1
+ /** Comparison contract versions for Mortice Mapwright v0.8. */
2
+ export const COMPARISON_SCHEMA_VERSION = 1;
3
+ export const CHANGE_CONTRACT_VERSION = "1";
4
+ export const COMPATIBILITY_RULE_VERSION = "1";
5
+ export const COMPARISON_IDENTITY_SCHEME = "comparison-sha256-v1";
6
+ export const COMPARISON_PROJECTION_VERSION = "1";
7
+ export const CANDIDATE_DETECTION_POLICY_VERSION = "1";
8
+ // FROZEN: namespace strings inherited from the pre-release Code Atlas era.
9
+ // Do not rename — changing them would change every stable comparison/change ID for existing comparisons.
10
+ export const COMPARISON_NAMESPACE = "code-atlas-comparison";
11
+ export const CHANGE_NAMESPACE = "code-atlas-change";
@@ -0,0 +1,9 @@
1
+ import type { CatalogueDocTag, DiagnosticRecord } from "../catalogue/schema.js";
2
+ export interface ParsedDoc {
3
+ summary: string;
4
+ tags: CatalogueDocTag[];
5
+ diagnostics: DiagnosticRecord[];
6
+ }
7
+ export declare function parseDocTags(text: string, filePath: string): ParsedDoc;
8
+ export declare function firstTag(tags: CatalogueDocTag[], name: string): string | undefined;
9
+ export declare function manyTags(tags: CatalogueDocTag[], name: string): string[];