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.
@@ -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
- const result = candidates.length ? await extractCodeIndexes({ indexers: ['javascript'], files: candidates, projectRoot, signal }) : { contributions: [], diagnostics: [] };
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 { parseAsync, registerDynamicLanguage } from '@ast-grep/napi';
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
- files: input.files, projectRoot: input.projectRoot, parseAsync, parserErrors: input.errors,
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
  }
@@ -74,6 +74,18 @@ const DIMENSIONS = {
74
74
  guidance: 'Explain the practical reasoning behind decisions in a teaching-oriented way.',
75
75
  },
76
76
  },
77
+ longChecks: {
78
+ conservative: {
79
+ name: 'Conservative',
80
+ description: 'Saves the most tokens; results arrive only when each check ends.',
81
+ guidance: 'Run each long check as one unattended command, bounded by a time limit, that writes its complete output to a local file and exits with its status, so it ends with a single completion signal. Do not watch it while it runs: no progress monitors, log tails, repeated status checks or interim reports. Pass this on to any agent you delegate checks to.',
82
+ },
83
+ normal: {
84
+ name: 'Normal',
85
+ description: 'Reacts sooner, uses more tokens.',
86
+ guidance: 'You may follow a long check\'s progress while it runs and stop a clearly broken run early. Still keep its full output in files and read only what you need.',
87
+ },
88
+ },
77
89
  };
78
90
 
