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
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.
|
|
268
|
-
|
|
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.
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
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
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
|
-
|
|
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
|
|
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
|
|
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]) || (
|
|
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
|
-
|
|
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
|
-
|
|
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]) || (
|
|
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.');
|