genesis-compiler 1.10.6 → 1.10.8

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.
@@ -1,7 +1,9 @@
1
- import { readFile, lstat } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
4
  import path from 'node:path';
3
5
 
4
- import { extractCodeIndexes } from './code-indexers/ast-grep.js';
6
+ import { extractCodeIndexes, extractionCandidates, extractorIdentity } from './code-indexers/ast-grep.js';
5
7
  import { inspectNpmPackages, recheckNpmInputs } from './code-indexers/npm-packages.js';
6
8
  import { asDiagnostic, GenesisError } from './errors.js';
7
9
  import { buildCityPresentation } from './city-presentation.js';
@@ -11,13 +13,19 @@ import { isProjectContentPath } from './paths.js';
11
13
  import { inspectProgram } from './program.js';
12
14
  import { inspectSubsystems } from './subsystems.js';
13
15
  import { buildArchitectureFacts } from './architecture.js';
16
+ import { CALL_WEB_PATH, buildCallWeb } from './call-web.js';
17
+ import { absentExtractions, extractionCachePath, extractionCacheRoot, loadExtractions, pruneExtractions, storeExtractions } from './extraction-cache.js';
18
+ import { attachEntryFunctions, discoverEntryPoints } from './entry-points.js';
19
+ import { runGit } from './process.js';
14
20
  import { CITY_SCHEMA_VERSION, MACHINE_CITY_SCHEMA, PROGRAM_CITY_SCHEMA } from './city-documents.js';
15
21
  import { gitVisibleFileStates } from './project-files.js';
16
22
  import { readStack } from './stack.js';
17
- import { sha256, stableJson, writeFileAtomic } from './utils.js';
23
+ import { sha256, stableJson, stableJsonParts } from './utils.js';
18
24
 
19
25
  export const MACHINE_CITY_PATH = '.genesis/machine-city.json';
20
26
  export const PROGRAM_CITY_PATH = '.genesis/program-city.json';
27
+ // Small write batches are reclaimed young instead of piling up as large objects.
28
+ const WRITE_BATCH_CHARACTERS = 64 * 1024;
21
29
 
