genesis-compiler 1.10.6 → 1.10.7
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.
- package/README.md +17 -1
- package/package.json +1 -1
- package/plugins/genesis/.codex-plugin/plugin.json +1 -1
- package/src/cli.js +9 -2
- package/src/index/architecture-observations.js +94 -7
- package/src/index/architecture.js +21 -2
- package/src/index/call-web.js +419 -0
- package/src/index/code-index.js +154 -16
- package/src/index/code-indexers/ast-grep.js +453 -77
- package/src/index/code-indexers/npm-packages.js +4 -3
- package/src/index/code-indexers/worker.js +5 -2
- package/src/index/entry-points.js +269 -0
- package/src/index/extraction-cache.js +81 -0
- package/src/index/project-files.js +7 -3
- package/src/index/project-inspection.js +5 -2
- package/src/index/utils.js +34 -0
- package/src/index.js +3 -1
package/src/index/code-index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|