hoi4-agent-tools 2.1.0 → 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,686 @@
1
+ import path from 'node:path';
2
+ import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
3
+ import { boundedSourceHashEvidence, } from '../core/artifacts.js';
4
+ import { RenderBudget } from '../core/render-budget.js';
5
+ import { ServiceError } from '../core/result.js';
6
+ import { SOURCE_MAX_BYTES } from '../core/source/index.js';
7
+ import { isPortablePathSegment } from '../core/workspace.js';
8
+ import { PACKAGE_VERSION } from '../version.js';
9
+ import { compareTechnologyGraphs } from './compare.js';
10
+ import { buildTechnologyGraph, technologyAssetPatterns } from './graph.js';
11
+ import { analyzeTechnologyImpact, discoverTechnologyFolders, explainTechnology, inspectTechnologyUnlocks, lintTechnologyGraph, technologyBonusCoverage, technologyScanReport, traceTechnology, } from './queries.js';
12
+ import { renderTechnologyGraph, } from './render.js';
13
+ const FRAGMENT_CACHE_ENTRIES = 8_192;
14
+ const FRAGMENT_CACHE_BYTES = 134_217_728;
15
+ const PROPOSED_SOURCE_FILES = 128;
16
+ const PROPOSED_SOURCE_BYTES = 67_108_864;
17
+ const GRAPH_ARTIFACT_BYTES = 134_217_728;
18
+ const GRAPH_ARTIFACT_CHUNKS = 2_048;
19
+ const HISTORY_ENTRIES = 12;
20
+ const FOCUSED_RENDER_LIMIT = 32;
21
+ class BoundedTechnologyFragmentCache {
22
+ #values = new Map();
23
+ #bytes = 0;
24
+ get(key) {
25
+ const value = this.#values.get(key);
26
+ if (value === undefined)
27
+ return undefined;
28
+ this.#values.delete(key);
29
+ this.#values.set(key, value);
30
+ return value.fragment;
31
+ }
32
+ set(key, fragment, sourceBytes) {
33
+ const previous = this.#values.get(key);
34
+ if (previous !== undefined)
35
+ this.#bytes -= previous.bytes;
36
+ this.#values.delete(key);
37
+ this.#values.set(key, { fragment, bytes: sourceBytes });
38
+ this.#bytes += sourceBytes;
39
+ while (this.#values.size > FRAGMENT_CACHE_ENTRIES || this.#bytes > FRAGMENT_CACHE_BYTES) {
40
+ const oldest = this.#values.entries().next().value;
41
+ if (oldest === undefined)
42
+ break;
43
+ this.#values.delete(oldest[0]);
44
+ this.#bytes -= oldest[1].bytes;
45
+ }
46
+ }
47
+ }
48
+ const sharedTechnologyStates = new WeakMap();
49
+ function sharedState(engine) {
50
+ let state = sharedTechnologyStates.get(engine);
51
+ if (state === undefined) {
52
+ state = {
53
+ current: new Map(),
54
+ history: new Map(),
55
+ fragments: new BoundedTechnologyFragmentCache(),
56
+ };
57
+ sharedTechnologyStates.set(engine, state);
58
+ }
59
+ return state;
60
+ }
61
+ function rememberGraph(state, graph) {
62
+ const history = state.history.get(graph.workspaceId) ?? new Map();
63
+ history.delete(graph.revision);
64
+ history.set(graph.revision, graph);
65
+ while (history.size > HISTORY_ENTRIES) {
66
+ const oldest = history.keys().next().value;
67
+ if (oldest === undefined)
68
+ break;
69
+ history.delete(oldest);
70
+ }
71
+ state.history.set(graph.workspaceId, history);
72
+ }
73
+ function safeSlug(value) {
74
+ return (value
75
+ .replace(/[^A-Za-z0-9._-]+/gu, '-')
76
+ .replace(/^-+|-+$/gu, '')
77
+ .slice(0, 64) || 'technology');
78
+ }
79
+ function graphHash(graph) {
80
+ return hashCanonical({
81
+ schemaVersion: graph.schemaVersion,
82
+ workspaceIdentity: graph.workspaceIdentity,
83
+ revision: graph.revision,
84
+ statistics: graph.statistics,
85
+ sourceHashes: graph.sourceHashes,
86
+ });
87
+ }
88
+ function technologyProvenance(graph, kind, metadata = {}) {
89
+ const sourceEvidence = boundedSourceHashEvidence(graph.sourceHashes);
90
+ return {
91
+ kind,
92
+ toolVersion: PACKAGE_VERSION,
93
+ schemaVersion: 'technology-tree-viewer.v1',
94
+ sourceHashes: sourceEvidence.sourceHashes,
95
+ renderProfile: {
96
+ graphRevision: graph.revision,
97
+ graphHash: graphHash(graph),
98
+ complete: graph.complete,
99
+ parserVersion: graph.parserVersion,
100
+ },
101
+ metadata: {
102
+ ...metadata,
103
+ sourceHashInventory: sourceEvidence.inventory,
104
+ unresolvedCount: graph.unresolved.length,
105
+ },
106
+ };
107
+ }
108
+ function issueDiagnostic(graph) {
109
+ return graph.issues.map((issue) => ({
110
+ code: issue.code,
111
+ severity: issue.severity,
112
+ category: issue.classification === 'confirmed_error' ? 'reference' : 'design',
113
+ message: issue.message,
114
+ ...(issue.location === undefined ? {} : { location: issue.location }),
115
+ ...(issue.related === undefined ? {} : { related: issue.related }),
116
+ details: {
117
+ classification: issue.classification,
118
+ confidence: issue.confidence,
119
+ blockers: issue.blockers,
120
+ ...issue.details,
121
+ },
122
+ }));
123
+ }
124
+ export function technologyDiagnostics(graph) {
125
+ return [...graph.diagnostics, ...issueDiagnostic(graph)];
126
+ }
127
+ function validRelativeSourcePath(relativePath) {
128
+ if (relativePath.length === 0 ||
129
+ relativePath.includes('\0') ||
130
+ path.win32.isAbsolute(relativePath) ||
131
+ path.posix.isAbsolute(relativePath))
132
+ throw new ServiceError('TECH_PROPOSED_PATH_INVALID', 'Proposed source path must be workspace-relative');
133
+ const normalized = path.posix.normalize(relativePath.replaceAll('\\', '/')).replace(/^\.\//u, '');
134
+ const segments = normalized.split('/');
135
+ if (normalized === '.' ||
136
+ normalized.startsWith('.hoi4-agent/') ||
137
+ segments.some((segment) => segment === '..' || !isPortablePathSegment(segment)))
138
+ throw new ServiceError('TECH_PROPOSED_PATH_INVALID', 'Proposed source path escapes the workspace or is not portable');
139
+ if (!/\.(?:txt|gui|gfx|yml|yaml)$/iu.test(normalized))
140
+ throw new ServiceError('TECH_PROPOSED_SOURCE_UNSUPPORTED', 'Technology comparison overlays support HOI4 text sources only');
141
+ return normalized;
142
+ }
143
+ function assertProposedBounds(sources) {
144
+ if (sources.length === 0)
145
+ throw new ServiceError('TECH_PROPOSED_SOURCE_REQUIRED', 'Proposed source list is empty');
146
+ if (sources.length > PROPOSED_SOURCE_FILES)
147
+ throw new ServiceError('TECH_PROPOSED_SOURCE_COUNT_LIMIT', 'Proposed source list exceeds its fixed file ceiling', {
148
+ count: sources.length,
149
+ maximumFiles: PROPOSED_SOURCE_FILES,
150
+ });
151
+ const bytes = sources.reduce((total, item) => total + (item.source === null ? 0 : Buffer.byteLength(item.source, 'utf8')), 0);
152
+ if (bytes > PROPOSED_SOURCE_BYTES)
153
+ throw new ServiceError('TECH_PROPOSED_SOURCE_TOTAL_LIMIT', 'Proposed sources exceed their fixed byte ceiling', {
154
+ bytes,
155
+ maximumBytes: PROPOSED_SOURCE_BYTES,
156
+ });
157
+ }
158
+ function recomputeShadowing(files) {
159
+ const groups = new Map();
160
+ for (const file of files) {
161
+ delete file.shadowedBy;
162
+ const key = file.relativePath.replaceAll('\\', '/').toLowerCase();
163
+ const group = groups.get(key) ?? [];
164
+ group.push(file);
165
+ groups.set(key, group);
166
+ }
167
+ for (const group of groups.values()) {
168
+ group.sort((left, right) => right.loadOrder - left.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
169
+ const active = group[0];
170
+ if (active !== undefined)
171
+ for (const file of group.slice(1))
172
+ file.shadowedBy = active.displayPath;
173
+ }
174
+ }
175
+ function snapshotRevision(files) {
176
+ return hashCanonical([...files]
177
+ .sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath))
178
+ .map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
179
+ }
180
+ function validateGraph(value) {
181
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
182
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph artifact is not an object');
183
+ const graph = value;
184
+ const sourceHashes = value.sourceHashes;
185
+ if (graph.schemaVersion !== 1 ||
186
+ typeof graph.workspaceId !== 'string' ||
187
+ typeof graph.workspaceIdentity !== 'string' ||
188
+ typeof graph.revision !== 'string' ||
189
+ !Array.isArray(graph.technologies) ||
190
+ !Array.isArray(graph.edges) ||
191
+ !Array.isArray(graph.placements) ||
192
+ sourceHashes === null ||
193
+ typeof sourceHashes !== 'object')
194
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph artifact has an invalid schema');
195
+ return graph;
196
+ }
197
+ async function readLogicalArtifact(engine, workspaceId, uri, principal, signal) {
198
+ const workspace = engine.resolver.get(workspaceId, principal);
199
+ const probe = await engine.artifacts.read(workspace, uri, { offset: 0, length: 1 }, signal);
200
+ if (probe.mimeType !== 'application/json' || probe.totalSize > GRAPH_ARTIFACT_BYTES)
201
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph artifact must be bounded JSON');
202
+ const read = await engine.artifacts.read(workspace, uri, undefined, signal);
203
+ let parsed;
204
+ try {
205
+ parsed = JSON.parse(read.bytes.toString('utf8'));
206
+ }
207
+ catch {
208
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph artifact is not valid JSON');
209
+ }
210
+ if (parsed === null ||
211
+ typeof parsed !== 'object' ||
212
+ parsed.type !== 'hoi4-agent.chunked-artifact')
213
+ return read.bytes;
214
+ const index = parsed;
215
+ if (index.schemaVersion !== 1 || !Array.isArray(index.chunks))
216
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Chunked graph index is malformed');
217
+ if (index.chunks.length === 0 || index.chunks.length > GRAPH_ARTIFACT_CHUNKS)
218
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_LIMIT', 'Chunked graph index exceeds the chunk ceiling');
219
+ const size = index.original?.size;
220
+ if (typeof size !== 'number' ||
221
+ !Number.isSafeInteger(size) ||
222
+ size < 0 ||
223
+ size > GRAPH_ARTIFACT_BYTES)
224
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_LIMIT', 'Chunked graph exceeds the comparison byte ceiling');
225
+ if (index.original?.mimeType !== 'application/json' || typeof index.original.sha256 !== 'string')
226
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Chunked graph metadata is malformed');
227
+ const buffers = [];
228
+ let offset = 0;
229
+ for (const [chunkIndex, candidate] of index.chunks.entries()) {
230
+ signal?.throwIfAborted();
231
+ if (candidate === null || typeof candidate !== 'object')
232
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Chunked graph entry is malformed');
233
+ const chunk = candidate;
234
+ if (chunk.index !== chunkIndex ||
235
+ chunk.offset !== offset ||
236
+ typeof chunk.length !== 'number' ||
237
+ !Number.isSafeInteger(chunk.length) ||
238
+ chunk.length <= 0 ||
239
+ typeof chunk.uri !== 'string' ||
240
+ typeof chunk.sha256 !== 'string')
241
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Chunked graph entry is malformed');
242
+ const content = await engine.artifacts.read(workspace, chunk.uri, undefined, signal);
243
+ if (content.bytes.length !== chunk.length || sha256Bytes(content.bytes) !== chunk.sha256)
244
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Chunked graph content hash does not match');
245
+ buffers.push(content.bytes);
246
+ offset += content.bytes.length;
247
+ }
248
+ const reconstructed = Buffer.concat(buffers, size);
249
+ if (offset !== size || sha256Bytes(reconstructed) !== index.original.sha256)
250
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Chunked graph does not reconstruct its declared content');
251
+ return reconstructed;
252
+ }
253
+ function parseGraphArtifact(bytes) {
254
+ let parsed;
255
+ try {
256
+ parsed = JSON.parse(bytes.toString('utf8'));
257
+ }
258
+ catch {
259
+ throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph snapshot is not valid JSON');
260
+ }
261
+ const record = parsed !== null && typeof parsed === 'object' ? parsed : undefined;
262
+ const report = record !== undefined &&
263
+ 'report' in record &&
264
+ record.report !== null &&
265
+ typeof record.report === 'object'
266
+ ? record.report
267
+ : undefined;
268
+ const candidate = record !== undefined && 'graph' in record
269
+ ? record.graph
270
+ : report !== undefined && 'graph' in report
271
+ ? report.graph
272
+ : parsed;
273
+ return validateGraph(candidate);
274
+ }
275
+ function renderWrites(prefix, render, provenance, label) {
276
+ return [
277
+ {
278
+ name: `${prefix}.json`,
279
+ mimeType: 'application/json',
280
+ content: render.json,
281
+ provenance,
282
+ description: `Authoritative ${label} technology data`,
283
+ },
284
+ {
285
+ name: `${prefix}.svg`,
286
+ mimeType: 'image/svg+xml',
287
+ content: render.svg,
288
+ provenance,
289
+ description: `Source-linked ${label} technology diagram`,
290
+ },
291
+ {
292
+ name: `${prefix}.png`,
293
+ mimeType: 'image/png',
294
+ content: render.png,
295
+ provenance,
296
+ description: `${label} technology image`,
297
+ },
298
+ ...(render.html === undefined
299
+ ? []
300
+ : [
301
+ {
302
+ name: `${prefix}.html`,
303
+ mimeType: 'text/html',
304
+ content: render.html,
305
+ provenance,
306
+ description: `Static ${label} technology report`,
307
+ },
308
+ ]),
309
+ ];
310
+ }
311
+ export class TechnologyTreeViewer {
312
+ engine;
313
+ #state;
314
+ constructor(engine) {
315
+ this.engine = engine;
316
+ this.#state = sharedState(engine);
317
+ }
318
+ async scan(workspaceId, options = {}) {
319
+ options.signal?.throwIfAborted();
320
+ const workspace = this.engine.resolver.get(workspaceId, options.principal);
321
+ const generation = this.engine.generation(workspaceId);
322
+ const cached = this.#state.current.get(workspaceId);
323
+ if (options.refresh !== true && cached?.generation === generation)
324
+ return cached.graph;
325
+ const snapshot = await this.engine.scan(workspaceId, {}, options.principal, options.signal);
326
+ const preliminary = buildTechnologyGraph(snapshot, {
327
+ workspaceIdentity: workspace.workspaceIdentity,
328
+ cache: this.#state.fragments,
329
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
330
+ });
331
+ const patterns = technologyAssetPatterns(preliminary);
332
+ const assetFiles = patterns.length === 0
333
+ ? []
334
+ : await this.engine.scanner.scan(workspace, {
335
+ patterns,
336
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
337
+ });
338
+ const graph = buildTechnologyGraph(snapshot, {
339
+ workspaceIdentity: workspace.workspaceIdentity,
340
+ cache: this.#state.fragments,
341
+ assetFiles,
342
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
343
+ });
344
+ this.#state.current.set(workspaceId, { generation, snapshot, assetFiles, graph });
345
+ rememberGraph(this.#state, graph);
346
+ return graph;
347
+ }
348
+ async analyze(input) {
349
+ const graph = await this.scan(input.workspaceId, {
350
+ refresh: input.refresh ?? input.mode === 'scan',
351
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
352
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
353
+ });
354
+ let report;
355
+ switch (input.mode) {
356
+ case 'scan':
357
+ report = { ...technologyScanReport(graph), graph };
358
+ break;
359
+ case 'folders':
360
+ report = discoverTechnologyFolders(graph, input.folderId);
361
+ break;
362
+ case 'trace':
363
+ if (input.technologyId === undefined)
364
+ throw new ServiceError('TECHNOLOGY_REQUIRED', 'Technology trace requires technologyId');
365
+ report = traceTechnology(graph, {
366
+ technologyId: input.technologyId,
367
+ direction: input.direction ?? 'both',
368
+ maxDepth: input.maxDepth ?? 32,
369
+ maxNodes: input.maxNodes ?? 2_000,
370
+ includeSubTechnologies: input.includeSubTechnologies ?? true,
371
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
372
+ });
373
+ break;
374
+ case 'explain':
375
+ if (input.technologyId === undefined)
376
+ throw new ServiceError('TECHNOLOGY_REQUIRED', 'Technology explanation requires technologyId');
377
+ report = explainTechnology(graph, input.technologyId, input.signal);
378
+ break;
379
+ case 'unlocks':
380
+ report = inspectTechnologyUnlocks(graph, {
381
+ ...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
382
+ ...(input.targetKind === undefined ? {} : { targetKind: input.targetKind }),
383
+ ...(input.targetId === undefined ? {} : { targetId: input.targetId }),
384
+ });
385
+ break;
386
+ case 'bonus_coverage':
387
+ report = technologyBonusCoverage(graph, {
388
+ ...(input.categoryId === undefined ? {} : { categoryId: input.categoryId }),
389
+ ...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
390
+ });
391
+ break;
392
+ case 'lint':
393
+ report = lintTechnologyGraph(graph, {
394
+ ...(input.classifications === undefined
395
+ ? {}
396
+ : { classifications: input.classifications }),
397
+ ...(input.codes === undefined ? {} : { codes: input.codes }),
398
+ ...(input.folderId === undefined ? {} : { folderId: input.folderId }),
399
+ ...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
400
+ });
401
+ break;
402
+ case 'impact':
403
+ if (input.impact === undefined)
404
+ throw new ServiceError('TECH_IMPACT_REQUIRED', 'Technology impact analysis requires a subject');
405
+ report = analyzeTechnologyImpact(graph, input.impact);
406
+ break;
407
+ }
408
+ const reportJson = `${canonicalJson({
409
+ schemaVersion: 'technology-analysis.v1',
410
+ workspaceId: graph.workspaceId,
411
+ workspaceIdentity: graph.workspaceIdentity,
412
+ graphRevision: graph.revision,
413
+ graphHash: graphHash(graph),
414
+ complete: graph.complete,
415
+ analysisBoundary: graph.analysisBoundary,
416
+ mode: input.mode,
417
+ report,
418
+ })}\n`;
419
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
420
+ const artifact = await this.engine.artifacts.putChunked(workspace, `${safeSlug(`technology-${input.mode}`)}-${graph.revision.slice(0, 12)}.json`, 'application/json', reportJson, technologyProvenance(graph, `technology-${input.mode}`, { mode: input.mode }), `Authoritative Technology Tree Viewer ${input.mode.replaceAll('_', ' ')} report`, input.signal);
421
+ return { graph, report, reportJson, artifacts: [artifact] };
422
+ }
423
+ async renderAndStore(input) {
424
+ const graph = await this.scan(input.workspaceId, {
425
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
426
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
427
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
428
+ });
429
+ const budget = new RenderBudget();
430
+ const renderOptions = {
431
+ view: input.view,
432
+ maxNodes: input.maxNodes ?? 1_000,
433
+ includeHtml: input.includeHtml ?? false,
434
+ budget,
435
+ ...(input.folderId === undefined ? {} : { folderId: input.folderId }),
436
+ ...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
437
+ ...(input.categoryId === undefined ? {} : { categoryId: input.categoryId }),
438
+ ...(input.targetId === undefined ? {} : { targetId: input.targetId }),
439
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
440
+ };
441
+ const render = await renderTechnologyGraph(graph, renderOptions);
442
+ const focused = [];
443
+ const focusedFolders = input.view === 'dependencies' && input.folderId === undefined
444
+ ? graph.folders
445
+ .filter((folder) => graph.placements.some(({ folderId }) => folderId === folder.id))
446
+ .slice(0, FOCUSED_RENDER_LIMIT)
447
+ : [];
448
+ for (const folder of focusedFolders) {
449
+ input.signal?.throwIfAborted();
450
+ focused.push(await renderTechnologyGraph(graph, {
451
+ view: 'folder',
452
+ folderId: folder.id,
453
+ maxNodes: 1_000,
454
+ includeHtml: false,
455
+ budget,
456
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
457
+ }));
458
+ }
459
+ const prefix = safeSlug(`technology-${input.view}-${graph.revision.slice(0, 12)}`);
460
+ const provenance = technologyProvenance(graph, 'technology-render', {
461
+ view: input.view,
462
+ selectedNodeCount: render.selectedIds.length,
463
+ omittedNodeCount: render.omittedNodeCount,
464
+ });
465
+ const writes = [
466
+ ...renderWrites(prefix, render, provenance, input.view),
467
+ ...focused.flatMap((bundle, index) => renderWrites(`${prefix}-folder-${String(index + 1).padStart(2, '0')}`, bundle, provenance, `focused folder ${index + 1}`)),
468
+ ];
469
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
470
+ const dataArtifacts = await this.engine.artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), input.signal);
471
+ const manifestJson = `${canonicalJson({
472
+ schemaVersion: 'technology-render-manifest.v1',
473
+ workspaceId: graph.workspaceId,
474
+ workspaceIdentity: graph.workspaceIdentity,
475
+ graphRevision: graph.revision,
476
+ graphHash: graphHash(graph),
477
+ analysisBoundary: graph.analysisBoundary,
478
+ view: input.view,
479
+ overview: {
480
+ selectedIds: render.selectedIds,
481
+ omittedNodeCount: render.omittedNodeCount,
482
+ sourceAccurate: render.sourceAccurate,
483
+ generatedAnalysisLayout: render.generatedAnalysisLayout,
484
+ hashes: render.hashes,
485
+ },
486
+ focusedFolders: focused.map((bundle, index) => ({
487
+ index: index + 1,
488
+ folderId: focusedFolders[index]?.id,
489
+ selectedIds: bundle.selectedIds,
490
+ omittedNodeCount: bundle.omittedNodeCount,
491
+ hashes: bundle.hashes,
492
+ })),
493
+ focusedFolderCoverage: {
494
+ total: graph.folders.length,
495
+ rendered: focused.length,
496
+ remainingFolderIds: graph.folders.slice(focused.length).map(({ id }) => id),
497
+ },
498
+ resources: dataArtifacts.map(({ name, uri, mimeType, size }) => ({
499
+ name,
500
+ uri,
501
+ mimeType,
502
+ size,
503
+ })),
504
+ })}\n`;
505
+ const manifest = await this.engine.artifacts.putChunked(workspace, `${prefix}-manifest.json`, 'application/json', manifestJson, provenance, `Technology ${input.view} render manifest`, input.signal);
506
+ return { graph, render, focused, manifestJson, artifacts: [manifest, ...dataArtifacts] };
507
+ }
508
+ #proposedGraph(workspaceId, sources, principal, signal) {
509
+ assertProposedBounds(sources);
510
+ const cached = this.#state.current.get(workspaceId);
511
+ if (cached === undefined)
512
+ throw new ServiceError('TECH_BASELINE_MISSING', 'Scan the workspace before comparing proposed source');
513
+ const paths = sources.map(({ relativePath }) => validRelativeSourcePath(relativePath));
514
+ if (new Set(paths.map((value) => value.toLowerCase())).size !== paths.length)
515
+ throw new ServiceError('TECH_PROPOSED_PATH_DUPLICATE', 'Proposed source paths must be unique');
516
+ const files = cached.snapshot.files.map((file) => ({
517
+ ...file,
518
+ bytes: Buffer.from(file.bytes),
519
+ }));
520
+ const workspace = this.engine.resolver.get(workspaceId, principal);
521
+ for (const [sourceIndex, source] of sources.entries()) {
522
+ signal?.throwIfAborted();
523
+ const relativePath = paths[sourceIndex];
524
+ const candidates = files
525
+ .map((file, index) => ({ file, index }))
526
+ .filter(({ file }) => file.relativePath.toLowerCase() === relativePath.toLowerCase())
527
+ .sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
528
+ compareCodeUnits(left.file.displayPath, right.file.displayPath));
529
+ const active = candidates.find(({ file }) => file.shadowedBy === undefined) ?? candidates[0];
530
+ const mod = candidates.find(({ file }) => file.rootKind === 'mod');
531
+ if (source.expectedSourceHash !== undefined &&
532
+ active?.file.sha256 !== source.expectedSourceHash)
533
+ throw new ServiceError('TECH_PROPOSED_SOURCE_STALE', 'Proposed source baseline hash is stale', {
534
+ relativePath,
535
+ expectedSourceHash: source.expectedSourceHash,
536
+ actualSourceHash: active?.file.sha256 ?? null,
537
+ });
538
+ if (source.source === null) {
539
+ if (mod === undefined)
540
+ throw new ServiceError('TECH_PROPOSED_SOURCE_MISSING', 'Cannot delete a source absent from the mod root');
541
+ files.splice(mod.index, 1);
542
+ continue;
543
+ }
544
+ const bytes = Buffer.from(source.source, 'utf8');
545
+ if (bytes.length > SOURCE_MAX_BYTES)
546
+ throw new ServiceError('SOURCE_FILE_LIMIT', 'Proposed technology source exceeds the parser byte ceiling');
547
+ if (mod !== undefined) {
548
+ Object.assign(mod.file, {
549
+ bytes,
550
+ size: bytes.length,
551
+ sha256: sha256Bytes(bytes),
552
+ modifiedMs: 0,
553
+ });
554
+ }
555
+ else {
556
+ const modRoot = [...workspace.roots]
557
+ .filter(({ kind }) => kind === 'mod')
558
+ .sort((a, b) => b.loadOrder - a.loadOrder)[0];
559
+ if (modRoot === undefined)
560
+ throw new ServiceError('TECH_PROPOSED_ROOT_MISSING', 'Workspace has no mod root for a proposed source');
561
+ files.push({
562
+ absolutePath: path.join(modRoot.path, relativePath),
563
+ displayPath: `mod:${relativePath}`,
564
+ relativePath,
565
+ rootKind: 'mod',
566
+ loadOrder: modRoot.loadOrder,
567
+ size: bytes.length,
568
+ modifiedMs: 0,
569
+ sha256: sha256Bytes(bytes),
570
+ bytes,
571
+ });
572
+ }
573
+ }
574
+ recomputeShadowing(files);
575
+ files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
576
+ const index = this.engine.indexFiles(files);
577
+ const snapshot = {
578
+ workspaceId,
579
+ revision: snapshotRevision(files),
580
+ files,
581
+ index,
582
+ complete: index.complete,
583
+ skippedSourceCount: index.skippedSourceCount,
584
+ skippedSources: index.skippedSources,
585
+ diagnostics: index.diagnostics,
586
+ };
587
+ return buildTechnologyGraph(snapshot, {
588
+ workspaceIdentity: workspace.workspaceIdentity,
589
+ cache: this.#state.fragments,
590
+ assetFiles: cached.assetFiles,
591
+ ...(signal === undefined ? {} : { signal }),
592
+ });
593
+ }
594
+ async #resolveReference(workspaceId, reference, current, principal, signal) {
595
+ if (reference?.artifactUri !== undefined) {
596
+ const graph = parseGraphArtifact(await readLogicalArtifact(this.engine, workspaceId, reference.artifactUri, principal, signal));
597
+ if (graph.workspaceId !== workspaceId ||
598
+ graph.workspaceIdentity !== current.workspaceIdentity)
599
+ throw new ServiceError('TECH_GRAPH_WORKSPACE_MISMATCH', 'Technology graph belongs to a different workspace');
600
+ return graph;
601
+ }
602
+ if (reference?.revision !== undefined) {
603
+ const graph = this.#state.history.get(workspaceId)?.get(reference.revision);
604
+ if (graph === undefined)
605
+ throw new ServiceError('TECH_REVISION_NOT_CACHED', 'Requested technology revision is not cached');
606
+ return graph;
607
+ }
608
+ return current;
609
+ }
610
+ async compareAndStore(input) {
611
+ input.signal?.throwIfAborted();
612
+ if (input.proposedSources !== undefined)
613
+ assertProposedBounds(input.proposedSources);
614
+ const current = await this.scan(input.workspaceId, {
615
+ refresh: input.refresh ?? input.proposedSources !== undefined,
616
+ ...(input.principal === undefined ? {} : { principal: input.principal }),
617
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
618
+ });
619
+ let before = await this.#resolveReference(input.workspaceId, input.before, current, input.principal, input.signal);
620
+ let after = input.proposedSources === undefined
621
+ ? await this.#resolveReference(input.workspaceId, input.after, current, input.principal, input.signal)
622
+ : this.#proposedGraph(input.workspaceId, input.proposedSources, input.principal, input.signal);
623
+ if (input.before === undefined &&
624
+ input.after === undefined &&
625
+ input.proposedSources === undefined) {
626
+ const previous = [...(this.#state.history.get(input.workspaceId)?.values() ?? [])]
627
+ .filter(({ revision }) => revision !== current.revision)
628
+ .at(-1);
629
+ if (previous === undefined)
630
+ throw new ServiceError('TECH_COMPARISON_BASELINE_REQUIRED', 'Provide a cached revision, graph artifact, or proposed source overlay');
631
+ before = previous;
632
+ after = current;
633
+ }
634
+ const comparison = compareTechnologyGraphs(before, after, input.signal);
635
+ const render = input.render === false
636
+ ? undefined
637
+ : await renderTechnologyGraph(after, {
638
+ view: 'comparison',
639
+ comparison,
640
+ maxNodes: input.maxRenderNodes ?? 500,
641
+ includeHtml: false,
642
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
643
+ });
644
+ const comparisonJson = `${canonicalJson({
645
+ schemaVersion: 'technology-comparison.v1',
646
+ workspaceId: current.workspaceId,
647
+ workspaceIdentity: current.workspaceIdentity,
648
+ beforeRevision: before.revision,
649
+ afterRevision: after.revision,
650
+ beforeComplete: before.complete,
651
+ afterComplete: after.complete,
652
+ beforeSourceHashes: before.sourceHashes,
653
+ afterSourceHashes: after.sourceHashes,
654
+ analysisBoundary: after.analysisBoundary,
655
+ comparison,
656
+ })}\n`;
657
+ const prefix = safeSlug(`technology-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}`);
658
+ const provenance = technologyProvenance(after, 'technology-comparison', {
659
+ beforeRevision: before.revision,
660
+ afterRevision: after.revision,
661
+ regressionCount: comparison.regressions.length,
662
+ });
663
+ const writes = [
664
+ {
665
+ name: `${prefix}.json`,
666
+ mimeType: 'application/json',
667
+ content: comparisonJson,
668
+ provenance,
669
+ description: 'Authoritative technology structural comparison',
670
+ },
671
+ ...(render === undefined
672
+ ? []
673
+ : renderWrites(prefix, render, provenance, 'comparison').filter(({ mimeType }) => mimeType !== 'application/json')),
674
+ ];
675
+ const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
676
+ const artifacts = await this.engine.artifacts.withAtomicChunkedWrites(workspace, writes, (stored) => Promise.resolve([...stored]), input.signal);
677
+ return {
678
+ before,
679
+ after,
680
+ comparison,
681
+ comparisonJson,
682
+ ...(render === undefined ? {} : { render }),
683
+ artifacts,
684
+ };
685
+ }
686
+ }