22
30
  export const GENESIS_DERIVED_ARTIFACTS = Object.freeze([
23
31
  Object.freeze({
@@ -30,6 +38,11 @@ export const GENESIS_DERIVED_ARTIFACTS = Object.freeze([
30
38
  path: PROGRAM_CITY_PATH,
31
39
  recreator: 'index-codebase',
32
40
  }),
41
+ Object.freeze({
42
+ id: 'call-web',
43
+ path: CALL_WEB_PATH,
44
+ recreator: 'index-codebase',
45
+ }),
33
46
  ]);
34
47
 
35
48
  // Both Cities are rebuilt from the working tree, so projects ignore them.
@@ -234,16 +247,93 @@ async function programCity(projectRoot) {
234
247
  }
235
248
  }
236
249
 
250
+ // Only these facts carry forward, and returning releases the rest of the
251
+ // previous city at once.
252
+ async function previousArchitecture(root) {
253
+ try {
254
+ const { callWeb, capabilities, entryPoints, sourceHash, stackHash, ui } = JSON.parse(await readFile(path.join(root, MACHINE_CITY_PATH), 'utf8')).architecture || {};
255
+ return { callWeb, capabilities, entryPoints, sourceHash, stackHash, ui };
256
+ } catch (error) {
257
+ if (!(error instanceof SyntaxError) && !['ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ async function fileDigest(location) {
263
+ const hash = createHash('sha256');
264
+ for await (const chunk of createReadStream(location)) hash.update(chunk);
265
+ return hash.digest('hex');
266
+ }
267
+
268
+ // A document is written beside its destination and compared there, so
269
+ // neither it nor the previous version is ever held whole in memory.
237
270
  async function writeIfChanged(projectRoot, relativePath, value) {
238
271
  const location = path.join(projectRoot, relativePath);
239
- const source = stableJson(value, { space: 0 });
272
+ const temporary = `${location}.${randomUUID()}.tmp`;
273
+ const written = createHash('sha256');
274
+ function* batches() {
275
+ let pending = '';
276
+ for (const part of typeof value === 'string' ? [value] : stableJsonParts(value)) {
277
+ pending += part;
278
+ if (pending.length < WRITE_BATCH_CHARACTERS) continue;
279
+ written.update(pending);
280
+ yield pending;
281
+ pending = '';
282
+ }
283
+ written.update(pending);
284
+ yield pending;
285
+ }
286
+ await mkdir(path.dirname(location), { recursive: true });
240
287
  try {
241
- if (await readFile(location, 'utf8') === source) return false;
288
+ await writeFile(temporary, batches(), { flag: 'wx' });
289
+ let existing = null;
290
+ try { existing = await stat(location); }
291
+ catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error; }
292
+ if (existing?.size === (await stat(temporary)).size && await fileDigest(location) === written.digest('hex')) return false;
293
+ await rename(temporary, location);
294
+ return true;
295
+ } finally {
296
+ await rm(temporary, { force: true });
297
+ }
298
+ }
299
+
300
+ // The call web is never written where Git would offer it for commit.
301
+ async function callWebIgnored(root) {
302
+ try {
303
+ await runGit(root, ['check-ignore', '--quiet', CALL_WEB_PATH]);
304
+ return true;
242
305
  } catch (error) {
243
- if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error;
306
+ if (error.details?.status === 1) return false;
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ // Call sites are only checked for presence up front. Their values are read
312
+ // when the call web is built, after the observation reader, so a host's
313
+ // inspector never runs beside them.
314
+ async function prepareCallSites(root, indexers, files) {
315
+ const cacheRoot = await extractionCacheRoot(root, 'call-sites');
316
+ const version = await extractorIdentity();
317
+ const candidates = extractionCandidates(indexers, files)
318
+ .map(entry => ({ ...entry, location: extractionCachePath(cacheRoot, { version, ...entry }) }));
319
+ return { cacheRoot, candidates, misses: await absentExtractions(candidates), extracted: 0 };
320
+ }
321
+
322
+ // An entry that was present when checked but is unreadable now is extracted
323
+ // again; one the extraction could not produce is not retried.
324
+ async function readCallSites(root, indexers, files, callSites, signal) {
325
+ const { values, misses } = await loadExtractions(callSites.cacheRoot, callSites.candidates);
326
+ const late = misses.filter(entry => !callSites.misses.includes(entry));
327
+ if (late.length) {
328
+ const locations = new Map(late.map(entry => [entry.path, entry.location]));
329
+ const { contributions } = await extractCodeIndexes({ indexers, files: files.filter(file => locations.has(file.path)), projectRoot: root, signal, callSites: [...locations.keys()] });
330
+ const extracted = contributions.flatMap(contribution => contribution.files).filter(file => file.callSites);
331
+ for (const file of extracted) values.set(file.path, file.callSites);
332
+ await storeExtractions(extracted.map(file => ({ location: locations.get(file.path), value: file.callSites })));
333
+ callSites.misses.push(...late);
334
+ callSites.extracted += extracted.length;
244
335
  }
245
- await writeFileAtomic(location, source);
246
- return true;
336
+ return values;
247
337
  }
248
338
 
249
339
  function functionMatches(entry, queries) {
@@ -265,6 +355,7 @@ export async function buildProjectIndex({
265
355
  stackPackages = [],
266
356
  write = true,
267
357
  observations = null,
358
+ callWeb = false,
268
359
  signal,
269
360
  } = {}) {
270
361
  signal?.throwIfAborted();
@@ -285,7 +376,22 @@ export async function buildProjectIndex({
285
376
  ))
286
377
  .map(([filePath, state]) => ({ path: filePath, hash: state.hash, mode: state.mode }));
287
378
  const indexers = [...new Set(stack.components.flatMap((component) => component.indexers || []))].sort();
288
- const { contributions, diagnostics } = await extractCodeIndexes({ indexers, files, projectRoot: root, signal });
379
+ const callWebDiagnostics = [];
380
+ let callSites = null;
381
+ if (callWeb) {
382
+ if (await callWebIgnored(root)) callSites = await prepareCallSites(root, indexers, files);
383
+ else callWebDiagnostics.push({ code: 'CALL_WEB_NOT_IGNORED', path: CALL_WEB_PATH, message: 'Add `/.genesis/call-web.json` to .gitignore' });
384
+ }
385
+ const extractionRoot = await extractionCacheRoot(root, 'extractions');
386
+ const { contributions, diagnostics, cacheLocations } = await extractCodeIndexes({ indexers, files, projectRoot: root, signal, callSites: callSites?.misses.map(entry => entry.path) || [], cacheRoot: extractionRoot });
387
+ if (callSites) {
388
+ // Extraction is content-addressed, so it is cached even if this build later fails.
389
+ const locations = new Map(callSites.misses.map(entry => [entry.path, entry.location]));
390
+ const extracted = contributions.flatMap(contribution => contribution.files).filter(file => file.callSites && locations.has(file.path));
391
+ await storeExtractions(extracted.map(file => ({ location: locations.get(file.path), value: file.callSites })));
392
+ for (const file of extracted) delete file.callSites;
393
+ callSites.extracted = extracted.length;
394
+ }
289
395
  const machine = machineCity({
290
396
  cityRegions: stack.cityRegions,
291
397
  components: stack.components.map(({ id }) => id),
@@ -309,14 +415,11 @@ export async function buildProjectIndex({
309
415
  for (const discovery of stack.packageDiscovery.discoveries) {
310
416
  signal?.throwIfAborted();
311
417
  if (discovery.adapter !== 'npm') { architectureDiagnostics.push({ code: 'PACKAGE_ADAPTER_UNSUPPORTED', path: discovery.manifests[0], message: `Package adapter ${discovery.adapter} has no installed inspector; public boundaries are unavailable.` }); continue; }
312
- const inspected = await inspectNpmPackages({ projectRoot: root, discovery, files, indexedFiles: contributions.flatMap(contribution => contribution.files), signal });
418
+ const inspected = await inspectNpmPackages({ projectRoot: root, discovery, files, indexedFiles: contributions.flatMap(contribution => contribution.files), cacheRoot: extractionRoot, signal });
313
419
  packages.push(...inspected.packages); packageInputs.push(...inspected.inputs); architectureDiagnostics.push(...inspected.diagnostics);
420
+ cacheLocations.push(...inspected.cacheLocations);
314
421
  }
315
- let previous;
316
- if (!observations && !observationReader) {
317
- try { previous = JSON.parse(await readFile(path.join(root, MACHINE_CITY_PATH), 'utf8')).architecture; }
318
- catch (error) { if (!(error instanceof SyntaxError) && !['ENOENT', 'ENOTDIR'].includes(error.code)) throw error; }
319
- }
422
+ const previous = observations || observationReader ? undefined : await previousArchitecture(root);
320
423
  machine.architecture = buildArchitectureFacts({ machine, subsystemMap, program, uiDiscovery: stack.uiDiscovery, packageDiscovery: stack.packageDiscovery, packages, stackHash: stack.identityHash, diagnostics: architectureDiagnostics, observations, previous, inventory: files, cityRegions: stack.cityRegions });
321
424
  if (observationReader) {
322
425
  signal?.throwIfAborted();
@@ -325,6 +428,35 @@ export async function buildProjectIndex({
325
428
  if (!observations) throw new GenesisError('ARCHITECTURE_OBSERVATIONS_INVALID', 'An architecture inspector must return its observation document.');
326
429
  machine.architecture = buildArchitectureFacts({ machine, subsystemMap, program, uiDiscovery: stack.uiDiscovery, packageDiscovery: stack.packageDiscovery, packages, stackHash: stack.identityHash, diagnostics: architectureDiagnostics, observations, inventory: files, cityRegions: stack.cityRegions });
327
430
  }
431
+ const architecture = machine.architecture;
432
+ const discovered = await discoverEntryPoints({ projectRoot: root, architecture, functions: machine.functions, program });
433
+ architecture.diagnostics.push(...discovered.diagnostics, ...callWebDiagnostics);
434
+ architecture.capabilities.entryPoints = discovered.capability;
435
+ let callWebDocument = null;
436
+ let callWebSource = null;
437
+ architecture.callWeb = null;
438
+ if (callSites) {
439
+ const extractions = await readCallSites(root, indexers, files, callSites, signal);
440
+ const built = buildCallWeb({ architecture, extractions, machineFunctions: machine.functions, observations, sourceIdentity: architecture.sourceHash });
441
+ callWebDocument = built.document;
442
+ callWebSource = stableJson(callWebDocument, { space: 0 });
443
+ architecture.callWeb = { path: CALL_WEB_PATH, identity: sha256(callWebSource), sourceIdentity: architecture.sourceHash, counts: built.counts, coverage: built.coverage };
444
+ const adapterCoverage = name => observations?.capabilities?.[name];
445
+ architecture.capabilities.functions = adapterCoverage('functions') === 'partial' ? 'partial' : 'complete';
446
+ architecture.capabilities.calls = built.truncated.length || adapterCoverage('calls') === 'partial' ? 'partial' : 'complete';
447
+ architecture.entryPoints = attachEntryFunctions(discovered.entries, callWebDocument);
448
+ } else if (callWeb) {
449
+ architecture.capabilities.functions = 'unconfigured';
450
+ architecture.capabilities.calls = 'unconfigured';
451
+ architecture.entryPoints = attachEntryFunctions(discovered.entries, null);
452
+ } else {
453
+ // An unchanged snapshot keeps its call web, as it keeps its observations.
454
+ const current = previous?.callWeb && previous.sourceHash === architecture.sourceHash && previous.stackHash === architecture.stackHash ? previous : null;
455
+ const previousIds = new Map((current?.entryPoints || []).map(entry => [entry.id, entry.functionIds]));
456
+ architecture.callWeb = current?.callWeb || null;
457
+ if (current) for (const name of ['functions', 'calls']) if (current.capabilities?.[name]) architecture.capabilities[name] = current.capabilities[name];
458
+ architecture.entryPoints = attachEntryFunctions(discovered.entries, null).map(entry => ({ ...entry, functionIds: previousIds.get(entry.id) || [] }));
459
+ }
328
460
  // Do not publish a join from different revisions or destroy the previous
329
461
  // valid artifact after an inspector/parser read racing a source change.
330
462
  const finalStates = await gitVisibleFileStates(root, { includePath });
@@ -338,10 +470,15 @@ export async function buildProjectIndex({
338
470
  await recheckNpmInputs(root, packageInputs, signal);
339
471
  const changedFiles = [];
340
472
  if (write) {
473
+ // Machine City names the call web's identity, so it is published second.
474
+ if (callWebSource !== null && await writeIfChanged(root, CALL_WEB_PATH, callWebSource)) changedFiles.push(CALL_WEB_PATH);
341
475
  if (await writeIfChanged(root, MACHINE_CITY_PATH, machine)) changedFiles.push(MACHINE_CITY_PATH);
342
476
  if (await writeIfChanged(root, PROGRAM_CITY_PATH, program)) changedFiles.push(PROGRAM_CITY_PATH);
477
+ await pruneExtractions(extractionRoot, cacheLocations);
478
+ if (callSites) await pruneExtractions(callSites.cacheRoot, callSites.candidates.map(entry => entry.location));
343
479
  }
344
480
  const allMatches = machine.functions.filter((entry) => functionMatches(entry, queries));
481
+ // Entry-point and call-web diagnostics are already part of the architecture's.
345
482
  const allDiagnostics = [...diagnostics, ...program.diagnostics, ...architectureDiagnostics];
346
483
  return {
347
484
  status: allDiagnostics.length > 0 ? 'completed-with-warning' : 'ready',
@@ -350,7 +487,7 @@ export async function buildProjectIndex({
350
487
  `in ${formatCount(machine.buildings.length, 'file')}`,
351
488
  `and ${formatCount(program.buildings.length, 'Program operation')}.`,
352
489
  ].join(' '),
353
- paths: { machine: MACHINE_CITY_PATH, program: PROGRAM_CITY_PATH },
490
+ paths: { machine: MACHINE_CITY_PATH, program: PROGRAM_CITY_PATH, ...(callWebDocument ? { callWeb: CALL_WEB_PATH } : {}) },
354
491
  changedFiles,
355
492
  diagnostics: allDiagnostics,
356
493
  functionCount: machine.functions.length,
@@ -360,5 +497,6 @@ export async function buildProjectIndex({
360
497
  matches: allMatches.slice(0, 200),
361
498
  machine,
362
499
  program,
500
+ ...(callWebDocument ? { callWeb: callWebDocument, callSites: { extracted: callSites.extracted, cached: callSites.candidates.length - callSites.misses.length } } : {}),
363
501
  };
364
502
  }