79
91
  const SECTION_FIELDS = Object.freeze({
@@ -81,8 +93,13 @@ const SECTION_FIELDS = Object.freeze({
81
93
  'Response length': 'responseLength',
82
94
  'Assumed experience': 'experience',
83
95
  'Explanation style': 'explanationStyle',
96
+ 'Long checks': 'longChecks',
84
97
  });
85
98
 
99
+ // A missing optional section is an unmade choice, not a default.
100
+ const OPTIONAL_SECTIONS = new Set(['Long checks']);
101
+ const LONG_CHECKS_UNTIL_CHOSEN = 'conservative';
102
+
86
103
  export const DEFAULT_COLLABORATION = Object.freeze({
87
104
  tone: 'encouraging',
88
105
  responseLength: 'concise',
@@ -196,6 +213,15 @@ function collaborationCatalog() {
196
213
  ]));
197
214
  }
198
215
 
216
+ function unsetLongChecksGuidance(value) {
217
+ const choices = DIMENSIONS.longChecks;
218
+ return [
219
+ 'The project has not chosen how agents handle long checks such as test suites and builds. Ask the person once, early, before running the first long check, which option they want:',
220
+ ...Object.entries(choices).map(([id, choice]) => `- \`${id}\`: ${choice.description}`),
221
+ `Record the answer with \`genesis collaboration set ${value.tone} ${value.responseLength} ${value.experience} ${value.explanationStyle} <${Object.keys(choices).join('|')}>\`. Until the person answers, use \`${LONG_CHECKS_UNTIL_CHOSEN}\`: ${choices[LONG_CHECKS_UNTIL_CHOSEN].guidance}`,
222
+ ].join('\n');
223
+ }
224
+
199
225
  function collaborationGuidance(value) {
200
226
  return [
201
227
  'Apply the configured tone, response length, assumed experience, and explanation style to every user-facing message, including progress updates and final responses.',
@@ -217,6 +243,10 @@ function collaborationGuidance(value) {
217
243
  '## Explanation style',
218
244
  '',
219
245
  DIMENSIONS.explanationStyle[value.explanationStyle].guidance,
246
+ '',
247
+ '## Long checks',
248
+ '',
249
+ value.longChecks ? DIMENSIONS.longChecks[value.longChecks].guidance : unsetLongChecksGuidance(value),
220
250
  ...(value.requirements ? [
221
251
  '',
222
252
  '## Project requirements',
@@ -231,17 +261,17 @@ export function parseCollaborationSource(source) {
231
261
  const parsed = sections(source);
232
262
  const expected = new Set([...Object.keys(SECTION_FIELDS), 'Project requirements']);
233
263
  const unknown = [...parsed.sections.keys()].filter((name) => !expected.has(name));
234
- const missing = [...expected].filter((name) => !parsed.sections.has(name));
264
+ const missing = [...expected].filter((name) => !parsed.sections.has(name) && !OPTIONAL_SECTIONS.has(name));
235
265
  if (unknown.length > 0 || missing.length > 0) {
236
266
  throw new GenesisError(
237
267
  'COLLABORATION_INVALID',
238
- `${COLLABORATION_PATH} needs only Tone, Response length, Assumed experience, Explanation style, and Project requirements sections.`,
268
+ `${COLLABORATION_PATH} needs only Tone, Response length, Assumed experience, Explanation style, optional Long checks, and Project requirements sections.`,
239
269
  { path: COLLABORATION_PATH },
240
270
  );
241
271
  }
242
272
  const values = Object.fromEntries(Object.entries(SECTION_FIELDS).map(([sectionName, field]) => [
243
273
  field,
244
- dimensionValue(parsed.sections, sectionName, field),
274
+ parsed.sections.has(sectionName) ? dimensionValue(parsed.sections, sectionName, field) : null,
245
275
  ]));
246
276
  const requirementsSource = sectionText(parsed.sections, 'Project requirements');
247
277
  if (!requirementsSource) {
@@ -296,6 +326,7 @@ function renderCollaboration(value) {
296
326
  '',
297
327
  `- \`${value.explanationStyle}\``,
298
328
  '',
329
+ ...(value.longChecks === null ? [] : ['## Long checks', '', `- \`${value.longChecks}\``, '']),
299
330
  '## Project requirements',
300
331
  '',
301
332
  value.requirements || '- Nothing.',
@@ -340,6 +371,7 @@ export async function inspectProjectCollaboration({ projectRoot } = {}) {
340
371
  export async function setProjectCollaboration({
341
372
  experience,
342
373
  explanationStyle,
374
+ longChecks,
343
375
  projectRoot,
344
376
  requirements,
345
377
  responseLength,
@@ -352,6 +384,7 @@ export async function setProjectCollaboration({
352
384
  responseLength: responseLength ?? current.responseLength,
353
385
  experience: experience ?? current.experience,
354
386
  explanationStyle: explanationStyle ?? current.explanationStyle,
387
+ longChecks: longChecks ?? current.longChecks,
355
388
  requirements: requirements ?? current.requirements,
356
389
  }));
357
390
  const rendered = renderCollaboration(next);
@@ -367,6 +400,7 @@ export async function setProjectCollaboration({
367
400
  responseLength: next.responseLength,
368
401
  experience: next.experience,
369
402
  explanationStyle: next.explanationStyle,
403
+ longChecks: next.longChecks,
370
404
  requirements: next.requirements,
371
405
  choices: collaborationCatalog(),
372
406
  guidance: collaborationGuidance(next),
@@ -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 states = new Map();
21
- for (const file of (await visiblePaths(projectRoot)).filter(includePath)) {
22
- states.set(file, await pathState(path.join(projectRoot, file)));
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
- return await readFile(path.join(projectRoot, '.gitignore'), 'utf8')
43
- === `${GENESIS_DERIVED_ARTIFACTS_GITIGNORE_LINES.join('\n')}\n`;
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;
@@ -167,6 +167,7 @@ function collaborationPromptContext(collaboration) {
167
167
  responseLength: collaboration.responseLength,
168
168
  experience: collaboration.experience,
169
169
  explanationStyle: collaboration.explanationStyle,
170
+ longChecks: collaboration.longChecks || 'unset',
170
171
  requirements: collaboration.requirements || 'Nothing.',
171
172
  };
172
173
  }
@@ -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';