hoi4-agent-tools 2.1.1 → 2.2.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.
@@ -0,0 +1,368 @@
1
+ import { z } from 'zod/v4';
2
+ import { publicArtifactLink } from '../../core/artifacts.js';
3
+ import { compareCodeUnits, hashCanonical } from '../../core/canonical.js';
4
+ import { emptyServiceResult } from '../../core/result.js';
5
+ import { workspaceIdSchema } from '../../schemas/common.js';
6
+ import { technologyDefectClassSchema, technologyDirectionSchema, technologyGraphReferenceSchema, technologyIdSchema, technologyImpactSchema, technologyProposedSourceSchema, technologyRenderViewSchema, technologyUnlockKindSchema, } from '../../schemas/technology.js';
7
+ import { TechnologyTreeViewer, technologyDiagnostics, } from '../../technology/service.js';
8
+ import { compactValidatedInputSchema } from '../server/context-schemas.js';
9
+ import { nonNegativeIntegerSchema, sha256Schema } from '../server/output-schemas.js';
10
+ import { progressReporter } from '../server/progress.js';
11
+ import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
12
+ const technologyMode = z.enum([
13
+ 'scan',
14
+ 'folders',
15
+ 'trace',
16
+ 'explain',
17
+ 'unlocks',
18
+ 'bonus_coverage',
19
+ 'lint',
20
+ 'impact',
21
+ ]);
22
+ const inspectInput = z
23
+ .object({
24
+ workspaceId: workspaceIdSchema,
25
+ mode: technologyMode,
26
+ folderId: technologyIdSchema.optional(),
27
+ technologyId: technologyIdSchema.optional(),
28
+ categoryId: technologyIdSchema.optional(),
29
+ targetKind: technologyUnlockKindSchema.optional(),
30
+ targetId: technologyIdSchema.optional(),
31
+ direction: technologyDirectionSchema.optional(),
32
+ maxDepth: z.number().int().min(1).max(256).optional(),
33
+ maxNodes: z.number().int().min(1).max(25_000).optional(),
34
+ includeSubTechnologies: z.boolean().optional(),
35
+ classifications: z.array(technologyDefectClassSchema).max(4).optional(),
36
+ codes: z.array(z.string().min(1).max(256)).max(100).optional(),
37
+ impact: compactValidatedInputSchema(technologyImpactSchema, 'Rename or removal subject.').optional(),
38
+ refresh: z.boolean().optional(),
39
+ })
40
+ .strict()
41
+ .superRefine((value, context) => {
42
+ if ((value.mode === 'trace' || value.mode === 'explain') && value.technologyId === undefined)
43
+ context.addIssue({
44
+ code: 'custom',
45
+ path: ['technologyId'],
46
+ message: `${value.mode} requires technologyId`,
47
+ });
48
+ if (value.mode === 'impact' && value.impact === undefined)
49
+ context.addIssue({
50
+ code: 'custom',
51
+ path: ['impact'],
52
+ message: 'Impact mode requires impact',
53
+ });
54
+ });
55
+ const renderInput = z
56
+ .object({
57
+ workspaceId: workspaceIdSchema,
58
+ view: technologyRenderViewSchema.exclude(['comparison']),
59
+ folderId: technologyIdSchema.optional(),
60
+ technologyId: technologyIdSchema.optional(),
61
+ categoryId: technologyIdSchema.optional(),
62
+ targetId: technologyIdSchema.optional(),
63
+ maxNodes: z.number().int().min(1).max(2_000).optional(),
64
+ includeHtml: z.boolean().optional(),
65
+ refresh: z.boolean().optional(),
66
+ })
67
+ .strict()
68
+ .superRefine((value, context) => {
69
+ if (value.view === 'folder' && value.folderId === undefined)
70
+ context.addIssue({
71
+ code: 'custom',
72
+ path: ['folderId'],
73
+ message: 'Folder view requires folderId',
74
+ });
75
+ });
76
+ const compareInput = z
77
+ .object({
78
+ workspaceId: workspaceIdSchema,
79
+ before: compactValidatedInputSchema(technologyGraphReferenceSchema, 'Revision or graph resource.').optional(),
80
+ after: compactValidatedInputSchema(technologyGraphReferenceSchema, 'Revision or graph resource.').optional(),
81
+ proposedSources: z
82
+ .array(compactValidatedInputSchema(technologyProposedSourceSchema, 'In-memory source overlay.'))
83
+ .min(1)
84
+ .max(128)
85
+ .optional(),
86
+ render: z.boolean().optional(),
87
+ maxRenderNodes: z.number().int().min(1).max(2_000).optional(),
88
+ refresh: z.boolean().optional(),
89
+ })
90
+ .strict()
91
+ .superRefine((value, context) => {
92
+ if (value.after !== undefined && value.proposedSources !== undefined)
93
+ context.addIssue({
94
+ code: 'custom',
95
+ path: ['after'],
96
+ message: 'after conflicts with proposedSources',
97
+ });
98
+ });
99
+ const countsSchema = z
100
+ .object({
101
+ technologies: nonNegativeIntegerSchema,
102
+ legacyDoctrines: nonNegativeIntegerSchema,
103
+ folders: nonNegativeIntegerSchema,
104
+ placements: nonNegativeIntegerSchema,
105
+ edges: nonNegativeIntegerSchema,
106
+ unlocks: nonNegativeIntegerSchema,
107
+ references: nonNegativeIntegerSchema,
108
+ issues: nonNegativeIntegerSchema,
109
+ unresolved: nonNegativeIntegerSchema,
110
+ artifacts: nonNegativeIntegerSchema,
111
+ })
112
+ .strict();
113
+ const analysisOutput = strictOperationResultSchema(z
114
+ .object({
115
+ mode: technologyMode,
116
+ revision: sha256Schema,
117
+ graphHash: sha256Schema,
118
+ counts: countsSchema,
119
+ })
120
+ .strict());
121
+ const renderOutput = strictOperationResultSchema(z
122
+ .object({
123
+ view: technologyRenderViewSchema,
124
+ revision: sha256Schema,
125
+ graphHash: sha256Schema,
126
+ hashes: z
127
+ .object({
128
+ json: sha256Schema,
129
+ svg: sha256Schema,
130
+ png: sha256Schema,
131
+ html: sha256Schema.optional(),
132
+ })
133
+ .strict(),
134
+ selectedNodes: nonNegativeIntegerSchema,
135
+ omittedNodes: nonNegativeIntegerSchema,
136
+ focusedRenders: nonNegativeIntegerSchema,
137
+ sourceAccurate: z.boolean(),
138
+ })
139
+ .strict());
140
+ const compareOutput = strictOperationResultSchema(z
141
+ .object({
142
+ beforeRevision: sha256Schema,
143
+ afterRevision: sha256Schema,
144
+ added: nonNegativeIntegerSchema,
145
+ removed: nonNegativeIntegerSchema,
146
+ renamed: nonNegativeIntegerSchema,
147
+ moved: nonNegativeIntegerSchema,
148
+ regressions: nonNegativeIntegerSchema,
149
+ artifacts: nonNegativeIntegerSchema,
150
+ renderHashes: z
151
+ .object({ json: sha256Schema, svg: sha256Schema, png: sha256Schema })
152
+ .strict()
153
+ .optional(),
154
+ })
155
+ .strict());
156
+ const readOnly = {
157
+ readOnlyHint: true,
158
+ destructiveHint: false,
159
+ idempotentHint: true,
160
+ openWorldHint: false,
161
+ };
162
+ function graphHash(graph) {
163
+ return hashCanonical({
164
+ schemaVersion: graph.schemaVersion,
165
+ workspaceIdentity: graph.workspaceIdentity,
166
+ revision: graph.revision,
167
+ statistics: graph.statistics,
168
+ sourceHashes: graph.sourceHashes,
169
+ });
170
+ }
171
+ function counts(graph, artifacts) {
172
+ return {
173
+ technologies: graph.statistics.technologyCount,
174
+ legacyDoctrines: graph.statistics.legacyDoctrineCount,
175
+ folders: graph.statistics.folderCount,
176
+ placements: graph.statistics.placementCount,
177
+ edges: graph.edges.length,
178
+ unlocks: graph.statistics.unlockCount,
179
+ references: graph.statistics.externalReferenceCount,
180
+ issues: graph.statistics.issueCount,
181
+ unresolved: graph.statistics.unresolvedCount,
182
+ artifacts,
183
+ };
184
+ }
185
+ function validation(graph) {
186
+ const blocking = technologyDiagnostics(graph).filter(({ severity }) => severity === 'error' || severity === 'blocker').length;
187
+ return {
188
+ passed: graph.complete && blocking === 0,
189
+ checks: [
190
+ {
191
+ id: 'technology-analysis',
192
+ passed: graph.complete && blocking === 0,
193
+ message: graph.complete
194
+ ? `${blocking} blocking technology diagnostics; full evidence is linked`
195
+ : `${graph.skippedSourceCount} source(s) were skipped; full evidence is linked`,
196
+ },
197
+ ],
198
+ };
199
+ }
200
+ function analysisRequest(input, workspaceId, context, signal) {
201
+ const impact = input.impact === undefined
202
+ ? undefined
203
+ : {
204
+ kind: input.impact.kind,
205
+ id: input.impact.id,
206
+ operation: input.impact.operation,
207
+ ...(input.impact.replacementId === undefined
208
+ ? {}
209
+ : { replacementId: input.impact.replacementId }),
210
+ };
211
+ const values = {
212
+ folderId: input.folderId,
213
+ technologyId: input.technologyId,
214
+ categoryId: input.categoryId,
215
+ targetKind: input.targetKind,
216
+ targetId: input.targetId,
217
+ direction: input.direction,
218
+ maxDepth: input.maxDepth,
219
+ maxNodes: input.maxNodes,
220
+ includeSubTechnologies: input.includeSubTechnologies,
221
+ classifications: input.classifications,
222
+ codes: input.codes,
223
+ impact,
224
+ refresh: input.refresh,
225
+ };
226
+ const compact = Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
227
+ return {
228
+ workspaceId,
229
+ mode: input.mode,
230
+ ...compact,
231
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
232
+ signal,
233
+ };
234
+ }
235
+ export function registerTechnologyTools(server, engine, context) {
236
+ const viewer = new TechnologyTreeViewer(engine);
237
+ server.registerTool('hoi4.tech_inspect', {
238
+ title: 'Inspect technology trees',
239
+ description: 'Scan, discover folders, trace, explain, inspect unlocks or bonuses, lint, and assess impact.',
240
+ inputSchema: inspectInput,
241
+ outputSchema: analysisOutput,
242
+ annotations: readOnly,
243
+ }, async (input, extra) => {
244
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
245
+ try {
246
+ const progress = progressReporter(extra);
247
+ await progress.report(0, 2, 'Analyzing technologies');
248
+ const output = await viewer.analyze(analysisRequest(input, workspaceId, context, progress.signal));
249
+ const result = emptyServiceResult(workspaceId, {
250
+ mode: input.mode,
251
+ revision: output.graph.revision,
252
+ graphHash: graphHash(output.graph),
253
+ counts: counts(output.graph, output.artifacts.length),
254
+ });
255
+ result.code = output.graph.complete ? 'TECH_INSPECTED' : 'TECH_INSPECTED_PARTIAL';
256
+ setInlineFilesScanned(result, output.graph.filesScanned);
257
+ result.artifacts = output.artifacts.map(publicArtifactLink);
258
+ result.validation = validation(output.graph);
259
+ await progress.report(2, 2, 'Technology analysis complete');
260
+ return toolResult(result);
261
+ }
262
+ catch (error) {
263
+ return errorResult(error, workspaceId);
264
+ }
265
+ });
266
+ server.registerTool('hoi4.tech_render', {
267
+ title: 'Render technology trees',
268
+ description: 'Render source-linked JSON, SVG, PNG, and optional HTML technology views.',
269
+ inputSchema: renderInput,
270
+ outputSchema: renderOutput,
271
+ annotations: readOnly,
272
+ }, async (input, extra) => {
273
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
274
+ try {
275
+ const progress = progressReporter(extra);
276
+ const request = {
277
+ workspaceId,
278
+ view: input.view,
279
+ ...(input.folderId === undefined ? {} : { folderId: input.folderId }),
280
+ ...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
281
+ ...(input.categoryId === undefined ? {} : { categoryId: input.categoryId }),
282
+ ...(input.targetId === undefined ? {} : { targetId: input.targetId }),
283
+ ...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
284
+ ...(input.includeHtml === undefined ? {} : { includeHtml: input.includeHtml }),
285
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
286
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
287
+ signal: progress.signal,
288
+ };
289
+ const output = await viewer.renderAndStore(request);
290
+ const result = emptyServiceResult(workspaceId, {
291
+ view: output.render.view,
292
+ revision: output.graph.revision,
293
+ graphHash: graphHash(output.graph),
294
+ hashes: output.render.hashes,
295
+ selectedNodes: output.render.selectedIds.length,
296
+ omittedNodes: output.render.omittedNodeCount,
297
+ focusedRenders: output.focused.length,
298
+ sourceAccurate: output.render.sourceAccurate,
299
+ });
300
+ result.code = output.graph.complete ? 'TECH_RENDERED' : 'TECH_RENDERED_PARTIAL';
301
+ setInlineFilesScanned(result, output.graph.filesScanned);
302
+ result.artifacts = output.artifacts.map(publicArtifactLink);
303
+ result.validation = validation(output.graph);
304
+ return toolResult(result);
305
+ }
306
+ catch (error) {
307
+ return errorResult(error, workspaceId);
308
+ }
309
+ });
310
+ server.registerTool('hoi4.tech_compare', {
311
+ title: 'Compare technology trees',
312
+ description: 'Compare cached, resource-backed, current, or proposed source graphs without writes.',
313
+ inputSchema: compareInput,
314
+ outputSchema: compareOutput,
315
+ annotations: readOnly,
316
+ }, async (input, extra) => {
317
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
318
+ try {
319
+ const progress = progressReporter(extra);
320
+ const request = {
321
+ workspaceId,
322
+ ...(input.before === undefined
323
+ ? {}
324
+ : { before: input.before }),
325
+ ...(input.after === undefined
326
+ ? {}
327
+ : { after: input.after }),
328
+ ...(input.proposedSources === undefined
329
+ ? {}
330
+ : {
331
+ proposedSources: input.proposedSources,
332
+ }),
333
+ ...(input.render === undefined ? {} : { render: input.render }),
334
+ ...(input.maxRenderNodes === undefined ? {} : { maxRenderNodes: input.maxRenderNodes }),
335
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
336
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
337
+ signal: progress.signal,
338
+ };
339
+ const output = await viewer.compareAndStore(request);
340
+ const comparison = output.comparison;
341
+ const result = emptyServiceResult(workspaceId, {
342
+ beforeRevision: comparison.beforeRevision,
343
+ afterRevision: comparison.afterRevision,
344
+ added: comparison.technologies.added.length,
345
+ removed: comparison.technologies.removed.length,
346
+ renamed: comparison.technologies.renamed.length,
347
+ moved: comparison.technologies.moved.length,
348
+ regressions: comparison.regressions.length,
349
+ artifacts: output.artifacts.length,
350
+ ...(output.render === undefined ? {} : { renderHashes: output.render.hashes }),
351
+ });
352
+ result.code =
353
+ output.before.complete && output.after.complete
354
+ ? 'TECH_COMPARED'
355
+ : 'TECH_COMPARED_PARTIAL';
356
+ setInlineFilesScanned(result, [...new Set([...output.before.filesScanned, ...output.after.filesScanned])].sort(compareCodeUnits));
357
+ result.artifacts = output.artifacts.map(publicArtifactLink);
358
+ result.validation = {
359
+ passed: validation(output.before).passed && validation(output.after).passed,
360
+ checks: [...validation(output.before).checks, ...validation(output.after).checks],
361
+ };
362
+ return toolResult(result);
363
+ }
364
+ catch (error) {
365
+ return errorResult(error, workspaceId);
366
+ }
367
+ });
368
+ }
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod/v4';
2
+ import { SOURCE_MAX_BYTES } from '../core/source/index.js';
3
+ import { workspaceRelativePathSchema } from './common.js';
4
+ export const technologyIdSchema = z.string().min(1).max(512);
5
+ export const technologyDirectionSchema = z.enum(['prerequisites', 'descendants', 'both']);
6
+ export const technologyUnlockKindSchema = z.enum([
7
+ 'equipment',
8
+ 'equipment_module',
9
+ 'sub_unit',
10
+ 'building',
11
+ 'ability',
12
+ 'tactic',
13
+ 'other',
14
+ ]);
15
+ export const technologyDefectClassSchema = z.enum([
16
+ 'confirmed_error',
17
+ 'probable_defect',
18
+ 'design_warning',
19
+ 'unresolved_analysis',
20
+ ]);
21
+ export const technologyRenderViewSchema = z.enum([
22
+ 'summary',
23
+ 'folder',
24
+ 'dependencies',
25
+ 'technology',
26
+ 'doctrine',
27
+ 'exclusive',
28
+ 'memberships',
29
+ 'bonuses',
30
+ 'grants',
31
+ 'unlocks',
32
+ 'metadata',
33
+ 'assets',
34
+ 'unresolved',
35
+ 'comparison',
36
+ ]);
37
+ export const technologyImpactSchema = z
38
+ .object({
39
+ kind: z.enum(['technology', 'category', 'folder', 'unlock_target']),
40
+ id: technologyIdSchema,
41
+ operation: z.enum(['remove', 'rename']),
42
+ replacementId: technologyIdSchema.optional(),
43
+ })
44
+ .strict()
45
+ .superRefine((value, context) => {
46
+ if (value.operation === 'rename' && value.replacementId === undefined)
47
+ context.addIssue({
48
+ code: 'custom',
49
+ path: ['replacementId'],
50
+ message: 'Rename requires replacementId',
51
+ });
52
+ });
53
+ export const technologyGraphReferenceSchema = z
54
+ .object({
55
+ revision: z
56
+ .string()
57
+ .regex(/^[a-f0-9]{64}$/u)
58
+ .optional(),
59
+ artifactUri: z
60
+ .string()
61
+ .min(1)
62
+ .max(8_192)
63
+ .regex(/^hoi4-agent:\/\//u)
64
+ .optional(),
65
+ })
66
+ .strict()
67
+ .refine(({ revision, artifactUri }) => Number(revision !== undefined) + Number(artifactUri !== undefined) === 1, 'Provide exactly one revision or artifact URI');
68
+ export const technologyProposedSourceSchema = z
69
+ .object({
70
+ relativePath: workspaceRelativePathSchema,
71
+ source: z.string().max(SOURCE_MAX_BYTES).nullable(),
72
+ expectedSourceHash: z
73
+ .string()
74
+ .regex(/^[a-f0-9]{64}$/u)
75
+ .optional(),
76
+ })
77
+ .strict();
@@ -0,0 +1,240 @@
1
+ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
2
+ function byId(values) {
3
+ return new Map(values.map((value) => [value.id, value]));
4
+ }
5
+ function placementSignature(values) {
6
+ return hashCanonical(values
7
+ .map(({ folderId, x, y, xExpression, yExpression }) => ({
8
+ folderId,
9
+ x: x ?? null,
10
+ y: y ?? null,
11
+ xExpression: xExpression ?? null,
12
+ yExpression: yExpression ?? null,
13
+ }))
14
+ .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
15
+ (left.x ?? 0) - (right.x ?? 0) ||
16
+ (left.y ?? 0) - (right.y ?? 0)));
17
+ }
18
+ function edgeKey(edge) {
19
+ return hashCanonical({
20
+ kind: edge.kind,
21
+ from: edge.from,
22
+ to: edge.to,
23
+ coefficient: edge.coefficient ?? null,
24
+ ignoreForLayout: edge.ignoreForLayout ?? null,
25
+ });
26
+ }
27
+ function unlockKey(unlock) {
28
+ return hashCanonical({
29
+ technologyId: unlock.technologyId,
30
+ kind: unlock.kind,
31
+ targetId: unlock.targetId,
32
+ level: unlock.level ?? null,
33
+ });
34
+ }
35
+ function externalKey(reference) {
36
+ return hashCanonical({
37
+ kind: reference.kind,
38
+ sourceKind: reference.sourceKind,
39
+ sourceId: reference.sourceId,
40
+ technologyId: reference.technologyId ?? null,
41
+ categoryId: reference.categoryId ?? null,
42
+ helperStack: reference.helperStack,
43
+ expression: reference.expression,
44
+ });
45
+ }
46
+ function issueKey(issue) {
47
+ return hashCanonical({
48
+ code: issue.code,
49
+ classification: issue.classification,
50
+ details: issue.details,
51
+ });
52
+ }
53
+ function difference(before, after, key) {
54
+ const beforeKeys = new Set(before.map(key));
55
+ const afterKeys = new Set(after.map(key));
56
+ return {
57
+ added: after.filter((value) => !beforeKeys.has(key(value))),
58
+ removed: before.filter((value) => !afterKeys.has(key(value))),
59
+ };
60
+ }
61
+ function reachable(graph, signal) {
62
+ const incoming = new Map();
63
+ const outgoing = new Map();
64
+ for (const technology of graph.technologies) {
65
+ incoming.set(technology.id, 0);
66
+ outgoing.set(technology.id, []);
67
+ }
68
+ for (const edge of graph.edges.filter(({ kind }) => kind === 'prerequisite')) {
69
+ if (!incoming.has(edge.from) || !incoming.has(edge.to))
70
+ continue;
71
+ incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
72
+ outgoing.get(edge.from).push(edge.to);
73
+ }
74
+ const result = new Set();
75
+ const pending = graph.technologies
76
+ .filter(({ id, hidden }) => hidden !== true && (incoming.get(id) ?? 0) === 0)
77
+ .map(({ id }) => id);
78
+ while (pending.length > 0) {
79
+ signal?.throwIfAborted();
80
+ const current = pending.pop();
81
+ if (result.has(current))
82
+ continue;
83
+ result.add(current);
84
+ pending.push(...(outgoing.get(current) ?? []));
85
+ }
86
+ return result;
87
+ }
88
+ function technologyMetadata(technology) {
89
+ return {
90
+ kind: technology.kind,
91
+ startYear: technology.startYear ?? null,
92
+ researchCost: technology.researchCost ?? null,
93
+ doctrineName: technology.doctrineName ?? null,
94
+ hidden: technology.hidden,
95
+ folders: technology.folders,
96
+ categories: technology.categories,
97
+ tags: technology.tags,
98
+ subTechnologies: technology.subTechnologies,
99
+ ai: technology.ai,
100
+ effectSignature: technology.effectSignature,
101
+ localisation: technology.localisation,
102
+ icon: technology.icon,
103
+ };
104
+ }
105
+ function renameCandidates(removed, added, beforePlacements, afterPlacements, signal) {
106
+ const candidates = [];
107
+ const claimed = new Set();
108
+ for (const before of removed) {
109
+ signal?.throwIfAborted();
110
+ const matches = added
111
+ .filter((after) => !claimed.has(after.id))
112
+ .map((after) => {
113
+ const evidence = [];
114
+ if (before.effectSignature === after.effectSignature)
115
+ evidence.push('identical effect and unlock signature');
116
+ if (before.startYear === after.startYear && before.researchCost === after.researchCost)
117
+ evidence.push('identical year and research cost');
118
+ if (placementSignature(beforePlacements.filter(({ technologyId }) => technologyId === before.id)) ===
119
+ placementSignature(afterPlacements.filter(({ technologyId }) => technologyId === after.id)))
120
+ evidence.push('identical folder placements');
121
+ if (before.categories.join('\0') === after.categories.join('\0'))
122
+ evidence.push('identical category membership');
123
+ if (before.source.path === after.source.path)
124
+ evidence.push('same source file');
125
+ return { after, evidence };
126
+ })
127
+ .filter(({ evidence }) => evidence.length >= 4)
128
+ .sort((left, right) => right.evidence.length - left.evidence.length ||
129
+ compareCodeUnits(left.after.id, right.after.id));
130
+ if (matches.length !== 1)
131
+ continue;
132
+ const match = matches[0];
133
+ claimed.add(match.after.id);
134
+ candidates.push({
135
+ beforeId: before.id,
136
+ afterId: match.after.id,
137
+ confidence: match.evidence.length >= 5 ? 'high' : 'medium',
138
+ evidence: match.evidence,
139
+ });
140
+ }
141
+ return candidates.sort((left, right) => compareCodeUnits(left.beforeId, right.beforeId));
142
+ }
143
+ export function compareTechnologyGraphs(before, after, signal) {
144
+ signal?.throwIfAborted();
145
+ const beforeTech = byId(before.technologies);
146
+ const afterTech = byId(after.technologies);
147
+ const addedDefinitions = after.technologies.filter(({ id }) => !beforeTech.has(id));
148
+ const removedDefinitions = before.technologies.filter(({ id }) => !afterTech.has(id));
149
+ const added = addedDefinitions.map(({ id }) => id).sort(compareCodeUnits);
150
+ const removed = removedDefinitions.map(({ id }) => id).sort(compareCodeUnits);
151
+ const moved = [];
152
+ const metadataChanged = [];
153
+ for (const [technologyId, beforeDefinition] of beforeTech) {
154
+ signal?.throwIfAborted();
155
+ const afterDefinition = afterTech.get(technologyId);
156
+ if (afterDefinition === undefined)
157
+ continue;
158
+ const beforePlacement = before.placements.filter(({ technologyId: id }) => id === technologyId);
159
+ const afterPlacement = after.placements.filter(({ technologyId: id }) => id === technologyId);
160
+ if (placementSignature(beforePlacement) !== placementSignature(afterPlacement))
161
+ moved.push({ technologyId, before: beforePlacement, after: afterPlacement });
162
+ const beforeMetadata = technologyMetadata(beforeDefinition);
163
+ const afterMetadata = technologyMetadata(afterDefinition);
164
+ const fields = Object.keys(beforeMetadata)
165
+ .filter((field) => hashCanonical(beforeMetadata[field]) !== hashCanonical(afterMetadata[field]))
166
+ .sort(compareCodeUnits);
167
+ if (fields.length > 0)
168
+ metadataChanged.push({
169
+ technologyId,
170
+ fields,
171
+ before: Object.fromEntries(fields.map((field) => [field, beforeMetadata[field]])),
172
+ after: Object.fromEntries(fields.map((field) => [field, afterMetadata[field]])),
173
+ });
174
+ }
175
+ const edges = difference(before.edges, after.edges, edgeKey);
176
+ const unlocks = difference(before.unlocks, after.unlocks, unlockKey);
177
+ const externalReferences = difference(before.externalReferences, after.externalReferences, externalKey);
178
+ const beforeFolders = byId(before.folders);
179
+ const afterFolders = byId(after.folders);
180
+ const folderAdded = after.folders
181
+ .filter(({ id }) => !beforeFolders.has(id))
182
+ .map(({ id }) => id)
183
+ .sort(compareCodeUnits);
184
+ const folderRemoved = before.folders
185
+ .filter(({ id }) => !afterFolders.has(id))
186
+ .map(({ id }) => id)
187
+ .sort(compareCodeUnits);
188
+ const folderChanged = before.folders
189
+ .filter(({ id }) => {
190
+ const afterFolder = afterFolders.get(id);
191
+ return (afterFolder !== undefined &&
192
+ hashCanonical({ ...beforeFolders.get(id), source: undefined }) !==
193
+ hashCanonical({ ...afterFolder, source: undefined }));
194
+ })
195
+ .map(({ id }) => id)
196
+ .sort(compareCodeUnits);
197
+ const issueDifference = difference(before.issues, after.issues, issueKey);
198
+ const retained = before.issues.filter((issue) => new Set(after.issues.map(issueKey)).has(issueKey(issue))).length;
199
+ const beforeReachable = reachable(before, signal);
200
+ const afterReachable = reachable(after, signal);
201
+ const sourceFiles = {
202
+ added: Object.keys(after.sourceHashes)
203
+ .filter((sourcePath) => !(sourcePath in before.sourceHashes))
204
+ .sort(compareCodeUnits),
205
+ removed: Object.keys(before.sourceHashes)
206
+ .filter((sourcePath) => !(sourcePath in after.sourceHashes))
207
+ .sort(compareCodeUnits),
208
+ changed: Object.keys(before.sourceHashes)
209
+ .filter((sourcePath) => after.sourceHashes[sourcePath] !== undefined &&
210
+ after.sourceHashes[sourcePath] !== before.sourceHashes[sourcePath])
211
+ .sort(compareCodeUnits),
212
+ };
213
+ return {
214
+ schemaVersion: 1,
215
+ beforeRevision: before.revision,
216
+ afterRevision: after.revision,
217
+ technologies: {
218
+ added,
219
+ removed,
220
+ renamed: renameCandidates(removedDefinitions, addedDefinitions, before.placements, after.placements, signal),
221
+ moved: moved.sort((left, right) => compareCodeUnits(left.technologyId, right.technologyId)),
222
+ metadataChanged: metadataChanged.sort((left, right) => compareCodeUnits(left.technologyId, right.technologyId)),
223
+ },
224
+ edges,
225
+ unlocks,
226
+ externalReferences,
227
+ folders: { added: folderAdded, removed: folderRemoved, changed: folderChanged },
228
+ issues: { introduced: issueDifference.added, resolved: issueDifference.removed, retained },
229
+ reachability: {
230
+ newlyReachable: [...afterReachable]
231
+ .filter((id) => !beforeReachable.has(id))
232
+ .sort(compareCodeUnits),
233
+ newlyOrphaned: [...beforeReachable]
234
+ .filter((id) => !afterReachable.has(id) && afterTech.has(id))
235
+ .sort(compareCodeUnits),
236
+ },
237
+ sourceFiles,
238
+ regressions: issueDifference.added.filter(({ classification }) => classification === 'confirmed_error' || classification === 'probable_defect'),
239
+ };
240
+ }