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 CHANGED
@@ -39,6 +39,7 @@ genesis/
39
39
  .genesis/ local, git-ignored (`genesis init` adds the ignore lines)
40
40
  machine-city.json derived detailed source and function map
41
41
  program-city.json derived simple subsystem and operation map
42
+ call-web.json derived function call web (`genesis index --call-web`)
42
43
  ```
43
44
 
44
45
  Program paths describe public operations rather than languages or source
@@ -253,7 +254,7 @@ files alone does not refresh its existing context.
253
254
  | `genesis stack add <piece...>` | Resolves component dependencies/conflicts, records the selection, materializes missing project contracts, synchronizes declared skills, and prints the one-time preparation prompt. It does not install application packages or run an agent. |
254
255
  | `genesis skills sync` | Refreshes selected, unmodified managed skills while preserving customized and unmanaged copies. |
255
256
  | `genesis context <path...>` | Returns path-focused Program explanations, indexed functions, selected technology guidance, exact project contracts, and relevant skills. |
256
- | `genesis index [function-or-path...]` | Regenerates the derived code indexes and optionally searches the existing function/method inventory. |
257
+ | `genesis index [--call-web] [function-or-path...]` | Regenerates the derived code indexes and optionally searches the existing function/method inventory. `--call-web` also writes the derived function call web. |
257
258
  | `genesis inspect environment` | Reports declared resources, public defaults, missing input names, and environment-file paths without returning secret values. |
258
259
  | `genesis inspect skills` | Reports selected skill freshness and ownership without changing files. |
259
260
  | `genesis inspect section <name>` | Returns one exact consumer-owned Stack section without interpreting or executing it. |
@@ -264,8 +265,10 @@ files alone does not refresh its existing context.
264
265
 
265
266
  Project-scoped options follow the command, for example
266
267
  `genesis check --project-root /path/to/project`. Commands support `--json` when
267
- a host or script needs the normalized result. Run `genesis --help` for the
268
- compact CLI synopsis.
268
+ a host or script needs the normalized result. A failed command prints its
269
+ diagnostic code and message on standard error; when the failure came from a
270
+ child process such as Git, its bounded output follows, so the actual reason is
271
+ visible without `--json`. Run `genesis --help` for the compact CLI synopsis.
269
272
 
270
273
  ## Collaboration approach
271
274
 
@@ -463,6 +466,18 @@ extractor warning does not stop ordinary work. They are rebuilt from the
463
466
  working tree, so they are not committed: `genesis init` adds both paths to the
464
467
  project's `.gitignore`.
465
468
 
469
+ Machine City also lists entry points: commands, library exports, Program
470
+ operations, tests and inspector-derived pages, components, routes, actions and
471
+ jobs. A page entry is titled by its first registration, such as the URL path
472
+ `/app/project/:slug`, else by the page's title. `genesis index --call-web`
473
+ additionally writes `.genesis/call-web.json`,
474
+ every function site and call site with its `probable`, `exact` or `unresolved`
475
+ targets. The generic baseline is syntax and import scope only, so it is never
476
+ `exact`; an inspector may supply exact facts for its files. The call web is
477
+ written only when Git ignores its path (`genesis init` adds the line). Per-file
478
+ extraction, including installed packages' public entry files, is cached inside
479
+ the Git directory, so unchanged files are not parsed again.
480
+
466
481
  Local package exports whose entry files are excluded from indexing retain their
467
482
  declared import paths with unavailable symbol coverage and a diagnostic. They do
468
483
  not invalidate the rest of the architecture. After upgrading Genesis, run
@@ -986,6 +1001,9 @@ async callback receiving the fresh architecture facts. A host can run its finite
986
1001
  inspector inside that callback, avoiding a second source extraction. Genesis
987
1002
  joins the result and rechecks source, Stack, subsystem and package inputs before
988
1003
  writing compact derived JSON; failures and cancellation retain the prior index.
1004
+ `indexCodebase({ callWeb: true })` also builds the call web; `readCallWeb()`
1005
+ returns it only while it matches the saved Machine City, `validateCallWeb()`
1006
+ checks a document and `CALL_WEB_SCHEMA` names its schema.
989
1007
  `ARCHITECTURE_OBSERVATIONS_SCHEMA` identifies the neutral inspector document.
990
1008
  `validateArchitectureObservations()` checks its evidence against supplied files
991
1009
  and packages; `connectArchitectureObservations()` joins exact owners and
package/docs/templates.md CHANGED
@@ -83,7 +83,14 @@ The framework starters live in `seed-django`, `seed-rails`, `seed-spring-boot`,
83
83
  source, dependency lockfile, setup/run contracts and focused checks.
84
84
 
85
85
  Applying fetches the explicitly named branch once, resolves its commit and reads
86
- its ordinary Git blobs into a temporary project. Genesis rejects arbitrary links,
86
+ its ordinary Git blobs into a temporary project. Both scratch repositories, the
87
+ fetched source and the prepared project, live inside the destination's Git-local
88
+ `genesis-template.lock` directory (`git rev-parse --git-path genesis-template.lock`),
89
+ never in the system temporary directory or the work tree. Every Git command
90
+ therefore runs inside the destination repository, which hosts that confine Git
91
+ to the project accept, and the scratch is removed with the lock when the
92
+ operation succeeds or fails. A linked worktree keeps its lock and scratch in its
93
+ own private Git directory. Genesis rejects arbitrary links,
87
94
  submodules, unsafe paths, dependencies and private dotenv files in the template.
88
95
  The only link exception is the exact generated Claude skill discovery link
89
96
  `.claude/skills/<name>` → `../../.agents/skills/<name>`. Incoming discovery links
@@ -94,9 +101,11 @@ generated links without following links supplied by a template.
94
101
  It validates the prepared project before changing the destination. A project
95
102
  lock serializes competing imports; the source and authored bootstrap files are
96
103
  checked again immediately before the copy. An ordinary copy failure rolls back
97
- written files. If a process is forcibly killed, inspect any partial work and the
98
- Git-local `genesis-template.lock` before removing the abandoned lock and retrying;
99
- Genesis never treats partial application code as an empty destination.
104
+ written files. `SIGINT` and `SIGTERM` let the CLI finish that cleanup. If a process
105
+ is forcibly killed, inspect any partial work and the Git-local
106
+ `genesis-template.lock` before removing the abandoned lock, and the scratch inside
107
+ it, and retrying; Genesis never treats partial application code as an empty
108
+ destination.
100
109
 
101
110
  The destination keeps its Git history, remotes, branch, meaningful Blueprint,
102
111
  collaboration/engineering choices, and authored Stack operation sections.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "type": "module",
5
5
  "description": "An agent-independent prompt, multi-language code-index, cleanup, and verification companion with project agent guidance.",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "description": "Makes Codex aware of optional Genesis adoption for existing projects.",
5
5
  "author": {
6
6
  "name": "Mobily Enterprises"
package/src/cli.js CHANGED
@@ -65,7 +65,7 @@ const USAGE = `Usage:
65
65
  genesis templates list
