genesis-compiler 1.10.5 → 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 +21 -3
- package/docs/templates.md +13 -4
- package/package.json +1 -1
- package/plugins/genesis/.codex-plugin/plugin.json +1 -1
- package/src/cli.js +22 -7
- 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/template-catalog.js +2 -2
- package/src/index/template-project.js +7 -8
- package/src/index/template-source.js +6 -3
- package/src/index/utils.js +34 -0
- package/src/index.js +3 -1
|
@@ -9,7 +9,7 @@ const packageName = value => typeof value === 'string' && /^(?:@[a-zA-Z0-9._-]+\
|
|
|
9
9
|
|
|
10
10
|
// npm owns manifest and export semantics. Read direct declarations and their
|
|
11
11
|
// public entry files only; never walk dependency trees or enumerate internals.
|
|
12
|
-
export async function inspectNpmPackages({ projectRoot, discovery, files, indexedFiles = [], signal }) {
|
|
12
|
+
export async function inspectNpmPackages({ projectRoot, discovery, files, indexedFiles = [], cacheRoot = null, signal }) {
|
|
13
13
|
const known = new Map(files.map(file => [file.path, file]));
|
|
14
14
|
const packages = []; const inputs = []; const diagnostics = [];
|
|
15
15
|
const manifests = new Map(); const workspaceScopes = []; const byRoot = new Map();
|
|
@@ -145,7 +145,8 @@ export async function inspectNpmPackages({ projectRoot, discovery, files, indexe
|
|
|
145
145
|
}
|
|
146
146
|
const projectIndexes = new Map(indexedFiles.map(file => [file.path, file]));
|
|
147
147
|
const candidates = [...new Map(entries.filter(entry => projectIndexes.get(entry.file.path)?.hash !== entry.file.hash).map(entry => [entry.file.path, { path: entry.file.path, hash: entry.file.hash, mode: 0o100644, publicEntry: true }])).values()];
|
|
148
|
-
|
|
148
|
+
// Entry files share the project's extraction cache, so unchanged packages are not parsed again.
|
|
149
|
+
const result = candidates.length ? await extractCodeIndexes({ indexers: ['javascript'], files: candidates, projectRoot, signal, cacheRoot }) : { contributions: [], diagnostics: [], cacheLocations: [] };
|
|
149
150
|
diagnostics.push(...result.diagnostics);
|
|
150
151
|
const indexed = new Map([...projectIndexes, ...result.contributions.flatMap(contribution => contribution.files.map(file => [file.path, file]))]);
|
|
151
152
|
function publicSurface(filePath, visited = new Set()) {
|
|
@@ -184,7 +185,7 @@ export async function inspectNpmPackages({ projectRoot, discovery, files, indexe
|
|
|
184
185
|
point.hash = file.hash; point.coverage = 'partial'; record.symbolCoverage = 'partial';
|
|
185
186
|
point.symbols = publicSurface(file.path);
|
|
186
187
|
}
|
|
187
|
-
return { packages, inputs: [...new Map(inputs.map(input => [input.path, input])).values()], diagnostics };
|
|
188
|
+
return { packages, inputs: [...new Map(inputs.map(input => [input.path, input])).values()], diagnostics, cacheLocations: result.cacheLocations };
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
// Metadata and entry files live outside Git's inventory. Recheck those exact
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parse, registerDynamicLanguage } from '@ast-grep/napi';
|
|
2
2
|
import { astGrepCodeIndexers } from './ast-grep.js';
|
|
3
3
|
|
|
4
4
|
try {
|
|
@@ -7,9 +7,12 @@ try {
|
|
|
7
7
|
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
8
8
|
if (Object.keys(input.registrations).length) registerDynamicLanguage(input.registrations);
|
|
9
9
|
const contributions = [];
|
|
10
|
+
const callSites = new Set(input.callSites || []);
|
|
10
11
|
for (const id of input.indexers) {
|
|
11
12
|
const result = await astGrepCodeIndexers.get(id).extract({
|
|
12
|
-
|
|
13
|
+
// Nothing else runs here while a file parses, so parsing stays on this
|
|
14
|
+
// thread instead of a round trip through the thread pool.
|
|
15
|
+
files: input.files, projectRoot: input.projectRoot, parse, parserErrors: input.errors, callSites,
|
|
13
16
|
});
|
|
14
17
|
contributions.push({ ...result, extractor: id });
|
|
15
18
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ENTRY_POINT_KINDS } from './architecture-observations.js';
|
|
5
|
+
import { sha256 } from './utils.js';
|
|
6
|
+
|
|
7
|
+
export const MAX_ENTRY_POINTS = 20_000;
|
|
8
|
+
export const MAX_TEST_ENTRY_POINTS = 5_000;
|
|
9
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
10
|
+
const TEST_FILE = [
|
|
11
|
+
/(?:^|\/)[^/]+\.(?:test|spec)\.[^/]+$/u, /(?:^|\/)test_[^/]+\.py$/u, /_test\.py$/u, /_test\.go$/u, /(?:^|\/)src\/test\//u,
|
|
12
|
+
/Test\.java$/u, /Test\.kt$/u, /Tests\.cs$/u, /(?:^|\/)spec\/(?:.+\/)?[^/]+_spec\.rb$/u, /(?:^|\/)tests\/(?:.+\/)?[^/]+\.rs$/u, /(?:^|\/)tests\/(?:.+\/)?[^/]+Test\.php$/u,
|
|
13
|
+
];
|
|
14
|
+
const KIND_ORDER = new Map(ENTRY_POINT_KINDS.map((kind, index) => [kind, index]));
|
|
15
|
+
const isTestFile = filePath => TEST_FILE.some(pattern => pattern.test(filePath));
|
|
16
|
+
|
|
17
|
+
const lineRange = (source, needle) => {
|
|
18
|
+
const lines = source.split('\n');
|
|
19
|
+
const index = needle ? lines.findIndex(line => line.includes(needle)) : -1;
|
|
20
|
+
const line = index < 0 ? 1 : index + 1;
|
|
21
|
+
const length = (lines[line - 1] || '').replace(/\r$/u, '').length;
|
|
22
|
+
return length ? { start: { line, column: 1 }, end: { line, column: length + 1 } } : { start: { line, column: 1 }, end: { line: line + 1, column: 1 } };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Minimal TOML: `[section]` / `[[section]]` headers with `key = "value"` lines.
|
|
26
|
+
function tomlSections(source) {
|
|
27
|
+
const sections = [];
|
|
28
|
+
let current = { name: '', values: {} };
|
|
29
|
+
sections.push(current);
|
|
30
|
+
for (const raw of source.split('\n')) {
|
|
31
|
+
const line = raw.replace(/#.*$/u, '').trim();
|
|
32
|
+
const header = line.match(/^\[\[?\s*([^\]]+?)\s*\]\]?$/u);
|
|
33
|
+
if (header) { current = { name: header[1], values: {} }; sections.push(current); continue; }
|
|
34
|
+
const pair = line.match(/^"?([^"=\s]+)"?\s*=\s*["']([^"']*)["']/u);
|
|
35
|
+
if (pair) current.values[pair[1]] = pair[2];
|
|
36
|
+
}
|
|
37
|
+
return sections;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Discovers language-level entry points from manifests, conventions, Program
|
|
42
|
+
* operations and adapter observations. Function identities are attached only
|
|
43
|
+
* when a call web exists (`attachEntryFunctions`).
|
|
44
|
+
*/
|
|
45
|
+
export async function discoverEntryPoints({ projectRoot, architecture, functions = [], program = null }) {
|
|
46
|
+
const files = new Map(architecture.files.map(file => [file.path, file]));
|
|
47
|
+
const sources = new Map();
|
|
48
|
+
async function read(filePath) {
|
|
49
|
+
if (sources.has(filePath)) return sources.get(filePath);
|
|
50
|
+
let source = null;
|
|
51
|
+
const file = files.get(filePath);
|
|
52
|
+
if (file) {
|
|
53
|
+
try {
|
|
54
|
+
const bytes = await readFile(path.join(projectRoot, filePath));
|
|
55
|
+
if (bytes.length <= MAX_MANIFEST_BYTES && sha256(bytes) === file.hash) source = bytes.toString('utf8');
|
|
56
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EISDIR'].includes(error.code)) throw error; }
|
|
57
|
+
}
|
|
58
|
+
sources.set(filePath, source);
|
|
59
|
+
return source;
|
|
60
|
+
}
|
|
61
|
+
const entries = new Map();
|
|
62
|
+
const evidence = (filePath, range) => [{ path: filePath, hash: files.get(filePath).hash, ...range }];
|
|
63
|
+
function add(record, target = null) {
|
|
64
|
+
if (!files.has(record.file) || entries.has(record.id)) return;
|
|
65
|
+
entries.set(record.id, { resolution: 'resolved', functionIds: [], pageId: null, componentId: null, operationKey: null, actionId: null, ...record, target });
|
|
66
|
+
}
|
|
67
|
+
const join = (directory, value) => path.posix.normalize(path.posix.join(directory, String(value).replace(/^\.\//u, '')));
|
|
68
|
+
const byFile = new Map();
|
|
69
|
+
for (const entry of functions) {
|
|
70
|
+
if (!byFile.has(entry.path)) byFile.set(entry.path, []);
|
|
71
|
+
byFile.get(entry.path).push(entry);
|
|
72
|
+
}
|
|
73
|
+
const paths = [...files.keys()];
|
|
74
|
+
const manifests = name => paths.filter(filePath => path.posix.basename(filePath) === name);
|
|
75
|
+
let libraryExports = 0;
|
|
76
|
+
|
|
77
|
+
for (const manifest of manifests('package.json')) {
|
|
78
|
+
const source = await read(manifest);
|
|
79
|
+
let document;
|
|
80
|
+
try { document = JSON.parse(source); } catch { continue; }
|
|
81
|
+
const bin = typeof document?.bin === 'string' ? { [String(document.name || '').split('/').at(-1) || path.posix.basename(document.bin)]: document.bin } : document?.bin;
|
|
82
|
+
for (const [name, value] of Object.entries(bin && typeof bin === 'object' ? bin : {})) {
|
|
83
|
+
if (typeof value !== 'string') continue;
|
|
84
|
+
const file = join(path.posix.dirname(manifest), value);
|
|
85
|
+
add({ id: `entry:cli-command:${manifest}#bin/${name}`, kind: 'cli-command', title: name, file, evidence: evidence(manifest, lineRange(source, `"${name}"`)) }, { module: file });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const manifest of manifests('pyproject.toml')) {
|
|
89
|
+
const source = await read(manifest);
|
|
90
|
+
if (source === null) continue;
|
|
91
|
+
const directory = path.posix.dirname(manifest);
|
|
92
|
+
const moduleFile = module => ['', 'src/'].flatMap(prefix => [`${prefix}${module.replaceAll('.', '/')}.py`, `${prefix}${module.replaceAll('.', '/')}/__init__.py`])
|
|
93
|
+
.map(candidate => join(directory, candidate)).find(candidate => files.has(candidate));
|
|
94
|
+
for (const section of tomlSections(source)) {
|
|
95
|
+
if (section.name === 'project.scripts') {
|
|
96
|
+
for (const [name, value] of Object.entries(section.values)) {
|
|
97
|
+
const [module, attribute] = value.split(':');
|
|
98
|
+
const file = moduleFile(module.trim());
|
|
99
|
+
if (file) add({ id: `entry:cli-command:${manifest}#scripts/${name}`, kind: 'cli-command', title: name, file, evidence: evidence(manifest, lineRange(source, name)) }, { name: attribute?.trim().split('.')[0], file });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (section.name === 'project' && section.values.name) {
|
|
103
|
+
const file = moduleFile(section.values.name.toLowerCase().replace(/[-.]+/gu, '_'));
|
|
104
|
+
if (file?.endsWith('__init__.py')) {
|
|
105
|
+
for (const entry of (byFile.get(file) || []).filter(entry => entry.visibility === 'public' && !entry.container)) {
|
|
106
|
+
libraryExports++;
|
|
107
|
+
add({ id: `entry:library-export:${file}#${entry.name}`, kind: 'library-export', title: entry.name, file, evidence: evidence(manifest, lineRange(source, 'name')) }, { name: entry.name, file });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const manifest of manifests('Cargo.toml')) {
|
|
114
|
+
const source = await read(manifest);
|
|
115
|
+
if (source === null) continue;
|
|
116
|
+
const directory = path.posix.dirname(manifest);
|
|
117
|
+
const sections = tomlSections(source);
|
|
118
|
+
for (const section of sections.filter(entry => entry.name === 'bin' && entry.values.name)) {
|
|
119
|
+
const file = join(directory, section.values.path || `src/bin/${section.values.name}.rs`);
|
|
120
|
+
add({ id: `entry:cli-command:${manifest}#bin/${section.values.name}`, kind: 'cli-command', title: section.values.name, file, evidence: evidence(manifest, lineRange(source, section.values.name)) }, { name: 'main', file });
|
|
121
|
+
}
|
|
122
|
+
const main = join(directory, 'src/main.rs');
|
|
123
|
+
const crate = sections.find(entry => entry.name === 'package')?.values.name || path.posix.basename(directory === '.' ? projectRoot : directory);
|
|
124
|
+
add({ id: `entry:cli-command:${manifest}#main`, kind: 'cli-command', title: crate, file: main, evidence: evidence(manifest, lineRange(source, '[package]')) }, { name: 'main', file: main });
|
|
125
|
+
const library = join(directory, 'src/lib.rs');
|
|
126
|
+
for (const entry of (byFile.get(library) || []).filter(entry => entry.visibility === 'public' && !entry.container)) {
|
|
127
|
+
libraryExports++;
|
|
128
|
+
add({ id: `entry:library-export:${library}#${entry.name}`, kind: 'library-export', title: entry.name, file: library, evidence: evidence(manifest, lineRange(source, '[package]')) }, { name: entry.name, file: library });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const manifest of paths.filter(filePath => filePath.endsWith('.gemspec'))) {
|
|
132
|
+
const source = await read(manifest);
|
|
133
|
+
if (source === null) continue;
|
|
134
|
+
const list = source.match(/executables\s*=\s*(?:\[([^\]]*)\]|%w[[(]([^\])]*)[\])])/u);
|
|
135
|
+
const names = list ? (list[1] !== undefined ? [...list[1].matchAll(/['"]([^'"]+)['"]/gu)].map(match => match[1]) : list[2].trim().split(/\s+/u)) : [];
|
|
136
|
+
const bindir = source.match(/bindir\s*=\s*['"]([^'"]+)['"]/u)?.[1] || 'bin';
|
|
137
|
+
for (const name of names.filter(Boolean)) {
|
|
138
|
+
const file = join(path.posix.dirname(manifest), `${bindir}/${name}`);
|
|
139
|
+
add({ id: `entry:cli-command:${manifest}#executables/${name}`, kind: 'cli-command', title: name, file, evidence: evidence(manifest, lineRange(source, 'executables')) }, { module: file });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const manifest of manifests('composer.json')) {
|
|
143
|
+
const source = await read(manifest);
|
|
144
|
+
let document;
|
|
145
|
+
try { document = JSON.parse(source); } catch { continue; }
|
|
146
|
+
for (const value of [document?.bin].flat().filter(entry => typeof entry === 'string')) {
|
|
147
|
+
const file = join(path.posix.dirname(manifest), value);
|
|
148
|
+
add({ id: `entry:cli-command:${manifest}#bin/${value}`, kind: 'cli-command', title: path.posix.basename(value), file, evidence: evidence(manifest, lineRange(source, value)) }, { module: file });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Language entry functions: Go `package main`, Java/Kotlin, C#, C++ `main`.
|
|
152
|
+
const goPackages = new Map();
|
|
153
|
+
for (const [filePath, file] of files) {
|
|
154
|
+
if (file.language !== 'go') continue;
|
|
155
|
+
const source = await read(filePath);
|
|
156
|
+
goPackages.set(filePath, source?.match(/^\s*package\s+(\w+)/mu)?.[1] || null);
|
|
157
|
+
}
|
|
158
|
+
for (const entry of functions) {
|
|
159
|
+
const file = files.get(entry.path);
|
|
160
|
+
const main = (entry.language === 'go' && entry.name === 'main' && !entry.container && goPackages.get(entry.path) === 'main')
|
|
161
|
+
|| (entry.language === 'java' && entry.name === 'main' && entry.static)
|
|
162
|
+
|| (entry.language === 'kotlin' && entry.name === 'main' && !entry.container)
|
|
163
|
+
|| (entry.language === 'csharp' && entry.name === 'Main')
|
|
164
|
+
|| (entry.language === 'cpp' && entry.name === 'main' && !entry.container);
|
|
165
|
+
if (main && file) add({ id: `entry:cli-command:${entry.path}#${entry.name}`, kind: 'cli-command', title: entry.container || path.posix.basename(entry.path).replace(/\.[^.]+$/u, ''), file: entry.path, evidence: evidence(entry.path, entry.start && entry.end ? { start: entry.start, end: entry.end } : lineRange('', null)) }, { name: entry.name, file: entry.path });
|
|
166
|
+
if (entry.language === 'go' && entry.visibility === 'public' && !entry.container && !isTestFile(entry.path) && goPackages.get(entry.path) && goPackages.get(entry.path) !== 'main') {
|
|
167
|
+
libraryExports++;
|
|
168
|
+
add({ id: `entry:library-export:${entry.path}#${entry.name}`, kind: 'library-export', title: entry.name, file: entry.path, evidence: evidence(entry.path, entry.start && entry.end ? { start: entry.start, end: entry.end } : lineRange('', null)) }, { name: entry.name, file: entry.path });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const filePath of paths.filter(value => path.posix.basename(value) === 'Program.cs' && !(byFile.get(value) || []).some(entry => entry.name === 'Main'))) {
|
|
172
|
+
add({ id: `entry:cli-command:${filePath}#top-level`, kind: 'cli-command', title: path.posix.basename(path.posix.dirname(filePath)) || 'Program', file: filePath, evidence: evidence(filePath, lineRange(await read(filePath) || '', null)) }, { module: filePath });
|
|
173
|
+
}
|
|
174
|
+
const commandFiles = new Set([...entries.values()].filter(entry => entry.kind === 'cli-command').map(entry => entry.file));
|
|
175
|
+
for (const [filePath, file] of files) {
|
|
176
|
+
if (file.mode !== 0o100755 || commandFiles.has(filePath)) continue;
|
|
177
|
+
const source = await read(filePath);
|
|
178
|
+
if (!source?.startsWith('#!')) continue;
|
|
179
|
+
add({ id: `entry:cli-command:${filePath}`, kind: 'cli-command', title: path.posix.basename(filePath), file: filePath, evidence: evidence(filePath, lineRange(source, '#!')) }, { module: filePath });
|
|
180
|
+
commandFiles.add(filePath);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const entry of architecture.packages || []) {
|
|
184
|
+
if (entry.opaque) continue;
|
|
185
|
+
for (const exported of entry.entryPoints || []) {
|
|
186
|
+
const specifier = exported.importPath === '.' ? entry.name : `${entry.name}${exported.importPath.slice(1)}`;
|
|
187
|
+
for (const symbol of exported.symbols || []) {
|
|
188
|
+
if (symbol.kind !== 'function' || !files.has(symbol.sourceFile)) continue;
|
|
189
|
+
libraryExports++;
|
|
190
|
+
add({ id: `entry:library-export:${symbol.sourceFile}#${symbol.sourceName || symbol.name}`, kind: 'library-export', title: `${symbol.name} (${specifier})`, file: symbol.sourceFile, evidence: evidence(symbol.sourceFile, { start: symbol.start, end: symbol.end }) }, { name: symbol.sourceName || symbol.name, file: symbol.sourceFile, within: { start: symbol.start, end: symbol.end } });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!libraryExports) {
|
|
195
|
+
// Without a declared package API, public functions outside commands and tests stand in for it.
|
|
196
|
+
for (const entry of functions.filter(value => value.visibility === 'public' && !value.container && value.role !== 'test' && !isTestFile(value.path) && !commandFiles.has(value.path) && value.start && value.end)) {
|
|
197
|
+
add({ id: `entry:library-export:${entry.path}#${entry.name}`, kind: 'library-export', title: entry.name, file: entry.path, evidence: evidence(entry.path, { start: entry.start, end: entry.end }) }, { name: entry.name, file: entry.path, within: { start: entry.start, end: entry.end } });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const operation of program?.buildings || []) {
|
|
201
|
+
const cited = operation.sources.filter(source => files.has(source));
|
|
202
|
+
if (!cited.length) continue;
|
|
203
|
+
const file = cited.includes(operation.mainSource) ? operation.mainSource : cited[0];
|
|
204
|
+
const names = [...new Set([...String(operation.publicContract || '').matchAll(/`(?:[A-Za-z_$][\w$]*[.:]+)*([A-Za-z_$][\w$]*)\s*\(/gu)].map(match => match[1]))];
|
|
205
|
+
add({ id: `entry:program-operation:${operation.path}`, kind: 'program-operation', title: operation.title, file, evidence: evidence(files.has(operation.path) ? operation.path : file, lineRange('', null)) }, { files: cited, names, module: file });
|
|
206
|
+
}
|
|
207
|
+
for (const [filePath, file] of files) {
|
|
208
|
+
if (file.language !== 'unknown' && isTestFile(filePath)) add({ id: `entry:test:${filePath}`, kind: 'test', title: path.posix.basename(filePath), file: filePath, evidence: evidence(filePath, lineRange('', null)) }, { all: filePath });
|
|
209
|
+
}
|
|
210
|
+
const ui = architecture.ui;
|
|
211
|
+
// A page entry is named by its first registration (for a web page, its URL
|
|
212
|
+
// path), which says more than a title taken from a file such as `[slug].vue`.
|
|
213
|
+
const registered = new Map();
|
|
214
|
+
for (const registration of ui?.registrations || []) if (!registered.has(registration.pageId)) registered.set(registration.pageId, registration.identity);
|
|
215
|
+
for (const page of ui?.pages || []) add({ id: `entry:page:${page.id}`, kind: 'page', title: registered.get(page.id) || page.title, file: page.file, pageId: page.id, evidence: page.evidence });
|
|
216
|
+
for (const component of ui?.components || []) if (component.file) add({ id: `entry:component:${component.id}`, kind: 'component', title: component.title, file: component.file, componentId: component.id, evidence: component.evidence });
|
|
217
|
+
for (const operation of ui?.entryOperations || []) {
|
|
218
|
+
if (operation.resolution !== 'resolved' || !operation.controllerFile) continue;
|
|
219
|
+
add({ id: `entry:route:${operation.id}`, kind: 'route', title: [operation.key.method, operation.key.operation].filter(Boolean).join(' '), file: operation.controllerFile, operationKey: operation.key, actionId: operation.actionId ?? null, evidence: operation.evidence }, { ids: operation.controllerFunctionId ? [operation.controllerFunctionId] : [] });
|
|
220
|
+
}
|
|
221
|
+
for (const record of ui?.entryPoints || []) {
|
|
222
|
+
add({ id: record.id.startsWith('entry:') ? record.id : `entry:${record.kind}:${record.id}`, kind: record.kind, title: record.title, file: record.file, pageId: record.pageId ?? null, componentId: record.componentId ?? null, operationKey: record.operationKey ?? null, actionId: record.actionId ?? null, evidence: record.evidence }, { ids: record.functionIds });
|
|
223
|
+
}
|
|
224
|
+
return capEntryPoints([...entries.values()]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Applies the 20,000 total / 5,000 test caps in a stable order. */
|
|
228
|
+
export function capEntryPoints(entries, { total = MAX_ENTRY_POINTS, tests = MAX_TEST_ENTRY_POINTS } = {}) {
|
|
229
|
+
const sorted = [...entries].sort((left, right) => KIND_ORDER.get(left.kind) - KIND_ORDER.get(right.kind) || left.file.localeCompare(right.file) || left.id.localeCompare(right.id));
|
|
230
|
+
let testCount = 0;
|
|
231
|
+
const kept = sorted.filter(entry => entry.kind !== 'test' || ++testCount <= tests).slice(0, total);
|
|
232
|
+
const truncated = kept.length < sorted.length;
|
|
233
|
+
return {
|
|
234
|
+
entries: kept,
|
|
235
|
+
capability: truncated ? 'partial' : 'complete',
|
|
236
|
+
diagnostics: truncated ? [{ code: 'ENTRY_POINTS_TRUNCATED', message: `Only ${kept.length} of ${sorted.length} entry points are listed (at most ${total}, of which ${tests} tests).` }] : [],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Fills `functionIds` from a call web; without one every list stays empty. */
|
|
241
|
+
export function attachEntryFunctions(entries, callWeb) {
|
|
242
|
+
const byFile = new Map();
|
|
243
|
+
if (callWeb) {
|
|
244
|
+
for (const entry of callWeb.functions) {
|
|
245
|
+
const filePath = callWeb.files[entry.file].path;
|
|
246
|
+
if (!byFile.has(filePath)) byFile.set(filePath, []);
|
|
247
|
+
byFile.get(filePath).push(entry);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const known = new Set(callWeb ? callWeb.functions.map(entry => entry.id) : []);
|
|
251
|
+
const inside = (entry, range) => !range || ((entry.start.line > range.start.line || (entry.start.line === range.start.line && entry.start.column >= range.start.column))
|
|
252
|
+
&& (entry.start.line < range.end.line || (entry.start.line === range.end.line && entry.start.column < range.end.column)));
|
|
253
|
+
const module = filePath => (byFile.get(filePath) || []).filter(entry => entry.kind === 'module').map(entry => entry.id);
|
|
254
|
+
return entries.map(({ target, ...entry }) => {
|
|
255
|
+
let ids = [];
|
|
256
|
+
if (callWeb && target) {
|
|
257
|
+
if (target.ids) ids = target.ids.filter(id => known.has(id));
|
|
258
|
+
else if (target.all) ids = (byFile.get(target.all) || []).map(fn => fn.id);
|
|
259
|
+
else if (target.files) {
|
|
260
|
+
ids = target.files.flatMap(filePath => (byFile.get(filePath) || []).filter(fn => fn.name && target.names.includes(fn.name)).map(fn => fn.id));
|
|
261
|
+
if (!ids.length) ids = module(target.module);
|
|
262
|
+
} else if (target.name) {
|
|
263
|
+
const candidates = (byFile.get(target.file) || []).filter(fn => fn.name === target.name && inside(fn, target.within));
|
|
264
|
+
ids = candidates.length ? [candidates[0].id] : module(target.file);
|
|
265
|
+
} else if (target.module) ids = module(target.module);
|
|
266
|
+
}
|
|
267
|
+
return { ...entry, functionIds: [...new Set(ids)] };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, readFile, readdir, rm, rmdir, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { runGitText } from './process.js';
|
|
6
|
+
import { writeFileAtomic } from './utils.js';
|
|
7
|
+
|
|
8
|
+
export const EXTRACTION_CACHE_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const STORE_BATCH = 64;
|
|
10
|
+
|
|
11
|
+
// Git never lists its own directory, so worktrees of one repository share
|
|
12
|
+
// these entries without an ignore line. Each kind of extraction has its own root.
|
|
13
|
+
export async function extractionCacheRoot(projectRoot, kind) {
|
|
14
|
+
const common = await runGitText(projectRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
15
|
+
return path.join(common, 'genesis-cache', kind);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One entry per extractor version, language and parsed content. */
|
|
19
|
+
export function extractionCachePath(root, { version, language, variant, hash }) {
|
|
20
|
+
const key = createHash('sha256').update(`${variant}\n${hash}`).digest('hex');
|
|
21
|
+
return path.join(root, String(version), language, `${key}.json`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Reads cached extractions; unreadable or missing entries are extracted again. */
|
|
25
|
+
export async function loadExtractions(root, entries) {
|
|
26
|
+
const values = new Map();
|
|
27
|
+
const misses = [];
|
|
28
|
+
for (let index = 0; index < entries.length; index += STORE_BATCH) {
|
|
29
|
+
await Promise.all(entries.slice(index, index + STORE_BATCH).map(async entry => {
|
|
30
|
+
try { values.set(entry.path, JSON.parse(await readFile(entry.location, 'utf8'))); }
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!(error instanceof SyntaxError) && !['ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
|
|
33
|
+
misses.push(entry);
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
return { values, misses: misses.sort((left, right) => left.path.localeCompare(right.path)) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Entries without a cache file. Only presence is checked; nothing is read. */
|
|
41
|
+
export async function absentExtractions(entries) {
|
|
42
|
+
const absent = [];
|
|
43
|
+
for (let index = 0; index < entries.length; index += STORE_BATCH) {
|
|
44
|
+
await Promise.all(entries.slice(index, index + STORE_BATCH).map(async entry => {
|
|
45
|
+
try { await access(entry.location); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
|
|
48
|
+
absent.push(entry);
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
return absent.sort((left, right) => left.path.localeCompare(right.path));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function storeExtractions(entries) {
|
|
56
|
+
for (let index = 0; index < entries.length; index += STORE_BATCH) {
|
|
57
|
+
await Promise.all(entries.slice(index, index + STORE_BATCH).map(({ location, value }) => writeFileAtomic(location, JSON.stringify(value))));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Deletes entries this snapshot does not use once they are older than the retention period. */
|
|
62
|
+
export async function pruneExtractions(root, referenced, { now = Date.now(), retentionMs = EXTRACTION_CACHE_RETENTION_MS } = {}) {
|
|
63
|
+
const keep = new Set(referenced);
|
|
64
|
+
let removed = 0;
|
|
65
|
+
async function visit(directory, depth) {
|
|
66
|
+
let names;
|
|
67
|
+
try { names = await readdir(directory); }
|
|
68
|
+
catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return; throw error; }
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
const location = path.join(directory, name);
|
|
71
|
+
if (depth < 2) { await visit(location, depth + 1); continue; }
|
|
72
|
+
if (keep.has(location)) continue;
|
|
73
|
+
try {
|
|
74
|
+
if (now - (await stat(location)).mtimeMs > retentionMs) { await rm(location, { force: true }); removed++; }
|
|
75
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error; }
|
|
76
|
+
}
|
|
77
|
+
if (depth > 0) await rmdir(directory).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
await visit(root, 0);
|
|
80
|
+
return { removed };
|
|
81
|
+
}
|
|
@@ -15,11 +15,15 @@ async function visiblePaths(projectRoot) {
|
|
|
15
15
|
.sort();
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const STATE_BATCH = 32;
|
|
19
|
+
|
|
18
20
|
/** Hashable states for ordinary Git-visible files, excluding ignored output. */
|
|
19
21
|
export async function gitVisibleFileStates(projectRoot, { includePath = () => true } = {}) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
const files = (await visiblePaths(projectRoot)).filter(includePath);
|
|
23
|
+
// Entries keep path order while a bounded batch of files is read at once.
|
|
24
|
+
const states = new Map(files.map(file => [file, null]));
|
|
25
|
+
for (let index = 0; index < files.length; index += STATE_BATCH) {
|
|
26
|
+
await Promise.all(files.slice(index, index + STATE_BATCH).map(async file => states.set(file, await pathState(path.join(projectRoot, file)))));
|
|
23
27
|
}
|
|
24
28
|
return states;
|
|
25
29
|
}
|
|
@@ -24,6 +24,7 @@ export const GENESIS_BOOTSTRAP_PATHS = Object.freeze([
|
|
|
24
24
|
'genesis/engineering.md',
|
|
25
25
|
'.genesis/machine-city.json',
|
|
26
26
|
'.genesis/program-city.json',
|
|
27
|
+
'.genesis/call-web.json',
|
|
27
28
|
'.codex/hooks.json',
|
|
28
29
|
'.claude/settings.json',
|
|
29
30
|
'.opencode/plugins/genesis-project-guidance.js',
|
|
@@ -36,11 +37,13 @@ export const GENESIS_BOOTSTRAP_PATHS = Object.freeze([
|
|
|
36
37
|
]);
|
|
37
38
|
const bootstrapPaths = new Set(GENESIS_BOOTSTRAP_PATHS);
|
|
38
39
|
// Genesis creates .gitignore only to ignore its Cities; any other content is the project's.
|
|
40
|
+
// Earlier releases wrote fewer derived-artifact lines; any such subset is still Genesis's.
|
|
39
41
|
async function isBootstrapGitignore(projectRoot, paths) {
|
|
40
42
|
if (!paths.includes('.gitignore')) return false;
|
|
41
43
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
const lines = (await readFile(path.join(projectRoot, '.gitignore'), 'utf8')).split('\n');
|
|
45
|
+
return lines.pop() === '' && lines.length > 0 && new Set(lines).size === lines.length
|
|
46
|
+
&& lines.every(line => GENESIS_DERIVED_ARTIFACTS_GITIGNORE_LINES.includes(line));
|
|
44
47
|
} catch (error) {
|
|
45
48
|
if (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(error?.code)) return false;
|
|
46
49
|
throw error;
|
|
@@ -64,7 +64,7 @@ async function installedCatalogs(stackPackages, projectRoot) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** Catalogues are explicit data sources. A Stack or Skill never implicitly installs a template. */
|
|
67
|
-
export async function listTemplates({ projectRoot = process.cwd(), stackPackages = [], templateSources = [] } = {}) {
|
|
67
|
+
export async function listTemplates({ projectRoot = process.cwd(), stackPackages = [], templateSources = [], scratchRoot } = {}) {
|
|
68
68
|
let recorded = [];
|
|
69
69
|
try { recorded = (await readStack(projectRoot, { stackPackages })).stackPackages; } catch { /* Explicit sources also work before init. */ }
|
|
70
70
|
stackPackages = [...new Set([...recorded, ...stackPackages])];
|
|
@@ -78,7 +78,7 @@ export async function listTemplates({ projectRoot = process.cwd(), stackPackages
|
|
|
78
78
|
namespaces.add(source.namespace);
|
|
79
79
|
let catalog = source.catalog;
|
|
80
80
|
if (!catalog) {
|
|
81
|
-
const snapshot = await readGitSnapshot({ repository: source.repository, branch: source.branch || 'main' });
|
|
81
|
+
const snapshot = await readGitSnapshot({ repository: source.repository, branch: source.branch || 'main', scratchRoot });
|
|
82
82
|
const file = snapshot.files.find(({ path: name }) => name === 'genesis.templates.json');
|
|
83
83
|
if (!file) throw templateCatalogError(`${source.namespace} does not contain genesis.templates.json.`);
|
|
84
84
|
try { catalog = JSON.parse(file.contents.toString('utf8')); }
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm,
|
|
2
|
-
import os from 'node:os';
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, writeFile } from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { parseBlueprintSource } from './blueprint.js';
|
|
5
4
|
import { buildProjectIndex } from './code-index.js';
|
|
@@ -79,22 +78,23 @@ async function assertSeedable(root, stackPackages) {
|
|
|
79
78
|
/** Apply one complete source tree without changing Git history or running application commands. */
|
|
80
79
|
export async function applyTemplate({ projectRoot, templateId, stackPackages = [], templateSources = [] } = {}) {
|
|
81
80
|
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
81
|
+
// The lock directory also holds the scratch repositories, so a host that
|
|
82
|
+
// confines Git to the project accepts them and they never outlive the lock.
|
|
82
83
|
const lockPath = path.resolve(root, await runGitText(root, ['rev-parse', '--git-path', 'genesis-template.lock']));
|
|
83
84
|
try { await mkdir(lockPath); }
|
|
84
85
|
catch (error) {
|
|
85
86
|
if (error.code === 'EEXIST') throw new GenesisError('TEMPLATE_PROJECT_BUSY', 'Another template operation holds this project lock.');
|
|
86
87
|
throw error;
|
|
87
88
|
}
|
|
88
|
-
let stagingRoot;
|
|
89
89
|
try {
|
|
90
90
|
await assertSeedable(root, stackPackages);
|
|
91
|
-
const { templates } = await listTemplates({ projectRoot: root, stackPackages, templateSources });
|
|
91
|
+
const { templates } = await listTemplates({ projectRoot: root, stackPackages, templateSources, scratchRoot: lockPath });
|
|
92
92
|
const template = resolveTemplate(templates, String(templateId || ''));
|
|
93
93
|
const preserved = new Map();
|
|
94
94
|
// A seedable project's .gitignore can only be the one init wrote for the Cities.
|
|
95
95
|
for (const name of [...GENESIS_BOOTSTRAP_PATHS, '.gitignore']) preserved.set(name, await existingFile(root, name));
|
|
96
|
-
const snapshot = await readGitSnapshot(template);
|
|
97
|
-
stagingRoot = await mkdtemp(path.join(
|
|
96
|
+
const snapshot = await readGitSnapshot({ ...template, scratchRoot: lockPath });
|
|
97
|
+
const stagingRoot = await mkdtemp(path.join(lockPath, 'genesis-template-project-'));
|
|
98
98
|
await runGit(stagingRoot, ['init', '--quiet']);
|
|
99
99
|
for (const file of snapshot.files) {
|
|
100
100
|
if (file.path.split('/').includes('node_modules') || file.path.split('/').some((part) => part === '.env' || part.startsWith('.env.') && part !== '.env.example')) {
|
|
@@ -168,7 +168,6 @@ export async function applyTemplate({ projectRoot, templateId, stackPackages = [
|
|
|
168
168
|
guidance: 'The starting application is ready for its declared workspace setup. No application commands or Git commits were run.',
|
|
169
169
|
};
|
|
170
170
|
} finally {
|
|
171
|
-
|
|
172
|
-
await rmdir(lockPath);
|
|
171
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
173
172
|
}
|
|
174
173
|
}
|
|
@@ -17,13 +17,16 @@ function validateRepository(repository) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Fetch a branch once and read ordinary blobs; never execute or check out remote hooks/filters.
|
|
22
|
+
* The scratch repository is created below scratchRoot (the system temporary directory by default).
|
|
23
|
+
*/
|
|
24
|
+
export async function readGitSnapshot({ repository, branch, scratchRoot = os.tmpdir() }) {
|
|
22
25
|
validateRepository(repository);
|
|
23
26
|
if (typeof branch !== 'string' || !branch || branch.startsWith('-')) {
|
|
24
27
|
throw new GenesisError('TEMPLATE_SOURCE_INVALID', 'A template branch is required.');
|
|
25
28
|
}
|
|
26
|
-
const temporaryRoot = await mkdtemp(path.join(
|
|
29
|
+
const temporaryRoot = await mkdtemp(path.join(scratchRoot, 'genesis-template-source-'));
|
|
27
30
|
try {
|
|
28
31
|
await runGit(temporaryRoot, ['init', '--bare', '--quiet']);
|
|
29
32
|
await runGit(temporaryRoot, ['check-ref-format', `refs/heads/${branch}`]);
|
package/src/index/utils.js
CHANGED
|
@@ -25,6 +25,40 @@ export function stableJson(value, { space = 2 } = {}) {
|
|
|
25
25
|
return `${JSON.stringify(stable(value), null, space)}\n`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const omittedJson = value => value === undefined || typeof value === 'function' || typeof value === 'symbol';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The text of stableJson(value, { space: 0 }) as consecutive parts. Members of
|
|
32
|
+
* the top two object levels and elements of the top three array levels are
|
|
33
|
+
* separate parts, so a large document is never copied or held whole.
|
|
34
|
+
*/
|
|
35
|
+
export function* stableJsonParts(value, depth = 0) {
|
|
36
|
+
if (Array.isArray(value) && depth < 3) {
|
|
37
|
+
yield '[';
|
|
38
|
+
for (let index = 0; index < value.length; index++) {
|
|
39
|
+
if (index) yield ',';
|
|
40
|
+
if (omittedJson(value[index])) yield 'null';
|
|
41
|
+
else yield* stableJsonParts(value[index], depth + 1);
|
|
42
|
+
}
|
|
43
|
+
yield ']';
|
|
44
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value) && depth < 2) {
|
|
45
|
+
// The member order of the copy stable() builds, including integer-like keys.
|
|
46
|
+
const sorted = Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]]));
|
|
47
|
+
let separator = '';
|
|
48
|
+
yield '{';
|
|
49
|
+
for (const [key, item] of Object.entries(sorted)) {
|
|
50
|
+
if (omittedJson(item)) continue;
|
|
51
|
+
yield `${separator}${JSON.stringify(key)}:`;
|
|
52
|
+
separator = ',';
|
|
53
|
+
yield* stableJsonParts(item, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
yield '}';
|
|
56
|
+
} else {
|
|
57
|
+
yield JSON.stringify(stable(value));
|
|
58
|
+
}
|
|
59
|
+
if (depth === 0) yield '\n';
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
export function uniqueSorted(values = []) {
|
|
29
63
|
return [...new Set(values)].sort();
|
|
30
64
|
}
|
package/src/index.js
CHANGED
|
@@ -202,9 +202,10 @@ export function indexCodebase({
|
|
|
202
202
|
stackPackages = [],
|
|
203
203
|
write = true,
|
|
204
204
|
observations = null,
|
|
205
|
+
callWeb = false,
|
|
205
206
|
signal,
|
|
206
207
|
} = {}) {
|
|
207
|
-
return buildProjectIndex({ projectRoot, queries, stackPackages, write, observations, signal });
|
|
208
|
+
return buildProjectIndex({ projectRoot, queries, stackPackages, write, observations, callWeb, signal });
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
export function inspectDerivedArtifacts() {
|
|
@@ -246,6 +247,7 @@ export { inspectSubsystems } from './index/subsystems.js';
|
|
|
246
247
|
export { inspectOpenCodePlugin, installOpenCodePlugin as syncOpenCodePlugin } from './index/opencode-plugin.js';
|
|
247
248
|
|
|
248
249
|
export { ARCHITECTURE_OBSERVATIONS_SCHEMA, validateArchitectureObservations, connectArchitectureObservations, mergeArchitectureObservations } from './index/architecture-observations.js';
|
|
250
|
+
export { CALL_WEB_SCHEMA, validateCallWeb, readCallWeb } from './index/call-web.js';
|
|
249
251
|
|
|
250
252
|
export { validateArchitectureFacts } from './index/architecture.js';
|
|
251
253
|
export { CITY_SCHEMA_VERSION, MACHINE_CITY_SCHEMA, PROGRAM_CITY_SCHEMA, validateCityDocument } from './index/city-documents.js';
|