@event4u/agent-config 17.1.0 → 17.2.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +70 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/project/analyze.md +9 -6
- package/dist/agent-src/commands/project/map.md +58 -0
- package/dist/agent-src/commands/project.md +5 -3
- package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +23 -39
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +39 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +65 -74
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/instructions_observer.chunk.cjs +301 -0
- package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +33058 -22930
- package/package.json +4 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/gitignore-block.txt +6 -0
- package/src/config/hook-bundle-budget.json +17 -2
- package/src/config/hook-token-budget.json +1 -1
- package/src/config/packed-binary-manifest.json +27 -0
- package/src/config/skill-body-budget.json +7 -1
- package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
- package/src/scripts/_lib/collector_record.ts +1 -0
- package/src/scripts/_lib/runtime_journal.ts +7 -7
- package/src/scripts/code_graph/build.ts +210 -10
- package/src/scripts/code_graph/cli.ts +168 -46
- package/src/scripts/code_graph/detect.ts +5 -4
- package/src/scripts/code_graph/docs_layer.ts +57 -0
- package/src/scripts/code_graph/extract.ts +58 -13
- package/src/scripts/code_graph/extract_more.ts +314 -0
- package/src/scripts/code_graph/laravel_overlay.ts +454 -0
- package/src/scripts/code_graph/map.ts +846 -0
- package/src/scripts/code_graph/map_model.ts +977 -0
- package/src/scripts/code_graph/map_verify.ts +140 -0
- package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
- package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
- package/src/scripts/code_graph/scip_reader.ts +201 -0
- package/src/scripts/code_graph/sqlite_store.ts +8 -1
- package/src/scripts/code_graph/types.ts +81 -15
- package/src/scripts/code_graph/validate.ts +16 -26
- package/src/scripts/code_graph/verbs.ts +7 -0
- package/src/scripts/hook_chunks.mjs +7 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +20 -0
- package/src/scripts/hooks/dispatch_hook.ts +2 -2
- package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
- package/src/scripts/hooks/lazy_concern.ts +4 -0
- package/src/scripts/install-hooks.sh +5 -0
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +1 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
- package/src/scripts/mcp_server/graph_tools.ts +73 -1
- package/src/scripts/mcp_server/tools.ts +19 -36
- package/src/vendor/grammars/README.md +10 -5
- package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-config code-graph map` — write the project map to `agents/map/`.
|
|
3
|
+
*
|
|
4
|
+
* IO around the pure projection in `map_model.ts`: read the engine's graph
|
|
5
|
+
* (building or refreshing it first when needed), derive the model, and write
|
|
6
|
+
* four artefacts a person or an agent reads (ADR-297):
|
|
7
|
+
*
|
|
8
|
+
* agents/map/index.html one self-contained offline file
|
|
9
|
+
* agents/map/MAP.md the same picture in words, budgeted
|
|
10
|
+
* agents/map/areas/<id>.md one page per area of five or more files
|
|
11
|
+
* agents/map/map.json the model the other three are rendered from
|
|
12
|
+
*
|
|
13
|
+
* Same tree in, same bytes out: nothing written here carries a timestamp, a
|
|
14
|
+
* host path or anything else that differs between two builds of one commit.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
import { write_atomic } from '../_lib/fs_atomic.js';
|
|
23
|
+
import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
|
|
24
|
+
import { buildFromRepo } from './build.js';
|
|
25
|
+
import { computeVerdict, graphState, NATIVE_CACHE_REL } from './detect.js';
|
|
26
|
+
import {
|
|
27
|
+
areaDrift,
|
|
28
|
+
buildMapModel,
|
|
29
|
+
type FileRole,
|
|
30
|
+
MAP_MODEL_VERSION,
|
|
31
|
+
type MapModel,
|
|
32
|
+
type OutcomeEvent,
|
|
33
|
+
outcomeScores,
|
|
34
|
+
prOverlap,
|
|
35
|
+
} from './map_model.js';
|
|
36
|
+
import type { CodeGraph } from './types.js';
|
|
37
|
+
|
|
38
|
+
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const VIEWER_DIR = path.join(_HERE, 'map_viewer');
|
|
40
|
+
|
|
41
|
+
export const MAP_DIR_REL = path.join('agents', 'map');
|
|
42
|
+
/** Rough characters-per-token used to hold MAP.md to its budget. */
|
|
43
|
+
const CHARS_PER_TOKEN = 4;
|
|
44
|
+
export const MAP_TOKEN_BUDGET = 2000;
|
|
45
|
+
export const AREA_PAGE_MIN_FILES = 5;
|
|
46
|
+
|
|
47
|
+
export interface MapMeta {
|
|
48
|
+
title: string;
|
|
49
|
+
commit: string;
|
|
50
|
+
state: string;
|
|
51
|
+
summary: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MapBuildResult {
|
|
55
|
+
outDir: string;
|
|
56
|
+
model: MapModel;
|
|
57
|
+
meta: MapMeta;
|
|
58
|
+
written: string[];
|
|
59
|
+
graphBuilt: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function gitHead(root: string): string {
|
|
63
|
+
try {
|
|
64
|
+
return execFileSync('git', ['-C', root, 'rev-parse', '--short=12', 'HEAD'], {
|
|
65
|
+
env: hardenedSpawnEnv(),
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
68
|
+
}).trim();
|
|
69
|
+
} catch {
|
|
70
|
+
return 'no-git';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Entry points a manifest declares: `package.json` `bin` / `main` / `module`,
|
|
76
|
+
* `composer.json` `bin`. Route files and `public/index.php` are recognised by
|
|
77
|
+
* path in `staticRole`; this only adds what a manifest names explicitly.
|
|
78
|
+
*/
|
|
79
|
+
export function declaredEntryFiles(root: string): string[] {
|
|
80
|
+
const out = new Set<string>();
|
|
81
|
+
const add = (v: unknown): void => {
|
|
82
|
+
if (typeof v !== 'string') return;
|
|
83
|
+
out.add(path.posix.normalize(v.replace(/^\.\//, '')));
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')) as Record<string, unknown>;
|
|
87
|
+
const bin = pkg['bin'];
|
|
88
|
+
if (typeof bin === 'string') add(bin);
|
|
89
|
+
else if (bin && typeof bin === 'object') for (const v of Object.values(bin)) add(v);
|
|
90
|
+
add(pkg['main']);
|
|
91
|
+
add(pkg['module']);
|
|
92
|
+
} catch {
|
|
93
|
+
/* no package.json */
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const composer = JSON.parse(fs.readFileSync(path.join(root, 'composer.json'), 'utf-8')) as Record<string, unknown>;
|
|
97
|
+
const bin = composer['bin'];
|
|
98
|
+
if (Array.isArray(bin)) for (const v of bin) add(v);
|
|
99
|
+
} catch {
|
|
100
|
+
/* no composer.json */
|
|
101
|
+
}
|
|
102
|
+
return [...out].sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The artefact relation graph, but only when `root` IS this package's source
|
|
107
|
+
* tree: the discovery graph reads the package it ships in, so for any other
|
|
108
|
+
* root it would describe the wrong repository.
|
|
109
|
+
*/
|
|
110
|
+
async function artefactGraph(root: string): Promise<MapInputsArtefacts | null> {
|
|
111
|
+
const pkgRoot = path.resolve(_HERE, '..', '..', '..');
|
|
112
|
+
if (path.resolve(root) !== pkgRoot || !fs.existsSync(path.join(root, 'src', 'skills'))) return null;
|
|
113
|
+
try {
|
|
114
|
+
const dg = await import('../discovery_graph.js');
|
|
115
|
+
const manifest = dg.loadManifest(null) as { artefacts: Array<{ path: string; name?: string; slug?: string; category?: string }> };
|
|
116
|
+
const graph = dg.getGraph(null, false);
|
|
117
|
+
const meta = new Map<string, { path: string; category: string | null }>();
|
|
118
|
+
for (const a of manifest.artefacts) {
|
|
119
|
+
for (const key of [a.name, a.slug, a.path]) if (key) meta.set(key, { path: a.path, category: a.category ?? null });
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
nodes: graph.nodes.map((id) => ({ id, path: meta.get(id)?.path ?? null, category: meta.get(id)?.category ?? null })),
|
|
123
|
+
edges: graph.edges.map((e) => ({ from: e.from, to: e.to, rel: e.rel })),
|
|
124
|
+
};
|
|
125
|
+
} catch (e) {
|
|
126
|
+
process.stderr.write(`code-graph map: artefact layer skipped — ${(e as Error).message.split('\n')[0]}\n`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
type MapInputsArtefacts = NonNullable<Parameters<typeof buildMapModel>[1]>['artefacts'];
|
|
131
|
+
|
|
132
|
+
function isGitRepo(root: string): boolean {
|
|
133
|
+
try {
|
|
134
|
+
execFileSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
|
|
135
|
+
env: hardenedSpawnEnv(),
|
|
136
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
137
|
+
});
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The graph the map is drawn from: built when absent, updated when behind. */
|
|
145
|
+
async function loadOrBuildGraph(
|
|
146
|
+
root: string,
|
|
147
|
+
cachePath: string,
|
|
148
|
+
noBuild: boolean,
|
|
149
|
+
): Promise<{ graph: CodeGraph; built: boolean }> {
|
|
150
|
+
const verdict = computeVerdict(root, cachePath).verdict;
|
|
151
|
+
// `edited` — an uncommitted change to an indexed file — reads FRESH to the
|
|
152
|
+
// commit-based verdict, and a map drawn from it would show the old code.
|
|
153
|
+
// Outside a git repository there is nothing to compare against at all, so
|
|
154
|
+
// freshness is unknowable and the incremental build decides per file.
|
|
155
|
+
if (verdict === 'FRESH' && fs.existsSync(cachePath) && isGitRepo(root) && graphState(root, cachePath) === 'fresh') {
|
|
156
|
+
return { graph: JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CodeGraph, built: false };
|
|
157
|
+
}
|
|
158
|
+
// `--no-build` (the git hook): the budgeted `refresh` before it owns the
|
|
159
|
+
// build; an unbudgeted one here would hold the refresh lock indefinitely.
|
|
160
|
+
if (noBuild) {
|
|
161
|
+
if (!fs.existsSync(cachePath)) throw new Error('no graph to draw (--no-build)');
|
|
162
|
+
return { graph: JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CodeGraph, built: false };
|
|
163
|
+
}
|
|
164
|
+
const { graph } = await buildFromRepo(root, cachePath, { update: fs.existsSync(cachePath) });
|
|
165
|
+
return { graph, built: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Files changed between `rev` and HEAD, plus uncommitted ones — the same reading
|
|
170
|
+
* `affected --since` uses, kept here so the map does not import the CLI. A rev
|
|
171
|
+
* beginning with `-` is refused: it would reach git as an option.
|
|
172
|
+
*/
|
|
173
|
+
export function diffFiles(root: string, rev: string): string[] | null {
|
|
174
|
+
if (rev.startsWith('-')) return null;
|
|
175
|
+
try {
|
|
176
|
+
const run = (args: string[]): string[] =>
|
|
177
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
178
|
+
env: hardenedSpawnEnv(),
|
|
179
|
+
encoding: 'utf-8',
|
|
180
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
181
|
+
})
|
|
182
|
+
.split('\n')
|
|
183
|
+
.map((s) => s.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
return [...new Set([...run(['diff', '--name-only', rev]), ...run(['diff', '--name-only', '--cached', rev])])].sort();
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Where `code-graph outcome` records which files a session found useful — local, never sent anywhere. */
|
|
192
|
+
export const OUTCOMES_REL = path.join('agents', 'runtime', 'state', 'map-outcomes.jsonl');
|
|
193
|
+
|
|
194
|
+
export function readOutcomes(root: string): OutcomeEvent[] {
|
|
195
|
+
let text: string;
|
|
196
|
+
try {
|
|
197
|
+
text = fs.readFileSync(path.join(root, OUTCOMES_REL), 'utf-8');
|
|
198
|
+
} catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
// Line by line: one malformed line costs that line, not the whole log.
|
|
202
|
+
const out: OutcomeEvent[] = [];
|
|
203
|
+
for (const line of text.split('\n')) {
|
|
204
|
+
if (!line) continue;
|
|
205
|
+
try {
|
|
206
|
+
const e = JSON.parse(line) as Partial<OutcomeEvent> | null;
|
|
207
|
+
if (e && typeof e.path === 'string' && typeof e.at === 'string' && (e.outcome === 'useful' || e.outcome === 'dead-end')) {
|
|
208
|
+
out.push({ path: e.path, outcome: e.outcome, at: e.at });
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
/* skip the line */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function recordOutcome(root: string, file: string, outcome: OutcomeEvent['outcome'], at: Date = new Date()): void {
|
|
218
|
+
const p = path.join(root, OUTCOMES_REL);
|
|
219
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
220
|
+
const rel = path.relative(fs.realpathSync(root), fs.realpathSync(path.resolve(file)));
|
|
221
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`not a file inside ${root}: ${file}`);
|
|
222
|
+
fs.appendFileSync(p, `${JSON.stringify({ path: rel.split(path.sep).join('/'), outcome, at: at.toISOString() })}\n`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Open PRs and their files via `gh`; null when `gh` is absent or refuses. */
|
|
226
|
+
export function openPrs(root: string): Array<{ number: number; title: string; files: string[] }> | null {
|
|
227
|
+
try {
|
|
228
|
+
const out = execFileSync(
|
|
229
|
+
'gh',
|
|
230
|
+
['pr', 'list', '--state', 'open', '--limit', '50', '--json', 'number,title,files'],
|
|
231
|
+
{ cwd: root, env: hardenedSpawnEnv(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 30_000 },
|
|
232
|
+
);
|
|
233
|
+
const rows = JSON.parse(out) as Array<{ number: number; title: string; files?: Array<{ path: string }> }>;
|
|
234
|
+
return rows.map((r) => ({ number: r.number, title: r.title, files: (r.files ?? []).map((f) => f.path) }));
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function buildMap(
|
|
241
|
+
root: string,
|
|
242
|
+
opts: { outDir?: string; cachePath?: string; diffRev?: string; prs?: boolean; noBuild?: boolean } = {},
|
|
243
|
+
): Promise<MapBuildResult> {
|
|
244
|
+
const outDir = opts.outDir ?? path.join(root, MAP_DIR_REL);
|
|
245
|
+
// A chosen --out directory is only ever written into when it is empty or
|
|
246
|
+
// already holds a map: the writer clears stale area pages, and that must
|
|
247
|
+
// never reach a directory of someone else's Markdown.
|
|
248
|
+
if (
|
|
249
|
+
opts.outDir !== undefined &&
|
|
250
|
+
fs.existsSync(outDir) &&
|
|
251
|
+
fs.readdirSync(outDir).length > 0 &&
|
|
252
|
+
!fs.existsSync(path.join(outDir, 'map.json'))
|
|
253
|
+
) {
|
|
254
|
+
throw new Error(`refusing to write a map into ${outDir}: it is not empty and holds no map.json`);
|
|
255
|
+
}
|
|
256
|
+
const cachePath = opts.cachePath ?? path.join(root, NATIVE_CACHE_REL);
|
|
257
|
+
const { graph, built } = await loadOrBuildGraph(root, cachePath, opts.noBuild === true);
|
|
258
|
+
let diff: { rev: string; changed: string[] } | undefined;
|
|
259
|
+
if (opts.diffRev !== undefined) {
|
|
260
|
+
const changed = diffFiles(root, opts.diffRev);
|
|
261
|
+
if (changed === null) throw new Error(`cannot resolve rev '${opts.diffRev}' in ${path.basename(root)}`);
|
|
262
|
+
diff = { rev: opts.diffRev, changed };
|
|
263
|
+
}
|
|
264
|
+
const artefacts = await artefactGraph(root);
|
|
265
|
+
const model = buildMapModel(graph, {
|
|
266
|
+
entryFiles: declaredEntryFiles(root),
|
|
267
|
+
...(diff ? { diff } : {}),
|
|
268
|
+
...(artefacts ? { artefacts } : {}),
|
|
269
|
+
});
|
|
270
|
+
const previous = readMapAt(outDir);
|
|
271
|
+
if (previous) {
|
|
272
|
+
const drift = areaDrift(previous.model, model);
|
|
273
|
+
if (drift) model.drift = drift;
|
|
274
|
+
}
|
|
275
|
+
const scores = outcomeScores(model, readOutcomes(root));
|
|
276
|
+
if (scores.length) model.outcomes = scores;
|
|
277
|
+
if (opts.prs) {
|
|
278
|
+
const prs = openPrs(root);
|
|
279
|
+
if (prs) model.prOverlap = prOverlap(model, prs);
|
|
280
|
+
}
|
|
281
|
+
const meta: MapMeta = {
|
|
282
|
+
title: `Map of ${path.basename(root)}`,
|
|
283
|
+
commit: gitHead(root),
|
|
284
|
+
state: graphState(root, cachePath),
|
|
285
|
+
summary: '',
|
|
286
|
+
};
|
|
287
|
+
meta.summary =
|
|
288
|
+
`${model.files.length} files · ${model.areas.length} areas · ` +
|
|
289
|
+
`${model.analyses.trustedEdges} trusted links · guess share ${pct(model.analyses.guessShare)}`;
|
|
290
|
+
|
|
291
|
+
const written: string[] = [];
|
|
292
|
+
const write = (rel: string, content: string): void => {
|
|
293
|
+
const p = path.join(outDir, rel);
|
|
294
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
295
|
+
write_atomic(p, content);
|
|
296
|
+
written.push(rel);
|
|
297
|
+
};
|
|
298
|
+
const areasDir = path.join(outDir, 'areas');
|
|
299
|
+
if (fs.existsSync(areasDir)) {
|
|
300
|
+
for (const f of fs.readdirSync(areasDir)) if (f.endsWith('.md')) fs.rmSync(path.join(areasDir, f));
|
|
301
|
+
}
|
|
302
|
+
write('map.json', `${JSON.stringify({ meta, model })}\n`);
|
|
303
|
+
write('MAP.md', renderMapMd(model, meta));
|
|
304
|
+
for (const [i, a] of model.areas.entries()) {
|
|
305
|
+
if (a.files.length >= AREA_PAGE_MIN_FILES) write(path.join('areas', `${a.id}.md`), renderAreaMd(model, i, meta));
|
|
306
|
+
}
|
|
307
|
+
write('index.html', renderHtml(model, meta));
|
|
308
|
+
return { outDir, model, meta, written, graphBuilt: built };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function pct(x: number): string {
|
|
312
|
+
return `${Math.round(x * 1000) / 10} %`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const ROLE_ORDER: readonly FileRole[] = [
|
|
316
|
+
'code',
|
|
317
|
+
'test',
|
|
318
|
+
'entry',
|
|
319
|
+
'view',
|
|
320
|
+
'migration',
|
|
321
|
+
'translation',
|
|
322
|
+
'config',
|
|
323
|
+
'doc',
|
|
324
|
+
'infra',
|
|
325
|
+
'isolated',
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Plain text from the scanned repository (a directory or area name) in
|
|
330
|
+
* Markdown: every character Markdown or a table would read as syntax is
|
|
331
|
+
* escaped, so a name can never become a link, a heading or emphasis.
|
|
332
|
+
*/
|
|
333
|
+
function md(s: string): string {
|
|
334
|
+
return s.replace(/[\\`*_[\]#<>|!]/g, (c) => `\\${c}`).replace(/\r?\n/g, ' ');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A name inside a code span. A backslash does not escape inside one, so a
|
|
339
|
+
* backtick in the name would close the span early and let the rest render as
|
|
340
|
+
* Markdown; it becomes `'` instead. `|` keeps its escape, which GFM tables
|
|
341
|
+
* honour even inside code.
|
|
342
|
+
*/
|
|
343
|
+
function code(s: string): string {
|
|
344
|
+
return `\`${s.replace(/`/g, "'").replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')}\``;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* MAP.md — the map in words, held to `MAP_TOKEN_BUDGET`. Sections are added in
|
|
349
|
+
* priority order; the first one that would cross the budget is replaced by a
|
|
350
|
+
* line naming every section that was cut, so a reader knows what is missing.
|
|
351
|
+
*/
|
|
352
|
+
export function renderMapMd(m: MapModel, meta: MapMeta): string {
|
|
353
|
+
const f = m.files;
|
|
354
|
+
const head = [
|
|
355
|
+
`# ${md(meta.title)}`,
|
|
356
|
+
'',
|
|
357
|
+
`> Generated by \`agent-config code-graph map\` at \`${meta.commit}\` · graph state \`${meta.state}\`.`,
|
|
358
|
+
'> Deterministic, no model call. Rebuild with `agent-config code-graph map`; never edit by hand.',
|
|
359
|
+
'',
|
|
360
|
+
`**${f.length} files · ${m.areas.length} areas · ${m.analyses.trustedEdges} trusted links · guess share ${pct(m.analyses.guessShare)}**`,
|
|
361
|
+
'',
|
|
362
|
+
`Thresholds: seed ${m.thresholds.seed} · infrastructure above in-degree ${m.thresholds.infraInDegree} · ` +
|
|
363
|
+
`areas under ${m.thresholds.smallArea} files fold into "small parts" · an area above ` +
|
|
364
|
+
`${Math.round(m.thresholds.maxAreaShare * 100)} % is split once · lists capped at ${m.thresholds.listCap}.`,
|
|
365
|
+
'',
|
|
366
|
+
'',
|
|
367
|
+
].join('\n');
|
|
368
|
+
|
|
369
|
+
const sections: Array<[string, string]> = [];
|
|
370
|
+
const areaLabel = (i: number): string => `${m.areas[i]?.id ?? ''} ${md(m.areas[i]?.name ?? '')}`;
|
|
371
|
+
|
|
372
|
+
if (m.drift) {
|
|
373
|
+
const d = m.drift;
|
|
374
|
+
sections.push([
|
|
375
|
+
'Since the last map',
|
|
376
|
+
[
|
|
377
|
+
'## Since the last map',
|
|
378
|
+
'',
|
|
379
|
+
...d.changed.slice(0, 10).map(([a, g, l]) => `- ${areaLabel(a)}: +${g} / −${l} files`),
|
|
380
|
+
...d.appeared.slice(0, 10).map((a) => `- new: ${areaLabel(a)}`),
|
|
381
|
+
...d.split.slice(0, 10).map(([name, parts]) => `- split: ${code(name)} → ${parts.map(areaLabel).join(', ')}`),
|
|
382
|
+
...d.merged.slice(0, 10).map(([a, names]) => `- merged into ${areaLabel(a)}: ${names.map((n) => `${code(n)}`).join(', ')}`),
|
|
383
|
+
...d.vanished.slice(0, 10).map((name) => `- gone: ${code(name)}`),
|
|
384
|
+
'',
|
|
385
|
+
].join('\n'),
|
|
386
|
+
]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (m.prOverlap) {
|
|
390
|
+
const o = m.prOverlap;
|
|
391
|
+
sections.push([
|
|
392
|
+
'Open PRs',
|
|
393
|
+
[
|
|
394
|
+
'## Open PRs by area',
|
|
395
|
+
'',
|
|
396
|
+
`${o.prs.length} open PR(s). Two PRs in the same area are a merge-order risk — advisory only.`,
|
|
397
|
+
'',
|
|
398
|
+
...(o.conflicts.length
|
|
399
|
+
? o.conflicts.slice(0, m.thresholds.listCap).map(([a, b, areas]) => `- #${a} and #${b} share ${areas.map(areaLabel).join(', ')}`)
|
|
400
|
+
: ['- no two open PRs touch the same area']),
|
|
401
|
+
'',
|
|
402
|
+
].join('\n'),
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (m.diff) {
|
|
407
|
+
const d = m.diff;
|
|
408
|
+
sections.push([
|
|
409
|
+
'This change',
|
|
410
|
+
[
|
|
411
|
+
`## This change (${code(d.rev)}..working tree)`,
|
|
412
|
+
'',
|
|
413
|
+
`${d.changed.length} changed file(s) on the map, ${d.impacted.length} more with a trusted link into them` +
|
|
414
|
+
(d.outsideGraph.length ? `, ${d.outsideGraph.length} changed path(s) the graph does not hold` : '') +
|
|
415
|
+
'. It touches these areas:',
|
|
416
|
+
'',
|
|
417
|
+
'| Area | Changed | Linked into a change |',
|
|
418
|
+
'|---|---|---|',
|
|
419
|
+
...d.areas.slice(0, m.thresholds.listCap).map(([a, c, i]) => `| ${m.areas[a]?.id ?? ''} ${md(m.areas[a]?.name ?? '')} | ${c} | ${i} |`),
|
|
420
|
+
...(d.areas.length === 0 ? ['| — none — | | |'] : []),
|
|
421
|
+
'',
|
|
422
|
+
].join('\n'),
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const AREA_ROWS = 25;
|
|
427
|
+
const areaRows = m.areas.slice(0, AREA_ROWS).map((a) => {
|
|
428
|
+
const top = [...a.files]
|
|
429
|
+
.sort((x, y) => (f[y]?.inFiles ?? 0) - (f[x]?.inFiles ?? 0) || x - y)
|
|
430
|
+
.slice(0, 3)
|
|
431
|
+
.map((x) => `${code(path.posix.basename(f[x]?.path ?? ''))}`)
|
|
432
|
+
.join(', ');
|
|
433
|
+
const page = a.files.length >= AREA_PAGE_MIN_FILES ? `[${a.id}](areas/${a.id}.md)` : a.id;
|
|
434
|
+
return `| ${page} | ${md(a.name)} | ${a.files.length} | ${pct(a.cohesion)} | ${pct(a.testShare)} | ${pct(a.folderMismatch)} | ${top} |`;
|
|
435
|
+
});
|
|
436
|
+
sections.push([
|
|
437
|
+
'Areas',
|
|
438
|
+
[
|
|
439
|
+
'## Areas',
|
|
440
|
+
'',
|
|
441
|
+
'Files grouped by how they are coupled, not by folder. *Outside hub folder* is the share of an',
|
|
442
|
+
"area's non-test files whose directory differs from its hub's — where the folder layout and the",
|
|
443
|
+
'actual dependencies disagree.',
|
|
444
|
+
'',
|
|
445
|
+
'| Area | Named after (hub) | Files | Cohesion | Tests | Outside hub folder | Most used |',
|
|
446
|
+
'|---|---|---|---|---|---|---|',
|
|
447
|
+
...areaRows,
|
|
448
|
+
...(m.areas.length > AREA_ROWS
|
|
449
|
+
? ['', `${m.areas.length - AREA_ROWS} smaller areas are listed in \`map.json\` and in \`index.html\`.`]
|
|
450
|
+
: []),
|
|
451
|
+
'',
|
|
452
|
+
].join('\n'),
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
const infra = f.map((x, i) => [x, i] as const).filter(([x]) => x.role === 'infra');
|
|
456
|
+
sections.push([
|
|
457
|
+
'Infrastructure',
|
|
458
|
+
[
|
|
459
|
+
'## Infrastructure',
|
|
460
|
+
'',
|
|
461
|
+
`Files used by more than ${m.thresholds.infraInDegree} other files. They are drawn apart and do not decide areas.`,
|
|
462
|
+
'',
|
|
463
|
+
...(infra.length ? infra.map(([x]) => `- ${code(x.path)} — used by ${x.inFiles} files`) : ['- none']),
|
|
464
|
+
'',
|
|
465
|
+
].join('\n'),
|
|
466
|
+
]);
|
|
467
|
+
|
|
468
|
+
sections.push([
|
|
469
|
+
'Surprises',
|
|
470
|
+
[
|
|
471
|
+
'## Surprising links',
|
|
472
|
+
'',
|
|
473
|
+
'Links between two areas that are otherwise barely connected, rarest first.',
|
|
474
|
+
'',
|
|
475
|
+
...(m.analyses.surprises.length
|
|
476
|
+
? m.analyses.surprises.map(
|
|
477
|
+
([s, t]) => `- ${code(f[s]?.path ?? '')} → ${code(f[t]?.path ?? '')}`,
|
|
478
|
+
)
|
|
479
|
+
: ['- none']),
|
|
480
|
+
'',
|
|
481
|
+
].join('\n'),
|
|
482
|
+
]);
|
|
483
|
+
|
|
484
|
+
sections.push([
|
|
485
|
+
'Cycles',
|
|
486
|
+
[
|
|
487
|
+
'## Import cycles',
|
|
488
|
+
'',
|
|
489
|
+
`${m.analyses.cyclesTotal} file-level cycle(s); the largest ${m.analyses.cycles.length} shown, up to 8 files each.`,
|
|
490
|
+
'',
|
|
491
|
+
...(m.analyses.cycles.length
|
|
492
|
+
? m.analyses.cycles.map((c) => `- ${c.map((x) => `${code(f[x]?.path ?? '')}`).join(' · ')}`)
|
|
493
|
+
: ['- none']),
|
|
494
|
+
'',
|
|
495
|
+
].join('\n'),
|
|
496
|
+
]);
|
|
497
|
+
|
|
498
|
+
const rp = m.analyses.requestPaths.filter((r) => r.handler >= 0);
|
|
499
|
+
if (m.analyses.requestPathsTotal > 0) {
|
|
500
|
+
// GET routes that render a view first: the page a reader recognises.
|
|
501
|
+
const rank = (r: (typeof rp)[number]): number =>
|
|
502
|
+
(r.route.startsWith('GET ') ? 0 : 2) + (r.views.length > 0 ? 0 : 1);
|
|
503
|
+
const withChain = rp
|
|
504
|
+
.filter((r) => r.views.length + r.tables.length > 0)
|
|
505
|
+
.sort((a, b) => rank(a) - rank(b) || (a.route < b.route ? -1 : a.route > b.route ? 1 : 0));
|
|
506
|
+
sections.push([
|
|
507
|
+
'Request paths',
|
|
508
|
+
[
|
|
509
|
+
'## Request paths',
|
|
510
|
+
'',
|
|
511
|
+
`${m.analyses.requestPathsTotal} declared routes; ${m.analyses.requestPathsResolved} reach a handler in this tree. ` +
|
|
512
|
+
'Route → handler → views it renders · tables its models persist:',
|
|
513
|
+
'',
|
|
514
|
+
...(withChain.length ? withChain : rp).slice(0, 10).map((r) => {
|
|
515
|
+
const handlerFile = path.posix.basename(f[r.handler]?.path ?? '');
|
|
516
|
+
const chain = [
|
|
517
|
+
handlerFile.replace(/\.php$/, '') === r.handlerSymbol ? handlerFile : `${handlerFile}::${r.handlerSymbol}`,
|
|
518
|
+
...r.views.map((v) => path.posix.basename(f[v]?.path ?? '')),
|
|
519
|
+
...r.tables.map((t) => `table ${code(t)}`),
|
|
520
|
+
];
|
|
521
|
+
return `- ${code(r.route)} → ${chain.map((c) => (c.startsWith('table') ? c : `${code(c)}`)).join(' → ')}`;
|
|
522
|
+
}),
|
|
523
|
+
'',
|
|
524
|
+
].join('\n'),
|
|
525
|
+
]);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (m.artefacts) {
|
|
529
|
+
const a = m.artefacts;
|
|
530
|
+
const inbound = new Map<number, number>();
|
|
531
|
+
for (const [, to] of a.edges) inbound.set(to, (inbound.get(to) ?? 0) + 1);
|
|
532
|
+
const top = [...inbound.entries()].sort((x, y) => y[1] - x[1] || x[0] - y[0]).slice(0, 8);
|
|
533
|
+
sections.push([
|
|
534
|
+
'Artefact layer',
|
|
535
|
+
[
|
|
536
|
+
'## Artefact layer',
|
|
537
|
+
'',
|
|
538
|
+
`This is an agent-config source tree, so its skills, rules, commands and ADRs are drawn as a second layer: ` +
|
|
539
|
+
Object.entries(a.relationCounts)
|
|
540
|
+
.sort()
|
|
541
|
+
.map(([rel, n]) => `${n} \`${rel}\``)
|
|
542
|
+
.join(' · ') +
|
|
543
|
+
'. Most referenced:',
|
|
544
|
+
'',
|
|
545
|
+
...top.map(([n, c]) => `- ${code(a.nodes[n]?.[0] ?? '')} — ${c}`),
|
|
546
|
+
'',
|
|
547
|
+
].join('\n'),
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const entries = f.filter((x) => x.role === 'entry');
|
|
552
|
+
sections.push([
|
|
553
|
+
'Entry points',
|
|
554
|
+
[
|
|
555
|
+
'## Entry points',
|
|
556
|
+
'',
|
|
557
|
+
...(entries.length
|
|
558
|
+
? entries.slice(0, m.thresholds.listCap).map((x) => `- ${code(x.path)}`)
|
|
559
|
+
: ['- none declared']),
|
|
560
|
+
...(entries.length > m.thresholds.listCap ? [`- … ${entries.length - m.thresholds.listCap} more`] : []),
|
|
561
|
+
'',
|
|
562
|
+
].join('\n'),
|
|
563
|
+
]);
|
|
564
|
+
|
|
565
|
+
const roleLines = ROLE_ORDER.filter((r) => m.analyses.roleCounts[r] > 0).map(
|
|
566
|
+
(r) => `| ${r} | ${m.analyses.roleCounts[r]} |`,
|
|
567
|
+
);
|
|
568
|
+
sections.push([
|
|
569
|
+
'Roles',
|
|
570
|
+
[
|
|
571
|
+
'## Files by role',
|
|
572
|
+
'',
|
|
573
|
+
'*isolated* means no trusted link in or out — never "dead": a file reached by reflection,',
|
|
574
|
+
'a string path or a framework convention the engine does not read is isolated too.',
|
|
575
|
+
'',
|
|
576
|
+
'| Role | Files |',
|
|
577
|
+
'|---|---|',
|
|
578
|
+
...roleLines,
|
|
579
|
+
'',
|
|
580
|
+
].join('\n'),
|
|
581
|
+
]);
|
|
582
|
+
|
|
583
|
+
sections.push([
|
|
584
|
+
'External dependencies',
|
|
585
|
+
[
|
|
586
|
+
'## External dependencies',
|
|
587
|
+
'',
|
|
588
|
+
...(m.analyses.externals.length
|
|
589
|
+
? m.analyses.externals.map(([spec, n]) => `- ${code(spec)} — imported by ${n} files`)
|
|
590
|
+
: ['- none']),
|
|
591
|
+
'',
|
|
592
|
+
].join('\n'),
|
|
593
|
+
]);
|
|
594
|
+
|
|
595
|
+
sections.push([
|
|
596
|
+
'Guesses',
|
|
597
|
+
[
|
|
598
|
+
'## Guessed links',
|
|
599
|
+
'',
|
|
600
|
+
`${m.analyses.guessEdgeCount} links were resolved by name only (no import, no type) and are not drawn by default; ` +
|
|
601
|
+
`they are ${pct(m.analyses.guessShare)} of all file-to-file links.`,
|
|
602
|
+
'',
|
|
603
|
+
].join('\n'),
|
|
604
|
+
]);
|
|
605
|
+
|
|
606
|
+
const budget = MAP_TOKEN_BUDGET * CHARS_PER_TOKEN;
|
|
607
|
+
let out = head;
|
|
608
|
+
const cut: string[] = [];
|
|
609
|
+
for (const [name, body] of sections) {
|
|
610
|
+
if (out.length + body.length <= budget) out += `${body}\n`;
|
|
611
|
+
else cut.push(name);
|
|
612
|
+
}
|
|
613
|
+
if (cut.length) {
|
|
614
|
+
out +=
|
|
615
|
+
`> Budget of ${MAP_TOKEN_BUDGET} tokens reached — cut: ${cut.join(', ')}. ` +
|
|
616
|
+
'They are in `map.json`, in the per-area pages and in `index.html`.\n';
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function renderAreaMd(m: MapModel, i: number, meta: MapMeta): string {
|
|
622
|
+
const a = m.areas[i];
|
|
623
|
+
if (!a) return '';
|
|
624
|
+
const f = m.files;
|
|
625
|
+
const inArea = new Set(a.files);
|
|
626
|
+
const outside = new Map<number, number>();
|
|
627
|
+
const inside: number[] = [];
|
|
628
|
+
for (const [s, t, w] of m.edges) {
|
|
629
|
+
if (inArea.has(s) && !inArea.has(t)) outside.set(t, (outside.get(t) ?? 0) + w);
|
|
630
|
+
if (inArea.has(s) && inArea.has(t)) inside.push(w);
|
|
631
|
+
}
|
|
632
|
+
const deps = [...outside.entries()].sort((x, y) => y[1] - x[1] || x[0] - y[0]).slice(0, m.thresholds.listCap);
|
|
633
|
+
const files = [...a.files].sort((x, y) => (f[y]?.inFiles ?? 0) - (f[x]?.inFiles ?? 0) || x - y);
|
|
634
|
+
return [
|
|
635
|
+
`# ${a.id} — ${md(a.name)}`,
|
|
636
|
+
'',
|
|
637
|
+
`> Part of [${md(meta.title)}](../MAP.md) at \`${meta.commit}\`. Generated; never edit by hand.`,
|
|
638
|
+
'',
|
|
639
|
+
`${a.files.length} files · cohesion ${pct(a.cohesion)} · tests ${pct(a.testShare)} · outside hub folder ${pct(a.folderMismatch)}`,
|
|
640
|
+
'',
|
|
641
|
+
'## Files, most used first',
|
|
642
|
+
'',
|
|
643
|
+
'| File | Role | Used by | Uses |',
|
|
644
|
+
'|---|---|---|---|',
|
|
645
|
+
...files.slice(0, 60).map((x) => {
|
|
646
|
+
const r = f[x];
|
|
647
|
+
return r ? `| ${code(r.path)} | ${r.role} | ${r.inFiles} | ${r.outFiles} |` : '';
|
|
648
|
+
}),
|
|
649
|
+
...(files.length > 60 ? [`| … ${files.length - 60} more | | | |`] : []),
|
|
650
|
+
'',
|
|
651
|
+
'## What this area depends on outside itself',
|
|
652
|
+
'',
|
|
653
|
+
...(deps.length
|
|
654
|
+
? deps.map(([t, w]) => {
|
|
655
|
+
const r = f[t];
|
|
656
|
+
const where = r && r.area >= 0 ? m.areas[r.area]?.id ?? '' : r?.role ?? '';
|
|
657
|
+
return `- ${code(r?.path ?? '')} (${where}) — ${w} link(s)`;
|
|
658
|
+
})
|
|
659
|
+
: ['- nothing']),
|
|
660
|
+
'',
|
|
661
|
+
].join('\n');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function readViewer(name: string): string {
|
|
665
|
+
return fs.readFileSync(path.join(VIEWER_DIR, name), 'utf-8');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Escape every `<` so no file or symbol name can close the data block. */
|
|
669
|
+
export function embedJson(value: unknown): string {
|
|
670
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function htmlText(s: string): string {
|
|
674
|
+
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sha256(s: string): string {
|
|
678
|
+
return createHash('sha256').update(s, 'utf-8').digest('base64');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* One self-contained page. The script and style are inline and pinned by hash
|
|
683
|
+
* in the Content-Security-Policy, which also forbids every network fetch:
|
|
684
|
+
* the page cannot load anything, so "offline" is enforced rather than hoped.
|
|
685
|
+
*/
|
|
686
|
+
export function renderHtml(m: MapModel, meta: MapMeta): string {
|
|
687
|
+
const css = readViewer('viewer.css');
|
|
688
|
+
const js = readViewer('viewer.js');
|
|
689
|
+
const data = embedJson({ meta, model: m });
|
|
690
|
+
const csp = [
|
|
691
|
+
"default-src 'none'",
|
|
692
|
+
`script-src 'sha256-${sha256(js)}'`,
|
|
693
|
+
`style-src 'sha256-${sha256(css)}'`,
|
|
694
|
+
"img-src 'none'",
|
|
695
|
+
"connect-src 'none'",
|
|
696
|
+
"base-uri 'none'",
|
|
697
|
+
"form-action 'none'",
|
|
698
|
+
].join('; ');
|
|
699
|
+
return [
|
|
700
|
+
'<!doctype html>',
|
|
701
|
+
'<html lang="en">',
|
|
702
|
+
'<head>',
|
|
703
|
+
'<meta charset="utf-8">',
|
|
704
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
705
|
+
`<meta http-equiv="Content-Security-Policy" content="${csp}">`,
|
|
706
|
+
`<title>${htmlText(meta.title)}</title>`,
|
|
707
|
+
`<style>${css}</style>`,
|
|
708
|
+
'</head>',
|
|
709
|
+
'<body>',
|
|
710
|
+
'<header>',
|
|
711
|
+
`<h1>${htmlText(meta.title)}</h1>`,
|
|
712
|
+
'<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"></nav>',
|
|
713
|
+
'<div class="search"><input id="search" type="search" autocomplete="off"><ul class="results" id="results"></ul></div>',
|
|
714
|
+
'<div class="toolbar">',
|
|
715
|
+
'<button type="button" id="lens-areas" aria-pressed="true"></button>',
|
|
716
|
+
'<button type="button" id="lens-folders" aria-pressed="false"></button>',
|
|
717
|
+
'<button type="button" id="lens-artefacts" aria-pressed="false" hidden></button>',
|
|
718
|
+
'<button type="button" id="t-infra" aria-pressed="true"></button>',
|
|
719
|
+
'<button type="button" id="t-guess" aria-pressed="false"></button>',
|
|
720
|
+
'<button type="button" id="t-tests" aria-pressed="true"></button>',
|
|
721
|
+
'<button type="button" id="z-out">−</button>',
|
|
722
|
+
'<button type="button" id="z-in">+</button>',
|
|
723
|
+
'<button type="button" id="z-fit"></button>',
|
|
724
|
+
'<button type="button" id="theme"></button>',
|
|
725
|
+
'<button type="button" id="lang"></button>',
|
|
726
|
+
'</div>',
|
|
727
|
+
'</header>',
|
|
728
|
+
'<main>',
|
|
729
|
+
'<div class="stage"><canvas id="stage" role="img" aria-label="Project map"></canvas></div>',
|
|
730
|
+
'<aside id="panel" aria-live="polite"></aside>',
|
|
731
|
+
'</main>',
|
|
732
|
+
`<script type="application/json" id="map-data">${data}</script>`,
|
|
733
|
+
`<script>${js}</script>`,
|
|
734
|
+
'</body>',
|
|
735
|
+
'</html>',
|
|
736
|
+
'',
|
|
737
|
+
].join('\n');
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** The map a previous `code-graph map` wrote, or null when there is none. */
|
|
741
|
+
export function readMap(root: string): { meta: MapMeta; model: MapModel } | null {
|
|
742
|
+
return readMapAt(path.join(root, MAP_DIR_REL));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function readMapAt(dir: string): { meta: MapMeta; model: MapModel } | null {
|
|
746
|
+
const p = path.join(dir, 'map.json');
|
|
747
|
+
try {
|
|
748
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf-8')) as { meta?: MapMeta; model?: Partial<MapModel> } | null;
|
|
749
|
+
const m = parsed?.model;
|
|
750
|
+
// A map from another model version, or a stray file, reads as "no map"
|
|
751
|
+
// rather than crashing a reader that trusts its shape.
|
|
752
|
+
if (
|
|
753
|
+
!parsed?.meta ||
|
|
754
|
+
!m ||
|
|
755
|
+
m.version !== MAP_MODEL_VERSION ||
|
|
756
|
+
!Array.isArray(m.files) ||
|
|
757
|
+
!Array.isArray(m.areas) ||
|
|
758
|
+
!Array.isArray(m.edges) ||
|
|
759
|
+
typeof m.analyses !== 'object' ||
|
|
760
|
+
m.analyses === null
|
|
761
|
+
) {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
return parsed as { meta: MapMeta; model: MapModel };
|
|
765
|
+
} catch {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export interface AreaReport {
|
|
771
|
+
id: string;
|
|
772
|
+
name: string;
|
|
773
|
+
kind: string;
|
|
774
|
+
files: Array<{ path: string; role: string; usedBy: number; uses: number }>;
|
|
775
|
+
hubs: string[];
|
|
776
|
+
cohesion: number;
|
|
777
|
+
testShare: number;
|
|
778
|
+
folderMismatch: number;
|
|
779
|
+
dependsOn: Array<{ path: string; area: string; links: number }>;
|
|
780
|
+
usedByAreas: Array<{ area: string; links: number }>;
|
|
781
|
+
page: string | null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* One area, found by id (`a3`), by exact name, or by a file it contains —
|
|
786
|
+
* exact matches only, so the answer is always about the area that was named.
|
|
787
|
+
*/
|
|
788
|
+
export function areaReport(m: MapModel, query: string): AreaReport | { refusal: string; candidates: string[] } {
|
|
789
|
+
let i = m.areas.findIndex((a) => a.id === query || a.name === query);
|
|
790
|
+
if (i < 0) {
|
|
791
|
+
const f = m.files.findIndex((x) => x.path === query);
|
|
792
|
+
if (f >= 0) i = m.files[f]?.area ?? -1;
|
|
793
|
+
if (f >= 0 && i < 0) {
|
|
794
|
+
return { refusal: `${query} belongs to no area (role ${m.files[f]?.role ?? '?'})`, candidates: [] };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (i < 0) {
|
|
798
|
+
const q = query.toLowerCase();
|
|
799
|
+
const candidates = m.areas
|
|
800
|
+
.filter((a) => a.name.toLowerCase().includes(q))
|
|
801
|
+
.slice(0, 10)
|
|
802
|
+
.map((a) => `${a.id} ${a.name}`);
|
|
803
|
+
return { refusal: `no area is exactly '${query}'`, candidates };
|
|
804
|
+
}
|
|
805
|
+
const a = m.areas[i] as MapModel['areas'][number];
|
|
806
|
+
const inArea = new Set(a.files);
|
|
807
|
+
const out = new Map<number, number>();
|
|
808
|
+
const inbound = new Map<number, number>();
|
|
809
|
+
for (const [s, t, w] of m.edges) {
|
|
810
|
+
if (inArea.has(s) && !inArea.has(t)) out.set(t, (out.get(t) ?? 0) + w);
|
|
811
|
+
if (inArea.has(t) && !inArea.has(s)) {
|
|
812
|
+
const sa = m.files[s]?.area ?? -1;
|
|
813
|
+
if (sa >= 0) inbound.set(sa, (inbound.get(sa) ?? 0) + w);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const cap = m.thresholds.listCap;
|
|
817
|
+
const files = [...a.files]
|
|
818
|
+
.sort((x, y) => (m.files[y]?.inFiles ?? 0) - (m.files[x]?.inFiles ?? 0) || x - y)
|
|
819
|
+
.map((x) => {
|
|
820
|
+
const r = m.files[x];
|
|
821
|
+
return { path: r?.path ?? '', role: r?.role ?? '', usedBy: r?.inFiles ?? 0, uses: r?.outFiles ?? 0 };
|
|
822
|
+
});
|
|
823
|
+
return {
|
|
824
|
+
id: a.id,
|
|
825
|
+
name: a.name,
|
|
826
|
+
kind: a.kind,
|
|
827
|
+
files: files.slice(0, 60),
|
|
828
|
+
hubs: files.slice(0, 5).map((x) => x.path),
|
|
829
|
+
cohesion: a.cohesion,
|
|
830
|
+
testShare: a.testShare,
|
|
831
|
+
folderMismatch: a.folderMismatch,
|
|
832
|
+
dependsOn: [...out.entries()]
|
|
833
|
+
.sort((x, y) => y[1] - x[1] || x[0] - y[0])
|
|
834
|
+
.slice(0, cap)
|
|
835
|
+
.map(([t, w]) => {
|
|
836
|
+
const r = m.files[t];
|
|
837
|
+
const ai = r?.area ?? -1;
|
|
838
|
+
return { path: r?.path ?? '', area: ai >= 0 ? (m.areas[ai]?.id ?? '') : (r?.role ?? ''), links: w };
|
|
839
|
+
}),
|
|
840
|
+
usedByAreas: [...inbound.entries()]
|
|
841
|
+
.sort((x, y) => y[1] - x[1] || x[0] - y[0])
|
|
842
|
+
.slice(0, cap)
|
|
843
|
+
.map(([ai, w]) => ({ area: `${m.areas[ai]?.id ?? ''} ${m.areas[ai]?.name ?? ''}`, links: w })),
|
|
844
|
+
page: a.files.length >= AREA_PAGE_MIN_FILES ? path.posix.join('agents', 'map', 'areas', `${a.id}.md`) : null,
|
|
845
|
+
};
|
|
846
|
+
}
|