66
66
  genesis templates apply <catalogue:technology/variant>
67
67
  genesis context <path...>
68
- genesis index [function-or-path...]
68
+ genesis index [--call-web] [function-or-path...]
69
69
  genesis parsers list
70
70
  genesis parsers install <language...> | --all
71
71
  genesis parsers verify <language...> | --all
@@ -87,6 +87,7 @@ Options:
87
87
  --task <task> Select the prompt task (default: work)
88
88
  --directory <path> Parser cache directory (parsers commands only)
89
89
  --all Select every parser (parsers install/verify only)
90
+ --call-web Also build .genesis/call-web.json (index only)
90
91
  --json Emit one machine-readable result
91
92
  --claude-part <1-4> Emit one bounded Claude session-hook part
92
93
  -h, --help Show this help
@@ -114,6 +115,7 @@ function parseCommand(argv) {
114
115
  json: { type: 'boolean', default: false },
115
116
  'claude-part': { type: 'string' },
116
117
  all: { type: 'boolean', default: false },
118
+ 'call-web': { type: 'boolean', default: false },
117
119
  directory: { type: 'string' },
118
120
  'project-root': { type: 'string' },
119
121
  'stack-package': { type: 'string', multiple: true, default: [] },
@@ -128,6 +130,7 @@ function parseCommand(argv) {
128
130
  json: parsed.values.json,
129
131
  claudePart: parsed.values['claude-part'],
130
132
  all: parsed.values.all,
133
+ callWeb: parsed.values['call-web'],
131
134
  directory: parsed.values.directory,
132
135
  projectRoot: parsed.values['project-root'],
133
136
  stackPackages: parsed.values['stack-package'],
@@ -150,6 +153,9 @@ function parseCommand(argv) {
150
153
  if (options.templateSources.length && command !== 'templates') {
151
154
  fail('CLI_OPTION_NOT_APPLICABLE', `Option --template-source is not applicable to ${command}.`);
152
155
  }
156
+ if (options.callWeb && command !== 'index') {
157
+ fail('CLI_OPTION_NOT_APPLICABLE', `Option --call-web is not applicable to ${command}.`);
158
+ }
153
159
  if (options.task !== undefined && command !== 'prompt') {
154
160
  fail('CLI_OPTION_NOT_APPLICABLE', `Option --task is not applicable to ${command}.`);
155
161
  }
@@ -233,6 +239,14 @@ function line(stream, value = '') {
233
239
  stream.write(`${String(value).replace(/\n$/u, '')}\n`);
234
240
  }
235
241
 
242
+ // A failed command's bounded output usually carries its actual reason.
243
+ function diagnosticOutput(stream, item) {
244
+ for (const field of ['stdout', 'stderr']) {
245
+ const output = item.details?.[field];
246
+ if (typeof output === 'string' && output.trim()) line(stream, output.trim());
247
+ }
248
+ }
249
+
236
250
  function namedItems(label, values) {
237
251
  if (!Array.isArray(values) || values.length === 0) return;
238
252
  line(process.stdout, `${label}:`);
@@ -378,6 +392,7 @@ function writeResult(command, result) {
378
392
  line(process.stdout, result.summary);
379
393
  line(process.stdout, `Machine City: ${result.paths.machine}`);
380
394
  line(process.stdout, `Program City: ${result.paths.program}`);
395
+ if (result.paths.callWeb) line(process.stdout, `Call web: ${result.paths.callWeb}`);
381
396
  if (result.totalMatches > 0) {
382
397
  line(process.stdout, 'Matching functions:');
383
398
  for (const entry of result.matches) {
@@ -413,10 +428,7 @@ function writeResult(command, result) {
413
428
  namedItems('Verification commands', result.commands?.map(({ label, argv }) => `${label}: ${argv.join(' ')}`));
414
429
  for (const item of result.diagnostics || []) {
415
430
  line(process.stdout, `${item.code}: ${item.message}`);
416
- for (const field of ['stdout', 'stderr']) {
417
- const output = item.details?.[field]?.trim();
418
- if (output) line(process.stdout, output);
419
- }
431
+ diagnosticOutput(process.stdout, item);
420
432
  }
421
433
  if (result.guidance) line(process.stdout, result.guidance);
422
434
  if (result.prompt) {
@@ -553,7 +565,7 @@ async function execute({ command, operands, options }, { signal, hostContext = n
553
565
  });
554
566
  }
555
567
  if (command === 'context') return getContext({ paths: operands, projectRoot, stackPackages });
556
- if (command === 'index') return indexCodebase({ projectRoot, queries: operands, stackPackages });
568
+ if (command === 'index') return indexCodebase({ projectRoot, queries: operands, stackPackages, callWeb: options.callWeb });
557
569
  if (command === 'inspect') {
558
570
  if (operands[0] === 'section') {
559
571
  return {
@@ -711,7 +723,10 @@ export async function runCli(argv = process.argv.slice(2)) {
711
723
  } catch (error) {
712
724
  const diagnostic = asDiagnostic(error);
713
725
  if (options.json) line(process.stderr, JSON.stringify(diagnostic));
714
- else line(process.stderr, `${diagnostic.code}: ${diagnostic.message}`);
726
+ else {
727
+ line(process.stderr, `${diagnostic.code}: ${diagnostic.message}`);
728
+ diagnosticOutput(process.stderr, diagnostic);
729
+ }
715
730
  return 1;
716
731
  } finally {
717
732
  process.removeListener('SIGINT', abort);
@@ -5,7 +5,22 @@ import { sha256, stableJson } from './utils.js';
5
5
  export const ARCHITECTURE_OBSERVATIONS_SCHEMA = 'genesis.architecture-observations.v1';
6
6
  const collections = ['pages', 'registrations', 'components', 'componentUses', 'entryOperations', 'operationUses'];
7
7
  const optionalCollections = ['imports'];
8
+ // Call-web collections appear only when an inspector declares them, so
9
+ // documents and merges without them keep their exact v1 shape.
10
+ const callCollections = ['functions', 'calls', 'entryPoints'];
8
11
  const states = ['complete', 'partial', 'unsupported', 'unconfigured'];
12
+ export const FUNCTION_KINDS = Object.freeze(['function', 'method', 'constructor', 'arrow', 'closure', 'module']);
13
+ export const CALL_CERTAINTIES = Object.freeze(['exact', 'probable', 'unresolved']);
14
+ export const CALL_METHODS = Object.freeze(['checker', 'di-binding', 'route-action', 'argument-flow', 'import-scope', 'same-file-name', 'self', 'receiver-name', 'none']);
15
+ export const EXACT_CALL_METHODS = Object.freeze(['checker', 'di-binding', 'route-action', 'self', 'argument-flow']);
16
+ export const ENTRY_POINT_KINDS = Object.freeze(['page', 'component', 'route', 'action', 'library-export', 'cli-command', 'job', 'program-operation', 'test']);
17
+ export const MAX_CALL_TARGETS = 16;
18
+ export const MAX_CALLS_PER_FILE = 2000;
19
+ export const MAX_CALLEE_LENGTH = 120;
20
+ // A file's top-level code is `fn:<path>#module`; every other function is named by its start.
21
+ export const functionId = (file, start, kind) => kind === 'module' ? `fn:${file}#module` : `fn:${file}#${start.line}:${start.column}`;
22
+ const before = (left, right) => left.line < right.line || (left.line === right.line && left.column < right.column);
23
+ const notAfter = (left, right) => !before(right, left);
9
24
  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
10
25
  const text = value => typeof value === 'string' && value.length > 0 && !/[\0\r\n]/u.test(value);
11
26
  const canonical = value => text(value) && value !== '.' && !/[\\]/u.test(value) && !path.posix.isAbsolute(value) && path.posix.normalize(value) === value && !value.split('/').includes('..');
@@ -15,6 +30,45 @@ function requireFact(condition, message) {
15
30
  if (!condition) throw new GenesisError('ARCHITECTURE_OBSERVATIONS_INVALID', message);
16
31
  }
17
32
 
33
+ function validateCallWebFacts(document, ids, inputs, functionReference, operation) {
34
+ requireFact(document.calls === undefined || Array.isArray(document.functions), 'calls require functions.');
35
+ const functions = new Map();
36
+ for (const entry of document.functions || []) {
37
+ requireFact(inputs.has(entry.file), `${entry.id} must identify a declared input file.`);
38
+ requireFact(point(entry.start) && point(entry.end) && before(entry.start, entry.end), `${entry.id} has an impossible source range.`);
39
+ requireFact(entry.id === functionId(entry.file, entry.start, entry.kind), `${entry.id} must be fn:<path>#<start line>:<start column> (fn:<path>#module for module code).`);
40
+ requireFact(FUNCTION_KINDS.includes(entry.kind) && typeof entry.name === 'string' && !/[\0\r\n]/u.test(entry.name), `${entry.id} needs a known kind and a name (empty when anonymous).`);
41
+ requireFact(entry.kind !== 'module' || (entry.start.line === 1 && entry.start.column === 1), `${entry.id} module code starts at 1:1.`);
42
+ requireFact(entry.container == null || text(entry.container), `${entry.id} has an invalid container.`);
43
+ requireFact(entry.exported === undefined || typeof entry.exported === 'boolean', `${entry.id} has an invalid export flag.`);
44
+ requireFact(entry.evidence.every(evidence => evidence.path === entry.file), `${entry.id} evidence must be in its own file.`);
45
+ functions.set(entry.id, entry);
46
+ }
47
+ const perFile = new Map();
48
+ for (const call of document.calls || []) {
49
+ const from = functions.get(call.fromFunctionId);
50
+ requireFact(from && Array.isArray(call.targets) && call.targets.every(target => functions.has(target)) && new Set(call.targets).size === call.targets.length, `${call.id} must reference known caller and target functions.`);
51
+ requireFact(CALL_CERTAINTIES.includes(call.certainty) && CALL_METHODS.includes(call.method), `${call.id} needs a known certainty and method.`);
52
+ requireFact(typeof call.callee === 'string' && call.callee.length <= MAX_CALLEE_LENGTH && !call.callee.includes('\0'), `${call.id} callee text must be at most ${MAX_CALLEE_LENGTH} characters.`);
53
+ if (call.certainty === 'exact') requireFact(call.resolution === 'resolved' && call.targets.length === 1 && EXACT_CALL_METHODS.includes(call.method), `${call.id} exact calls need one resolved target from a resolver.`);
54
+ if (call.certainty === 'probable') requireFact(call.method !== 'none' && ((call.resolution === 'resolved' && call.targets.length === 1) || (call.resolution === 'ambiguous' && call.targets.length >= 2 && call.targets.length <= MAX_CALL_TARGETS)), `${call.id} probable calls need one resolved or 2-${MAX_CALL_TARGETS} ambiguous targets.`);
55
+ if (call.certainty === 'unresolved') requireFact(call.resolution === 'unresolved' && call.targets.length === 0 && call.method === 'none', `${call.id} unresolved calls have no targets and no method.`);
56
+ requireFact(call.evidence.every(evidence => evidence.path === from.file && notAfter(from.start, evidence.start) && notAfter(evidence.end, from.end)), `${call.id} evidence must lie inside its caller.`);
57
+ perFile.set(from.file, (perFile.get(from.file) || 0) + 1);
58
+ requireFact(perFile.get(from.file) <= MAX_CALLS_PER_FILE, `${from.file} has more than ${MAX_CALLS_PER_FILE} call records; truncate with CALLS_TRUNCATED.`);
59
+ }
60
+ for (const entry of document.entryPoints || []) {
61
+ requireFact(ENTRY_POINT_KINDS.includes(entry.kind) && text(entry.title), `${entry.id} needs a known kind and a title.`);
62
+ requireFact(inputs.has(entry.file), `${entry.id} must identify a declared input file.`);
63
+ requireFact(Array.isArray(entry.functionIds), `${entry.id} needs function identities.`);
64
+ for (const value of entry.functionIds) functionReference(value, entry.id);
65
+ if (entry.pageId != null) requireFact(ids.get(entry.pageId)?.name === 'pages', `${entry.id} refers to an unknown page.`);
66
+ if (entry.componentId != null) requireFact(ids.get(entry.componentId)?.name === 'components', `${entry.id} refers to an unknown component.`);
67
+ if (entry.operationKey != null) operation(entry.operationKey, entry.id);
68
+ if (entry.actionId != null) requireFact(text(entry.actionId), `${entry.id} has an invalid action identity.`);
69
+ }
70
+ }
71
+
18
72
  // A finite, framework-independent JSON contract. Inspectors collect evidence;
19
73
  // this owner validates identities and joins without executing application code.
20
74
  export function validateArchitectureObservations(document, { files = [], packages = [] } = {}) {
@@ -23,7 +77,7 @@ export function validateArchitectureObservations(document, { files = [], package
23
77
  requireFact(Array.isArray(document.inputs), 'inputs must be an array.');
24
78
  requireFact(object(document.capabilities), 'capabilities must be an object.');
25
79
  for (const name of ['pages', 'components', 'entryOperations', 'operationUses']) requireFact(states.includes(document.capabilities[name]), `Missing or invalid ${name} capability.`);
26
- if (document.capabilities.imports !== undefined) requireFact(states.includes(document.capabilities.imports), 'Invalid import coverage.');
80
+ for (const name of ['imports', ...callCollections]) if (document.capabilities[name] !== undefined) requireFact(states.includes(document.capabilities[name]), `Invalid ${name} coverage.`);
27
81
  requireFact(Array.isArray(document.diagnostics) && document.diagnostics.every(entry => object(entry) && text(entry.code) && text(entry.message)), 'diagnostics must contain code and message.');
28
82
  const filesByPath = new Map(files.map(file => [file.path, file]));
29
83
  const inputs = new Map();
@@ -35,8 +89,8 @@ export function validateArchitectureObservations(document, { files = [], package
35
89
  inputs.set(input.path, input);
36
90
  }
37
91
  const ids = new Map();
38
- for (const name of [...collections, ...optionalCollections]) {
39
- requireFact(Array.isArray(document[name]) || (optionalCollections.includes(name) && document[name] === undefined), `${name} must be an array.`);
92
+ for (const name of [...collections, ...optionalCollections, ...callCollections]) {
93
+ requireFact(Array.isArray(document[name]) || (!collections.includes(name) && document[name] === undefined), `${name} must be an array.`);
40
94
  for (const record of document[name] || []) {
41
95
  requireFact(object(record) && text(record.id) && !ids.has(record.id), `Invalid or duplicate ${name} identity.`);
42
96
  requireFact(['resolved', 'unresolved', 'ambiguous'].includes(record.resolution), `${record.id} needs an explicit resolution.`);
@@ -93,10 +147,24 @@ export function validateArchitectureObservations(document, { files = [], package
93
147
  if (entry.resolution === 'resolved') source(entry.controllerFile, entry.id);
94
148
  if (entry.controllerSymbol != null) requireFact(text(entry.controllerSymbol), `${entry.id} has an invalid symbol.`);
95
149
  }
150
+ // Without a function table (a saved compact copy), caller identities stay opaque strings.
151
+ const hasFunctions = Array.isArray(document.functions);
152
+ function functionReference(value, label) {
153
+ if (hasFunctions) reference(value, ['functions'], label);
154
+ else requireFact(text(value), `${label} has an invalid function identity.`);
155
+ }
156
+ for (const entry of document.entryOperations) {
157
+ if (entry.controllerFunctionId !== undefined) functionReference(entry.controllerFunctionId, entry.id);
158
+ if (entry.actionId !== undefined) requireFact(text(entry.actionId), `${entry.id} has an invalid action identity.`);
159
+ }
96
160
  for (const use of document.operationUses) {
97
- reference(use.parentId, ['pages', 'components', 'componentUses'], use.id);
161
+ requireFact(use.parentId != null || use.fromFunctionId !== undefined, `${use.id} needs a page/component parent or a caller function.`);
162
+ if (use.parentId != null) reference(use.parentId, ['pages', 'components', 'componentUses'], use.id);
163
+ if (use.fromFunctionId !== undefined) functionReference(use.fromFunctionId, use.id);
98
164
  if (use.resolution === 'resolved') operation(use.key, use.id);
165
+ if (use.keyCertainty !== undefined) requireFact(use.resolution === 'resolved' && ['exact', 'probable'].includes(use.keyCertainty), `${use.id} key certainty applies only to a resolved key.`);
99
166
  }
167
+ validateCallWebFacts(document, ids, inputs, functionReference, operation);
100
168
  // Parent graphs must be finite. Shared definitions are not duplicate owners.
101
169
  for (const name of ['pages', 'componentUses']) for (const record of document[name]) {
102
170
  const seen = new Set([record.id]);
@@ -121,7 +189,9 @@ export function connectArchitectureObservations(document, files, packages = [])
121
189
  const connections = document.operationUses.map(use => {
122
190
  const matches = use.resolution === 'resolved' ? entries.get(stableJson(use.key)) || [] : [];
123
191
  const entry = matches.length === 1 ? matches[0] : null;
124
- return { id: `connection:${use.id}`, useId: use.id, parentId: use.parentId, key: use.key || null, resolution: entry ? 'resolved' : matches.length > 1 ? 'ambiguous' : 'unresolved', entryId: entry?.id || null, controllerFileId: entry ? byPath.get(entry.controllerFile).id : null, controllerSymbol: entry?.controllerSymbol || null, evidence: [...use.evidence, ...(entry?.evidence || [])] };
192
+ // Caller facts appear only for function-level uses; an exact key alone makes an exact bridge.
193
+ const bridge = use.fromFunctionId === undefined ? {} : { fromFunctionId: use.fromFunctionId, controllerFunctionId: entry?.controllerFunctionId ?? null, certainty: entry ? (use.keyCertainty === 'exact' ? 'exact' : 'probable') : null };
194
+ return { id: `connection:${use.id}`, useId: use.id, parentId: use.parentId ?? null, key: use.key || null, resolution: entry ? 'resolved' : matches.length > 1 ? 'ambiguous' : 'unresolved', entryId: entry?.id || null, controllerFileId: entry ? byPath.get(entry.controllerFile).id : null, controllerSymbol: entry?.controllerSymbol || null, evidence: [...use.evidence, ...(entry?.evidence || [])], ...bridge };
125
195
  });
126
196
  return {
127
197
  ...document, connections,
@@ -130,9 +200,14 @@ export function connectArchitectureObservations(document, files, packages = [])
130
200
  };
131
201
  }
132
202
 
203
+ const sameFunction = (left, right) => ['file', 'name', 'kind'].every(field => left[field] === right[field])
204
+ && stableJson([left.start, left.end]) === stableJson([right.start, right.end]);
205
+
133
206
  export function mergeArchitectureObservations(documents, { files = [], packages = [] } = {}) {
134
207
  requireFact(Array.isArray(documents) && documents.length > 0, 'At least one observation document is required.');
135
208
  const merged = { schema: ARCHITECTURE_OBSERVATIONS_SCHEMA, adapter: '', inputs: [], capabilities: {}, diagnostics: [], ...Object.fromEntries([...collections, ...optionalCollections].map(name => [name, []])) };
209
+ const declared = callCollections.filter(name => documents.some(document => document?.[name] !== undefined));
210
+ for (const name of declared) merged[name] = [];
136
211
  const adapters = new Set();
137
212
  for (const document of documents) {
138
213
  requireFact(document?.schema === ARCHITECTURE_OBSERVATIONS_SCHEMA && text(document.adapter) && !adapters.has(document.adapter), 'Every inspector needs a unique adapter identity and the observation schema.');
@@ -141,13 +216,20 @@ export function mergeArchitectureObservations(documents, { files = [], packages
141
216
  requireFact(!merged.packageIdentity || merged.packageIdentity === document.packageIdentity, 'Inspectors disagree about the package snapshot.');
142
217
  merged.packageIdentity = document.packageIdentity;
143
218
  }
144
- for (const name of ['inputs', ...collections, ...optionalCollections]) {
145
- requireFact(Array.isArray(document[name]) || (optionalCollections.includes(name) && document[name] === undefined), `${name} must be an array.`);
219
+ for (const name of ['inputs', ...collections, ...optionalCollections, ...declared]) {
220
+ requireFact(Array.isArray(document[name]) || (!collections.includes(name) && name !== 'inputs' && document[name] === undefined), `${name} must be an array.`);
146
221
  const byId = new Map(merged[name].map(record => [name === 'inputs' ? record.path : record.id, record]));
147
222
  for (const record of document[name] || []) {
148
223
  requireFact(object(record), `Invalid ${name} record.`);
149
224
  const id = name === 'inputs' ? record.path : record.id;
150
225
  const previous = byId.get(id);
226
+ if (name === 'functions') {
227
+ // The same function seen by two inspectors is one record; on
228
+ // disagreement the inspector earlier in Stack order wins.
229
+ if (previous && !sameFunction(previous, record)) merged.diagnostics.push({ code: 'FUNCTION_CONFLICT', path: record.file, message: `${document.adapter} and ${previous.adapter} disagree about ${id}; ${previous.adapter} is kept.` });
230
+ if (!previous) { const kept = { ...record, adapter: document.adapter }; merged[name].push(kept); byId.set(id, kept); }
231
+ continue;
232
+ }
151
233
  requireFact(!previous || stableJson(previous) === stableJson(record), `Conflicting ${name} identity ${id}.`);
152
234
  if (!previous) { merged[name].push(record); byId.set(id, record); }
153
235
  }
@@ -163,5 +245,10 @@ export function mergeArchitectureObservations(documents, { files = [], packages
163
245
  }
164
246
  const importCoverage = documents.map(document => document.capabilities?.imports || 'unconfigured');
165
247
  merged.capabilities.imports = new Set(importCoverage).size === 1 ? importCoverage[0] : 'partial';
248
+ for (const capability of callCollections) {
249
+ if (!documents.some(document => document.capabilities?.[capability] !== undefined)) continue;
250
+ const values = documents.map(document => document.capabilities?.[capability] || 'unconfigured');
251
+ merged.capabilities[capability] = new Set(values).size === 1 ? values[0] : 'partial';
252
+ }
166
253
  return validateArchitectureObservations(merged, { files, packages });
167
254
  }
@@ -1,10 +1,12 @@
1
1
  import path from 'node:path';
2
- import { connectArchitectureObservations } from './architecture-observations.js';
2
+ import { connectArchitectureObservations, ENTRY_POINT_KINDS } from './architecture-observations.js';
3
3
  import { buildCityPresentation } from './city-presentation.js';
4
4
  import { GenesisError } from './errors.js';
5
5
  import { sha256, stableJson } from './utils.js';
6
6
 
7
7
  const owns = (root, file) => root === '.' || file.startsWith(`${root}/`);
8
+ // Function and call tables live in the call web; Machine City keeps the rest.
9
+ const withoutCallTables = ({ functions, calls, ...rest }) => rest;
8
10
 
9
11
  function resolveReference(file, reference, filesByPath, packages) {
10
12
  const specifier = reference.specifier;
@@ -80,7 +82,7 @@ export function buildArchitectureFacts({ machine, subsystemMap, program = null,
80
82
  })));
81
83
  const symbols = [...machine.functions, ...(machine.symbols || [])].map(symbol => ({ ...symbol, start: symbol.start || { line: symbol.line, column: symbol.column }, rangeAvailable: Boolean(symbol.end) }));
82
84
  const sourceHash = sha256(stableJson(files.map(file => ({ path: file.path, hash: file.hash, mode: file.mode }))));
83
- let ui = observations ? connectArchitectureObservations(observations, files, packages) : null;
85
+ let ui = observations ? withoutCallTables(connectArchitectureObservations(observations, files, packages)) : null;
84
86
  if (!observations && previous?.ui) {
85
87
  // Source-only indexing must not erase unchanged consumer observations.
86
88
  // A changed snapshot requires its inspector; never carry guesses forward.
@@ -219,6 +221,23 @@ export function validateArchitectureFacts(snapshot) {
219
221
  }
220
222
  check(snapshot.capabilities && typeof snapshot.capabilities === 'object' && Array.isArray(snapshot.diagnostics), 'Missing architecture coverage or diagnostics.');
221
223
  for (const name of ['symbols', 'dependencies', 'pages', 'runtimeCalls']) check(['complete', 'partial', 'unsupported', 'unconfigured'].includes(snapshot.capabilities[name]), 'Invalid architecture coverage.');
224
+ for (const name of ['functions', 'calls', 'entryPoints']) check(snapshot.capabilities[name] === undefined || ['complete', 'partial', 'unsupported', 'unconfigured'].includes(snapshot.capabilities[name]), 'Invalid architecture coverage.');
225
+ const filesByPath = new Map([...files.values()].map(file => [file.path, file]));
226
+ if (snapshot.entryPoints !== undefined) {
227
+ check(Array.isArray(snapshot.entryPoints), 'entryPoints must be an array.');
228
+ const ids = new Set();
229
+ for (const entry of snapshot.entryPoints) {
230
+ check(entry && typeof entry.id === 'string' && entry.id.length > 0 && !ids.has(entry.id) && ENTRY_POINT_KINDS.includes(entry.kind) && typeof entry.title === 'string' && entry.title.length > 0, 'Invalid or duplicate entry point.');
231
+ ids.add(entry.id);
232
+ check(filesByPath.has(entry.file) && Array.isArray(entry.functionIds) && entry.functionIds.every(id => typeof id === 'string' && id.length > 0), `${entry.id} must name a known file and function identities.`);
233
+ check(Array.isArray(entry.evidence) && entry.evidence.length > 0 && entry.evidence.every(evidence => filesByPath.get(evidence?.path)?.hash === evidence.hash), `${entry.id} evidence is stale.`);
234
+ }
235
+ }
236
+ if (snapshot.callWeb != null) {
237
+ const { callWeb } = snapshot;
238
+ check(callWeb.path === '.genesis/call-web.json' && typeof callWeb.identity === 'string' && typeof callWeb.sourceIdentity === 'string' && callWeb.counts && typeof callWeb.counts === 'object' && callWeb.coverage && typeof callWeb.coverage === 'object', 'Invalid call-web summary.');
239
+ for (const name of ['functions', 'calls', 'exact', 'probable', 'unresolved']) check(Number.isSafeInteger(callWeb.counts[name]) && callWeb.counts[name] >= 0, 'Invalid call-web counts.');
240
+ }
222
241
  if (snapshot.ui) {
223
242
  const recomputed = connectArchitectureObservations(snapshot.ui, snapshot.files, snapshot.packages || []);
224
243
  check(stableJson(recomputed.connections) === stableJson(snapshot.ui.connections), 'Controller connections disagree with their evidence.');