@dotdotgod/cli 0.1.21

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/src/core.mjs ADDED
@@ -0,0 +1,2458 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Graph, leiden } from 'leiden-ts';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
6
+ import { runInit } from './init.mjs';
7
+
8
+ export const CACHE_VERSION = 10;
9
+ const CACHE_DIR = '.dotdotgod';
10
+ const MANIFEST_FILE = 'manifest.json';
11
+ const GRAPH_NODE_SHARDS = ['docs', 'packages', 'source'];
12
+ const GRAPH_EDGE_SHARDS = ['docs-links', 'packages', 'other'];
13
+
14
+ const HELP_TOKENS = new Set(['help', '--help', '-h']);
15
+ const VERSION_TOKENS = new Set(['version', '--version', '-v']);
16
+
17
+ function commandUsage(command = 'root') {
18
+ switch (command) {
19
+ case 'validate':
20
+ return `Usage:
21
+ dotdotgod validate <root> [--include-local-memory] [--check-index] [--max-lines n] [--max-chars n] [--no-link-check] [--json]`;
22
+ case 'init':
23
+ return `Usage:
24
+ dotdotgod init <root> [--project-name NAME] [--dotdot-setting] [--force] [--dry-run] [--json]
25
+
26
+ Create AGENTS.md, agent entrypoints, docs indexes, and local memory gitignore entries.`;
27
+ case 'index':
28
+ return `Usage:
29
+ dotdotgod index <root> [--json]`;
30
+ case 'config':
31
+ return `Usage:
32
+ dotdotgod config <root> [--json]
33
+ dotdotgod config init <root> [--force] [--json]
34
+
35
+ Inspect or initialize the project-level dotdotgod config file.`;
36
+ case 'config init':
37
+ return `Usage:
38
+ dotdotgod config init <root> [--force] [--json]
39
+
40
+ Create dotdotgod.config.json with the built-in default memory, traceability, validation, and impact ranking policy.`;
41
+ case 'status':
42
+ return `Usage:
43
+ dotdotgod status <root> [--json]`;
44
+ case 'load-snapshot':
45
+ return `Usage:
46
+ dotdotgod load-snapshot <root> [--json]`;
47
+ case 'resolve':
48
+ return `Usage:
49
+ dotdotgod resolve <root> <ref> [--max-results n] [--include-archive] [--json]`;
50
+ case 'expand':
51
+ return `Usage:
52
+ dotdotgod expand <root> <prompt> [--max-results n] [--include-archive] [--with-impact] [--fuzzy] [--json]`;
53
+ case 'graph':
54
+ return `Usage:
55
+ dotdotgod graph impact <root> --changed <path> [--compact] [--json]
56
+ dotdotgod graph communities <root> [--json]`;
57
+ case 'graph impact':
58
+ return `Usage:
59
+ dotdotgod graph impact <root> --changed <path> [--compact] [--json]
60
+
61
+ Ranks nodes related to a changed file. <root> is the project root; --changed is a project-relative file path. Use --compact for an agent-facing grouped summary.`;
62
+ case 'graph communities':
63
+ return `Usage:
64
+ dotdotgod graph communities <root> [--json]`;
65
+ default:
66
+ return `Usage:
67
+ dotdotgod [--help|-h]
68
+ dotdotgod [--version|-v]
69
+ dotdotgod help [command]
70
+ dotdotgod validate <root> [--include-local-memory] [--check-index] [--max-lines n] [--max-chars n] [--no-link-check] [--json]
71
+ dotdotgod init <root> [--project-name NAME] [--dotdot-setting] [--force] [--dry-run] [--json]
72
+ dotdotgod index <root> [--json]
73
+ dotdotgod config <root> [--json]
74
+ dotdotgod config init <root> [--force] [--json]
75
+ dotdotgod status <root> [--json]
76
+ dotdotgod load-snapshot <root> [--json]
77
+ dotdotgod resolve <root> <ref> [--max-results n] [--include-archive] [--json]
78
+ dotdotgod expand <root> <prompt> [--max-results n] [--include-archive] [--with-impact] [--fuzzy] [--json]
79
+ dotdotgod graph impact <root> --changed <path> [--compact] [--json]
80
+ dotdotgod graph communities <root> [--json]`;
81
+ }
82
+ }
83
+
84
+ function usage(message, command = 'root') {
85
+ const text = commandUsage(command);
86
+ if (message) {
87
+ console.error(message);
88
+ console.error(text);
89
+ process.exit(2);
90
+ }
91
+ console.log(text);
92
+ process.exit(0);
93
+ }
94
+
95
+ function isHelpToken(value) {
96
+ return HELP_TOKENS.has(value);
97
+ }
98
+
99
+ function hasHelpToken(argv) {
100
+ return argv.some((arg) => isHelpToken(arg));
101
+ }
102
+
103
+ function isVersionToken(value) {
104
+ return VERSION_TOKENS.has(value);
105
+ }
106
+
107
+ function readCliVersion() {
108
+ try {
109
+ const data = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
110
+ return typeof data.version === 'string' ? data.version : 'unknown';
111
+ } catch {
112
+ return 'unknown';
113
+ }
114
+ }
115
+
116
+ function printVersion() {
117
+ console.log(readCliVersion());
118
+ process.exit(0);
119
+ }
120
+
121
+ function helpCommandFromArgs(args) {
122
+ const nonHelp = args.filter((arg) => !isHelpToken(arg));
123
+ if (nonHelp[0] === 'graph' && nonHelp[1]) return `graph ${nonHelp[1]}`;
124
+ if (nonHelp[0] === 'config' && nonHelp[1] === 'init') return 'config init';
125
+ return nonHelp[0] ?? 'root';
126
+ }
127
+
128
+ export function parseCommon(argv) {
129
+ const options = { root: '.', json: false };
130
+ for (let i = 0; i < argv.length; i += 1) {
131
+ const arg = argv[i];
132
+ if (arg === '--json') options.json = true;
133
+ else if (!arg.startsWith('-') && options.root === '.') options.root = arg;
134
+ }
135
+ options.root = resolve(options.root);
136
+ return options;
137
+ }
138
+
139
+ export function rel(root, file) {
140
+ return relative(root, file).replaceAll('\\', '/');
141
+ }
142
+
143
+ export function isKebabCase(value) {
144
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
145
+ }
146
+
147
+ export function isUpperSnakeMarkdown(value) {
148
+ return value === 'README.md' || /^[A-Z0-9][A-Z0-9_]*\.md$/.test(value);
149
+ }
150
+
151
+ export function removeCodeBlocks(content) {
152
+ return content.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1\s*$/gm, '');
153
+ }
154
+
155
+ export function extractLinks(content) {
156
+ const links = [];
157
+ const lines = removeCodeBlocks(content).split('\n');
158
+ const re = /\[[^\]]*\]\(([^)]+)\)/g;
159
+ lines.forEach((lineText, index) => {
160
+ let match;
161
+ while ((match = re.exec(lineText)) !== null) {
162
+ const href = match[1].trim();
163
+ if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) continue;
164
+ links.push({ href, line: index + 1 });
165
+ }
166
+ });
167
+ return links;
168
+ }
169
+
170
+ const TRACEABILITY_PATH_FIELDS = ['implementedBy', 'verifiedBy', 'relatedDocs'];
171
+ const TRACEABILITY_COMMAND_FIELDS = ['verificationCommands'];
172
+
173
+ export function traceabilityExample() {
174
+ return 'Expected dotdotgod traceability block:\n\n```json dotdotgod\n{\n "kind": "spec",\n "implementedBy": ["packages/..."],\n "verifiedBy": ["packages/..."],\n "relatedDocs": ["docs/..."],\n "verificationCommands": ["pnpm ..."]\n}\n```\n\nProperty guidance:\n- kind: use "spec" for behavior specs.\n- implementedBy: source/config/script files that implement this spec\'s behavior.\n- verifiedBy: test files or verification docs that check this behavior.\n- relatedDocs: docs with relevant architecture, test strategy, or product context.\n- verificationCommands: commands an agent can run to verify this behavior.';
175
+ }
176
+
177
+ function lineForOffset(content, offset) {
178
+ return content.slice(0, offset).split('\n').length;
179
+ }
180
+
181
+ export function extractDotdotgodTraceabilityBlocks(content) {
182
+ const blocks = [];
183
+ const re = /^(`{3,}|~{3,})[ \t]*([^\n]*)\n([\s\S]*?)\n\1[ \t]*$/gm;
184
+ let match;
185
+ while ((match = re.exec(content)) !== null) {
186
+ const info = match[2].trim().toLowerCase().split(/\s+/);
187
+ if (!info.includes('json') || !info.includes('dotdotgod')) continue;
188
+ const raw = match[3].trim();
189
+ const line = lineForOffset(content, match.index);
190
+ try {
191
+ blocks.push({ data: JSON.parse(raw), raw, line });
192
+ } catch (error) {
193
+ blocks.push({ error: error instanceof Error ? error.message : String(error), raw, line });
194
+ }
195
+ }
196
+ return blocks;
197
+ }
198
+
199
+ function isLocalRelativeTraceabilityPath(value) {
200
+ if (typeof value !== 'string' || value.trim() !== value || value.length === 0) return false;
201
+ if (value.startsWith('/') || value.startsWith('~') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return false;
202
+ if (value.split('/').includes('..')) return false;
203
+ return !isSecretIndexPath(value);
204
+ }
205
+
206
+ function traceabilityFieldError(file, code, field, message, line = null) {
207
+ return { file, line, code, message: `${field ? `Field "${field}": ` : ''}${message}\nFix: update the traceability block so it matches the expected schema and points to existing project files or commands.\n\n${traceabilityExample()}` };
208
+ }
209
+
210
+ export function validateTraceabilityPlacement(content, root, file) {
211
+ const headings = [...content.matchAll(/^##\s+(.+)$/gm)];
212
+ const lastHeading = headings.at(-1)?.[1]?.trim();
213
+ if (lastHeading !== 'Traceability') {
214
+ return [traceabilityFieldError(rel(root, file), 'TRACEABILITY_PLACEMENT', null, 'Traceability must be the final section in behavior specs.')];
215
+ }
216
+ return [];
217
+ }
218
+
219
+ export function validateTraceabilityBlock(data, root, file, line = null) {
220
+ const errors = [];
221
+ const add = (code, field, message) => errors.push(traceabilityFieldError(rel(root, file), code, field, message, line));
222
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
223
+ add('TRACEABILITY_INVALID_JSON', null, 'Traceability block must be a JSON object.');
224
+ return errors;
225
+ }
226
+ if (data.kind !== 'spec') add('TRACEABILITY_INVALID_KIND', 'kind', 'must be "spec" for behavior specs.');
227
+ for (const field of TRACEABILITY_PATH_FIELDS) {
228
+ if (!Array.isArray(data[field])) {
229
+ add('TRACEABILITY_INVALID_FIELD', field, 'must be an array of local relative paths.');
230
+ continue;
231
+ }
232
+ for (const value of data[field]) {
233
+ if (!isLocalRelativeTraceabilityPath(value)) {
234
+ add('TRACEABILITY_INVALID_PATH', field, `invalid local relative path: ${JSON.stringify(value)}.`);
235
+ continue;
236
+ }
237
+ if (!existsSync(resolve(root, value))) add('TRACEABILITY_MISSING_TARGET', field, `target does not exist: ${value}.`);
238
+ }
239
+ }
240
+ for (const field of TRACEABILITY_COMMAND_FIELDS) {
241
+ if (!Array.isArray(data[field])) {
242
+ add('TRACEABILITY_INVALID_FIELD', field, 'must be an array of executable project-local command strings.');
243
+ continue;
244
+ }
245
+ for (const value of data[field]) if (typeof value !== 'string' || value.trim().length === 0) add('TRACEABILITY_INVALID_COMMAND', field, `invalid command: ${JSON.stringify(value)}.`);
246
+ }
247
+ return errors;
248
+ }
249
+
250
+ export function headingToAnchor(text) {
251
+ return text
252
+ .replace(/`([^`]+)`/g, '$1')
253
+ .toLowerCase()
254
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
255
+ .trim()
256
+ .replace(/\s+/g, '-');
257
+ }
258
+
259
+ export function extractAnchors(content) {
260
+ const anchors = new Set();
261
+ const seen = new Map();
262
+ const re = /^#{1,6}\s+(.+)$/gm;
263
+ let match;
264
+ while ((match = re.exec(content)) !== null) {
265
+ const base = headingToAnchor(match[1]);
266
+ if (!base) continue;
267
+ const count = seen.get(base) ?? 0;
268
+ seen.set(base, count + 1);
269
+ anchors.add(count === 0 ? base : `${base}-${count}`);
270
+ }
271
+ return anchors;
272
+ }
273
+
274
+ export function runValidate(argv) {
275
+ const options = { root: '.', includeLocalMemory: false, checkIndex: false, maxLines: null, maxChars: null, linkCheck: true, json: false };
276
+ for (let i = 0; i < argv.length; i += 1) {
277
+ const arg = argv[i];
278
+ if (arg === '--include-local-memory') options.includeLocalMemory = true;
279
+ else if (arg === '--check-index') options.checkIndex = true;
280
+ else if (arg === '--max-lines') options.maxLines = Number(argv[++i]);
281
+ else if (arg === '--max-chars') options.maxChars = Number(argv[++i]);
282
+ else if (arg === '--no-link-check') options.linkCheck = false;
283
+ else if (arg === '--json') options.json = true;
284
+ else if (!arg.startsWith('-')) options.root = arg;
285
+ else usage(`Unknown option: ${arg}`, 'validate');
286
+ }
287
+
288
+ const root = resolve(options.root);
289
+ const docs = join(root, 'docs');
290
+ const errors = [];
291
+ const markdownFiles = [];
292
+ const fileCache = new Map();
293
+ const addError = (file, code, message, line = null, fix = null) => errors.push({ file: rel(root, file), line, code, message: fix ? `${message}\nFix: ${fix}` : message });
294
+ const shouldSkipDir = (dir) => {
295
+ const path = rel(root, dir);
296
+ if (!path || path === '.') return false;
297
+ if (path.includes('/node_modules') || path === 'node_modules') return true;
298
+ if (path.includes('/.git') || path === '.git') return true;
299
+ if (path === CACHE_DIR || path.startsWith(`${CACHE_DIR}/`)) return true;
300
+ if (!options.includeLocalMemory && (path === 'docs/plan' || path.startsWith('docs/plan/') || path === 'docs/archive' || path.startsWith('docs/archive/'))) return true;
301
+ return false;
302
+ };
303
+ const walk = (dir) => {
304
+ if (!existsSync(dir) || shouldSkipDir(dir)) return;
305
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
306
+ const path = join(dir, entry.name);
307
+ if (entry.isDirectory()) {
308
+ const docsRel = rel(docs, path);
309
+ if (docsRel && !docsRel.startsWith('..')) {
310
+ for (const part of docsRel.split('/')) if (part && !isKebabCase(part)) addError(path, 'DIR_NAMING', `Directory must be kebab-case: ${part}`, null, 'rename this docs directory to kebab-case and update any links that reference it.');
311
+ }
312
+ walk(path);
313
+ } else if (entry.isFile() && entry.name.endsWith('.md')) markdownFiles.push(path);
314
+ }
315
+ };
316
+
317
+ if (!existsSync(docs)) usage(`docs directory not found: ${docs}`);
318
+ const memoryConfig = readMemoryConfig(root);
319
+ const validationPolicy = cloneValidationPolicy(memoryConfig.validation ?? DEFAULT_VALIDATION_POLICY);
320
+ const maxLines = options.maxLines ?? validationPolicy.markdown.maxLines;
321
+ const maxChars = options.maxChars ?? validationPolicy.markdown.maxChars;
322
+ for (const error of memoryConfig.errors ?? []) errors.push(error);
323
+ walk(docs);
324
+ for (const file of markdownFiles) {
325
+ const name = basename(file);
326
+ const docsRel = rel(docs, file);
327
+ if (docsRel && !docsRel.startsWith('..') && !isUpperSnakeMarkdown(name)) addError(file, 'FILE_NAMING', `Markdown file must be UPPER_SNAKE_CASE.md or README.md: ${name}`, null, 'rename the markdown file to UPPER_SNAKE_CASE.md or README.md and update any links that reference it.');
328
+ const content = readFileSync(file, 'utf8');
329
+ fileCache.set(file, content);
330
+ const lines = content.split('\n').length;
331
+ const path = rel(root, file);
332
+ const skipSizeChecks = isMarkdownSizeExcluded(path, memoryConfig);
333
+ if (!skipSizeChecks && lines > maxLines) addError(file, 'FILE_TOO_LONG', `Markdown file has ${lines} lines; max is ${maxLines}`, null, 'split the document into focused markdown files and update the nearest README.md index, or add a narrow validation.markdown.exclude entry if this file is intentionally oversized.');
334
+ if (!skipSizeChecks && content.length > maxChars) addError(file, 'FILE_TOO_LARGE', `Markdown file has ${content.length} characters; max is ${maxChars}`, null, 'split the document into focused markdown files and update the nearest README.md index, or add a narrow validation.markdown.exclude entry if this file is intentionally oversized.');
335
+ if (requiresTraceability(rel(root, file), memoryConfig)) {
336
+ const blocks = extractDotdotgodTraceabilityBlocks(content);
337
+ if (blocks.length === 0) addError(file, 'TRACEABILITY_MISSING', `Behavior specs must include a fenced \`json dotdotgod\` traceability block as the final section.\nFix: add a final \`## Traceability\` section with the expected \`json dotdotgod\` block and point it at the relevant source, tests, related docs, and verification commands.\n\n${traceabilityExample()}`);
338
+ else for (const error of validateTraceabilityPlacement(content, root, file)) errors.push(error);
339
+ for (const block of blocks) {
340
+ if (block.error) addError(file, 'TRACEABILITY_INVALID_JSON', `Invalid \`json dotdotgod\` block: ${block.error}\nFix: repair the fenced \`json dotdotgod\` block so it is valid JSON and still matches the expected schema.\n\n${traceabilityExample()}`, block.line);
341
+ else for (const error of validateTraceabilityBlock(block.data, root, file, block.line)) errors.push(error);
342
+ }
343
+ }
344
+ }
345
+ const byDir = new Map();
346
+ for (const file of markdownFiles) byDir.set(dirname(file), [...(byDir.get(dirname(file)) ?? []), file]);
347
+ for (const [dir, files] of byDir) if (files.length > 1 && !files.some((file) => basename(file) === 'README.md')) addError(dir, 'MISSING_README', 'Directory with multiple markdown files must include README.md', null, 'add a README.md in this directory that indexes the important markdown files and their purpose.');
348
+ if (options.linkCheck) {
349
+ for (const [file, content] of fileCache) {
350
+ const fileDir = dirname(file);
351
+ for (const { href, line } of extractLinks(content)) {
352
+ const hashIndex = href.indexOf('#');
353
+ const pathPart = hashIndex === -1 ? href : href.slice(0, hashIndex);
354
+ const anchor = hashIndex === -1 ? '' : href.slice(hashIndex + 1);
355
+ const target = pathPart ? resolve(fileDir, pathPart) : file;
356
+ if (pathPart && !existsSync(target)) {
357
+ addError(file, 'BROKEN_LINK', `Local link target does not exist: ${pathPart}`, line, 'update the link target to an existing local file, create the intended file, or remove the stale link.');
358
+ continue;
359
+ }
360
+ if (anchor && extname(target) === '.md') {
361
+ const targetContent = fileCache.get(target) ?? (existsSync(target) ? readFileSync(target, 'utf8') : '');
362
+ if (targetContent && !extractAnchors(targetContent).has(decodeURIComponent(anchor))) addError(file, 'BROKEN_ANCHOR', `Local anchor target does not exist: ${href}`, line, 'update the fragment to a heading that exists in the target markdown file, or add the missing heading.');
363
+ }
364
+ }
365
+ }
366
+ }
367
+ if (options.checkIndex) {
368
+ const index = readIndex(root);
369
+ if (!index) {
370
+ addError(cacheFile(root), 'INDEX_MISSING', 'Expected .dotdotgod index cache.', null, 'run `dotdotgod index <root>` or a lazy-refreshing command such as `dotdotgod load-snapshot <root> --json`.');
371
+ } else {
372
+ const schemaVersion = index.schemaVersion ?? index.version ?? null;
373
+ if (schemaVersion !== CACHE_VERSION) addError(cacheFile(root), 'INDEX_SCHEMA_MISMATCH', `Index schema is ${String(schemaVersion)}; expected ${CACHE_VERSION}.`, null, 'run `dotdotgod index <root>` to rebuild the cache with the current schema.');
374
+ const indexed = new Map((index.files ?? []).map((file) => [file.path, file.sha256]));
375
+ const indexableMarkdownPaths = new Set(collectIndexFiles(root, memoryConfig).map((file) => rel(root, file)).filter((path) => path.endsWith('.md')));
376
+ for (const file of markdownFiles) {
377
+ const path = rel(root, file);
378
+ if (!indexableMarkdownPaths.has(path)) continue;
379
+ const indexedHash = indexed.get(path);
380
+ const currentHash = fingerprint(file);
381
+ if (!indexedHash) addError(file, 'INDEX_MISSING_FILE', 'Markdown file is not present in the current graph index.', null, 'run `dotdotgod index <root>` to refresh the graph index.');
382
+ else if (indexedHash !== currentHash) addError(file, 'INDEX_STALE', 'Markdown fingerprint differs from the current graph index.', null, 'run `dotdotgod index <root>` or a lazy-refreshing command such as `dotdotgod load-snapshot <root> --json`.');
383
+ }
384
+ }
385
+ }
386
+
387
+ if (options.includeLocalMemory) {
388
+ for (const area of ['plan', 'archive/plan', 'archive/report']) {
389
+ const areaRoot = join(docs, area);
390
+ if (!existsSync(areaRoot)) continue;
391
+ for (const entry of readdirSync(areaRoot, { withFileTypes: true })) {
392
+ if (!entry.isDirectory()) continue;
393
+ if (!isKebabCase(entry.name)) addError(join(areaRoot, entry.name), 'SLUG_NAMING', `Archive/plan/report slug must be kebab-case: ${entry.name}`, null, 'rename the task/report directory to kebab-case and update any README index links that reference it.');
394
+ const readme = join(areaRoot, entry.name, 'README.md');
395
+ if (!existsSync(readme)) addError(readme, 'MISSING_README', `Expected README.md in docs/${area}/${entry.name}/`, null, 'add a README.md that summarizes the task/report and links any supporting files.');
396
+ }
397
+ }
398
+ }
399
+ const gitignore = join(root, '.gitignore');
400
+ if (!existsSync(gitignore)) addError(gitignore, 'MISSING_GITIGNORE', 'Expected .gitignore', null, 'create .gitignore and include docs/plan, docs/archive, and .dotdotgod entries.');
401
+ else {
402
+ const content = readFileSync(gitignore, 'utf8').split('\n').map((line) => line.trim());
403
+ for (const required of ['docs/plan', 'docs/archive', CACHE_DIR]) if (!content.includes(required)) addError(gitignore, 'MISSING_GITIGNORE_ENTRY', `Expected .gitignore entry: ${required}`, null, `add ${required} to .gitignore so local plans, archives, and cache files stay untracked.`);
404
+ }
405
+
406
+ if (options.json) console.log(JSON.stringify({ ok: errors.length === 0, errors }, null, 2));
407
+ else if (errors.length === 0) console.log(`✅ docs validation passed (${markdownFiles.length} markdown files)`);
408
+ else {
409
+ for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}`);
410
+ console.log(`\n❌ ${errors.length} docs validation error(s)`);
411
+ }
412
+ process.exit(errors.length === 0 ? 0 : 1);
413
+ }
414
+
415
+ const INDEX_TEXT_EXTENSIONS = new Set([
416
+ '.md', '.mdx', '.markdown', '.txt', '.rst', '.adoc', '.org',
417
+ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.pyw', '.go', '.rs', '.java', '.kt', '.kts', '.swift', '.rb', '.php', '.cs', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.m', '.mm', '.scala', '.clj', '.cljs', '.ex', '.exs', '.erl', '.hrl', '.lua', '.pl', '.pm', '.r', '.R', '.sql',
418
+ '.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.properties', '.xml', '.html', '.htm', '.css', '.scss', '.sass', '.less', '.svg',
419
+ '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.tf', '.tfvars', '.hcl', '.nix', '.cue',
420
+ ]);
421
+ const INDEX_TEXT_FILENAMES = new Set([
422
+ 'AGENTS.md', 'CLAUDE.md', 'CODEX.md', 'README', 'README.md', 'LICENSE', 'NOTICE', 'CHANGELOG', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'AUTHORS', 'CODEOWNERS', '.gitignore', '.editorconfig',
423
+ 'dotdotgod.config.json', '.dotdotgodrc.json', 'package.json', 'pnpm-workspace.yaml', 'tsconfig.json', 'jsconfig.json',
424
+ 'Dockerfile', 'Containerfile', 'Makefile', 'Justfile', 'Procfile', 'Rakefile', 'Gemfile', 'go.mod', 'go.sum', 'Cargo.toml', 'Cargo.lock', 'pyproject.toml', 'requirements.txt', 'Pipfile', 'Pipfile.lock', 'poetry.lock', 'deno.json', 'deno.jsonc', 'bunfig.toml',
425
+ '.env.example', '.env.sample', '.env.template',
426
+ ]);
427
+ const INDEX_EXCLUDED_DIRS = new Set(['.git', CACHE_DIR, 'node_modules', 'vendor', '.venv', 'venv', 'target', 'dist', 'build', 'coverage', '.next', '.turbo', '.cache', '.pytest_cache', '__pycache__']);
428
+
429
+ function isExcludedIndexDir(path) {
430
+ return path.split('/').some((part) => INDEX_EXCLUDED_DIRS.has(part));
431
+ }
432
+
433
+ function isSecretIndexPath(path) {
434
+ const name = basename(path);
435
+ return name === '.env' || (/^\.env\./.test(name) && !INDEX_TEXT_FILENAMES.has(name));
436
+ }
437
+
438
+ function isGeneratedIndexPath(path) {
439
+ const name = basename(path);
440
+ return name.endsWith('.min.js') || name.endsWith('.snap') || name.endsWith('.lockb');
441
+ }
442
+
443
+ function isSupportedIndexFile(path) {
444
+ const name = basename(path);
445
+ return INDEX_TEXT_FILENAMES.has(name) || INDEX_TEXT_EXTENSIONS.has(extname(name));
446
+ }
447
+
448
+ export function shouldIndexPath(path, config = defaultMemoryConfig()) {
449
+ const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '');
450
+ if (!normalized || normalized.endsWith('/placeholder')) return false;
451
+ if (isExcludedIndexDir(normalized) || isSecretIndexPath(normalized) || isGeneratedIndexPath(normalized)) return false;
452
+ const area = resolveMemoryArea(normalized, config);
453
+ if (area?.includeBodiesByDefault === false) return false;
454
+ return isSupportedIndexFile(normalized);
455
+ }
456
+
457
+ function gitIndexCandidates(root) {
458
+ const result = spawnSync('git', ['-C', root, 'ls-files', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8' });
459
+ if (result.status !== 0) return null;
460
+ return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
461
+ }
462
+
463
+ function walkIndexCandidates(root, config = readMemoryConfig(root)) {
464
+ const files = [];
465
+ const walk = (dir) => {
466
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
467
+ const path = join(dir, entry.name);
468
+ const pathRel = rel(root, path);
469
+ if (entry.isDirectory()) {
470
+ const area = resolveMemoryArea(pathRel, config);
471
+ if (!isExcludedIndexDir(pathRel) && area?.includeBodiesByDefault !== false) walk(path);
472
+ } else if (entry.isFile()) files.push(pathRel);
473
+ }
474
+ };
475
+ walk(root);
476
+ return files;
477
+ }
478
+
479
+ function addDotdotgodLocalMemoryCandidates(root, candidates) {
480
+ for (const file of ['AGENTS.md', 'CLAUDE.md', 'CODEX.md', 'README.md', 'docs/README.md', 'docs/spec/README.md', 'docs/test/README.md', 'docs/arch/README.md', 'docs/plan/README.md', 'docs/archive/README.md']) {
481
+ if (existsSync(join(root, file))) candidates.add(file);
482
+ }
483
+ const planRoot = join(root, 'docs/plan');
484
+ const walkPlan = (dir) => {
485
+ if (!existsSync(dir)) return;
486
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
487
+ const path = join(dir, entry.name);
488
+ if (entry.isDirectory()) walkPlan(path);
489
+ else if (entry.isFile()) candidates.add(rel(root, path));
490
+ }
491
+ };
492
+ walkPlan(planRoot);
493
+ }
494
+
495
+ export function collectIndexFiles(root, config = readMemoryConfig(root)) {
496
+ const candidates = new Set(gitIndexCandidates(root) ?? walkIndexCandidates(root, config));
497
+ addDotdotgodLocalMemoryCandidates(root, candidates);
498
+ return [...candidates].filter((path) => shouldIndexPath(path, config)).map((path) => join(root, path)).filter(existsSync).sort();
499
+ }
500
+
501
+ export function fingerprint(file) {
502
+ const content = readFileSync(file);
503
+ return createHash('sha256').update(content).digest('hex');
504
+ }
505
+
506
+ export function cacheFile(root) {
507
+ return join(root, CACHE_DIR, MANIFEST_FILE);
508
+ }
509
+
510
+ function graphNodeShard(node) {
511
+ const path = node.path ?? '';
512
+ if (path.startsWith('docs/') || node.type === 'heading') return 'docs';
513
+ if (node.type === 'package' || node.type === 'script' || node.type === 'binary' || node.type === 'dependency' || node.type === 'package_resource') return 'packages';
514
+ return 'source';
515
+ }
516
+
517
+ function graphEdgeShard(edge) {
518
+ if (edge.relation === 'links_to' || edge.relation === 'routes_to' || edge.relation === 'contains_heading' || edge.relation === 'implemented_by' || edge.relation === 'verified_by' || edge.relation === 'related_doc' || edge.relation === 'verification_command' || SEMANTIC_RELATIONS.has(edge.relation)) return 'docs-links';
519
+ if (edge.relation === 'declares_package' || edge.relation === 'declares_script' || edge.relation === 'declares_bin' || edge.relation === 'depends_on' || edge.relation === 'includes_resource') return 'packages';
520
+ return 'other';
521
+ }
522
+
523
+ function compactNode(node) {
524
+ const { id, type, ...data } = node;
525
+ return Object.keys(data).length > 0 ? [id, type, data] : [id, type];
526
+ }
527
+
528
+ function expandNode(row) {
529
+ const [id, type, data = {}] = row;
530
+ return { id, type, ...data };
531
+ }
532
+
533
+ function compactEdge(edge) {
534
+ const { source, target, relation, ...data } = edge;
535
+ return Object.keys(data).length > 0 ? [source, target, relation, data] : [source, target, relation];
536
+ }
537
+
538
+ function expandEdge(row) {
539
+ const [source, target, relation, data = {}] = row;
540
+ return { source, target, relation, ...data };
541
+ }
542
+
543
+ function shardFile(root, kind, name) {
544
+ return join(root, CACHE_DIR, 'graph', kind, `${name}.json`);
545
+ }
546
+
547
+ function jsonSize(file) {
548
+ return existsSync(file) ? statSync(file).size : 0;
549
+ }
550
+
551
+ function writeJson(file, value) {
552
+ mkdirSync(dirname(file), { recursive: true });
553
+ writeFileSync(file, `${JSON.stringify(value)}\n`);
554
+ }
555
+
556
+ function compactGraph(graph) {
557
+ const nodes = Object.fromEntries(GRAPH_NODE_SHARDS.map((name) => [name, []]));
558
+ const edges = Object.fromEntries(GRAPH_EDGE_SHARDS.map((name) => [name, []]));
559
+ for (const node of graph.nodes) nodes[graphNodeShard(node)].push(compactNode(node));
560
+ for (const edge of graph.edges) edges[graphEdgeShard(edge)].push(compactEdge(edge));
561
+ return { nodes, edges };
562
+ }
563
+
564
+ function expandGraph(compact) {
565
+ return {
566
+ nodes: Object.values(compact?.nodes ?? {}).flat().map(expandNode),
567
+ edges: Object.values(compact?.edges ?? {}).flat().map(expandEdge),
568
+ };
569
+ }
570
+
571
+ function graphStats(graph) {
572
+ return { nodes: graph.nodes.length, edges: graph.edges.length };
573
+ }
574
+
575
+ function graphNodeIndex(graph) {
576
+ if (!graph._nodeIndex) {
577
+ Object.defineProperty(graph, '_nodeIndex', {
578
+ value: new Map(graph.nodes.map((node) => [node.id, node])),
579
+ enumerable: false,
580
+ writable: true,
581
+ });
582
+ }
583
+ return graph._nodeIndex;
584
+ }
585
+
586
+ function graphEdgeIndex(graph) {
587
+ if (!graph._edgeIndex) {
588
+ Object.defineProperty(graph, '_edgeIndex', {
589
+ value: new Set(graph.edges.map((edge) => JSON.stringify(edge))),
590
+ enumerable: false,
591
+ writable: true,
592
+ });
593
+ }
594
+ return graph._edgeIndex;
595
+ }
596
+
597
+ function definedEntries(data) {
598
+ return Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined));
599
+ }
600
+
601
+ export function addNode(graph, id, type, data = {}) {
602
+ const index = graphNodeIndex(graph);
603
+ const existing = index.get(id);
604
+ if (existing) {
605
+ Object.assign(existing, definedEntries(data));
606
+ return;
607
+ }
608
+ const node = { id, type, ...definedEntries(data) };
609
+ graph.nodes.push(node);
610
+ index.set(id, node);
611
+ }
612
+
613
+ export function addEdge(graph, source, target, relation, data = {}) {
614
+ const edge = { source, target, relation, ...data };
615
+ const key = JSON.stringify(edge);
616
+ const index = graphEdgeIndex(graph);
617
+ if (index.has(key)) return;
618
+ graph.edges.push(edge);
619
+ index.add(key);
620
+ }
621
+
622
+ const MEMORY_CONFIG_FILES = ['dotdotgod.config.json', '.dotdotgodrc.json'];
623
+ const MEMORY_SCOPES = new Set(['shared', 'local']);
624
+ const MEMORY_FRESHNESS = new Set(['fresh', 'stale']);
625
+ const DEFAULT_TRACEABILITY_POLICY = {
626
+ required: ['docs/spec/**'],
627
+ exclude: ['**/README.md'],
628
+ };
629
+ const DEFAULT_VALIDATION_POLICY = {
630
+ markdown: { maxLines: 200, maxChars: 10000, exclude: [] },
631
+ };
632
+ const DEFAULT_IMPACT_RANKING_POLICY = {
633
+ preset: 'balanced',
634
+ weights: { ppr: 40, traceability: 30, memoryPolicy: 10, verification: 15, proximity: 10, semantic: 10, freshness: 5, archivePenalty: -25 },
635
+ ppr: { enabled: true, damping: 0.85, iterations: 20, tolerance: 0.000001 },
636
+ relationWeights: {
637
+ implemented_by: 4,
638
+ verified_by: 4,
639
+ related_doc: 3,
640
+ verification_command: 3,
641
+ links_to: 2,
642
+ belongs_to_area: 2,
643
+ semantic_similarity: 2,
644
+ mentions_package: 1,
645
+ },
646
+ traceabilityBoosts: { implemented_by: 30, 'incoming:implemented_by': 30, verified_by: 25, 'incoming:verified_by': 25, verification_command: 15, 'incoming:verification_command': 15, related_doc: 12, 'incoming:related_doc': 12 },
647
+ verificationBoosts: { verified_by: 15, 'incoming:verified_by': 15, verification_command: 12, 'incoming:verification_command': 12 },
648
+ semanticBoosts: { semantic_similarity: 8, 'incoming:semantic_similarity': 8, mentions_package: 4, 'incoming:mentions_package': 4 },
649
+ proximityBoosts: { links_to: 6, 'incoming:links_to': 6, routes_to: 5, 'incoming:routes_to': 5 },
650
+ semantic: { enabled: true, threshold: 0.5, topKPerFile: 5, includeArchiveBodies: false, signals: ['path', 'filename', 'heading', 'package'] },
651
+ };
652
+ const IMPACT_RANKING_PRESETS = {
653
+ balanced: {},
654
+ 'docs-first': { weights: { ppr: 35, traceability: 35, memoryPolicy: 15, verification: 15, proximity: 5, semantic: 8, freshness: 5, archivePenalty: -30 } },
655
+ 'code-proximity': { weights: { ppr: 45, traceability: 20, memoryPolicy: 8, verification: 12, proximity: 20, semantic: 8, freshness: 3, archivePenalty: -25 } },
656
+ 'test-focused': { weights: { ppr: 35, traceability: 25, memoryPolicy: 8, verification: 25, proximity: 10, semantic: 7, freshness: 5, archivePenalty: -25 } },
657
+ 'archive-aware': { weights: { ppr: 35, traceability: 25, memoryPolicy: 10, verification: 15, proximity: 10, semantic: 8, freshness: 3, archivePenalty: -10 } },
658
+ };
659
+ const SEMANTIC_RELATIONS = new Set(['semantic_similarity', 'mentions_package']);
660
+ const IMPACT_RANKING_WEIGHT_KEYS = new Set(['ppr', 'traceability', 'memoryPolicy', 'verification', 'proximity', 'semantic', 'freshness', 'archivePenalty']);
661
+ const IMPACT_RANKING_RELATION_KEYS = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', 'links_to', 'belongs_to_area', 'semantic_similarity', 'mentions_package']);
662
+ const IMPACT_RANKING_REASON_KEYS = new Set(['implemented_by', 'incoming:implemented_by', 'verified_by', 'incoming:verified_by', 'verification_command', 'incoming:verification_command', 'related_doc', 'incoming:related_doc', 'semantic_similarity', 'incoming:semantic_similarity', 'mentions_package', 'incoming:mentions_package', 'links_to', 'incoming:links_to', 'routes_to', 'incoming:routes_to']);
663
+ const SEMANTIC_SIGNAL_KEYS = new Set(['path', 'filename', 'heading', 'package']);
664
+ const DEFAULT_FUZZY_LOW_SIGNAL_TERMS = [
665
+ 'a', 'an', 'and', 'are', 'as', 'by', 'docs', 'document', 'for', 'from', 'it', 'of', 'on', 'plan', 'test', 'the', 'to', 'update', 'version', 'with',
666
+ '계획', '문서', '수정', '업데이트', '버전', '정보', '확인', '테스트',
667
+ ];
668
+ const DEFAULT_MEMORY_AREAS = [
669
+ { id: 'rules', label: 'Agent Rules', paths: ['AGENTS.md'], scope: 'shared', freshness: 'fresh', role: 'agent-working-rules', priority: 100, includeBodiesByDefault: true },
670
+ { id: 'agent-entrypoint', label: 'Agent Entrypoints', paths: ['CLAUDE.md', 'CODEX.md'], scope: 'shared', freshness: 'fresh', role: 'agent-specific-entrypoint', priority: 85, includeBodiesByDefault: true },
671
+ { id: 'project-overview', label: 'Project Overview', paths: ['README.md'], scope: 'shared', freshness: 'fresh', role: 'project-map', priority: 85, includeBodiesByDefault: true },
672
+ { id: 'docs-index', label: 'Docs Index', paths: ['docs/README.md'], scope: 'shared', freshness: 'fresh', role: 'documentation-routing-map', priority: 90, includeBodiesByDefault: true },
673
+ { id: 'spec', label: 'Product Specs', paths: ['docs/spec/**'], scope: 'shared', freshness: 'fresh', role: 'behavior-truth', priority: 80, includeBodiesByDefault: true },
674
+ { id: 'architecture', label: 'Architecture', paths: ['docs/arch/**'], scope: 'shared', freshness: 'fresh', role: 'architecture-rationale', priority: 75, includeBodiesByDefault: true },
675
+ { id: 'test', label: 'Tests', paths: ['docs/test/**'], scope: 'shared', freshness: 'fresh', role: 'verification-knowledge', priority: 70, includeBodiesByDefault: true },
676
+ { id: 'active-plan', label: 'Active Plans', paths: ['docs/plan/**'], scope: 'local', freshness: 'fresh', role: 'active-task-intent', priority: 95, includeBodiesByDefault: true },
677
+ { id: 'archive-map', label: 'Archive Map', paths: ['docs/archive/README.md'], scope: 'local', freshness: 'stale', role: 'historical-memory-map', priority: 65, includeBodiesByDefault: true },
678
+ { id: 'archive-body', label: 'Archive Body', paths: ['docs/archive/**'], excludePaths: ['docs/archive/README.md'], scope: 'local', freshness: 'stale', role: 'historical-memory-body', priority: 20, includeBodiesByDefault: false },
679
+ ];
680
+
681
+ function cloneArea(area) {
682
+ return {
683
+ ...area,
684
+ paths: [...(area.paths ?? [])],
685
+ excludePaths: [...(area.excludePaths ?? [])],
686
+ };
687
+ }
688
+
689
+ function cloneTraceabilityPolicy(policy = DEFAULT_TRACEABILITY_POLICY) {
690
+ return {
691
+ required: [...(policy.required ?? [])],
692
+ exclude: [...(policy.exclude ?? [])],
693
+ };
694
+ }
695
+
696
+ function cloneValidationPolicy(policy = DEFAULT_VALIDATION_POLICY) {
697
+ return {
698
+ markdown: {
699
+ maxLines: policy.markdown?.maxLines ?? DEFAULT_VALIDATION_POLICY.markdown.maxLines,
700
+ maxChars: policy.markdown?.maxChars ?? DEFAULT_VALIDATION_POLICY.markdown.maxChars,
701
+ exclude: [...(policy.markdown?.exclude ?? [])],
702
+ },
703
+ };
704
+ }
705
+
706
+ function cloneImpactRankingPolicy(policy = DEFAULT_IMPACT_RANKING_POLICY) {
707
+ return {
708
+ preset: policy.preset ?? 'balanced',
709
+ weights: { ...DEFAULT_IMPACT_RANKING_POLICY.weights, ...(policy.weights ?? {}) },
710
+ ppr: { ...DEFAULT_IMPACT_RANKING_POLICY.ppr, ...(policy.ppr ?? {}) },
711
+ relationWeights: { ...DEFAULT_IMPACT_RANKING_POLICY.relationWeights, ...(policy.relationWeights ?? {}) },
712
+ traceabilityBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.traceabilityBoosts, ...(policy.traceabilityBoosts ?? {}) },
713
+ verificationBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.verificationBoosts, ...(policy.verificationBoosts ?? {}) },
714
+ semanticBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.semanticBoosts, ...(policy.semanticBoosts ?? {}) },
715
+ proximityBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.proximityBoosts, ...(policy.proximityBoosts ?? {}) },
716
+ semantic: { ...DEFAULT_IMPACT_RANKING_POLICY.semantic, ...(policy.semantic ?? {}), signals: [...(policy.semantic?.signals ?? DEFAULT_IMPACT_RANKING_POLICY.semantic.signals)] },
717
+ };
718
+ }
719
+
720
+ function normalizeLowSignalTerm(value = '') {
721
+ return String(value).trim().toLowerCase().replace(/\s+/g, ' ');
722
+ }
723
+
724
+ function uniqueNormalizedTerms(values = []) {
725
+ return [...new Set((Array.isArray(values) ? values : []).map(normalizeLowSignalTerm).filter(Boolean))];
726
+ }
727
+
728
+ function cloneReferenceExpansionPolicy(policy = {}) {
729
+ const lowSignal = policy.fuzzy?.lowSignal ?? {};
730
+ const defaults = uniqueNormalizedTerms(lowSignal.defaults ?? DEFAULT_FUZZY_LOW_SIGNAL_TERMS);
731
+ const add = uniqueNormalizedTerms(lowSignal.add ?? []);
732
+ const remove = uniqueNormalizedTerms(lowSignal.remove ?? []);
733
+ const terms = new Set(defaults);
734
+ for (const term of remove) terms.delete(term);
735
+ for (const term of add) terms.add(term);
736
+ return { fuzzy: { lowSignal: { defaults, add, remove, terms: [...terms].sort() } } };
737
+ }
738
+
739
+ function normalizeReferenceExpansionPolicy(raw) {
740
+ const lowSignal = raw?.fuzzy?.lowSignal ?? {};
741
+ return cloneReferenceExpansionPolicy({ fuzzy: { lowSignal: { add: lowSignal.add, remove: lowSignal.remove } } });
742
+ }
743
+
744
+ function normalizeImpactRankingPolicy(raw) {
745
+ const presetName = typeof raw?.preset === 'string' ? raw.preset : 'balanced';
746
+ const preset = IMPACT_RANKING_PRESETS[presetName] ?? IMPACT_RANKING_PRESETS.balanced;
747
+ return cloneImpactRankingPolicy({ ...preset, ...raw, preset: presetName, weights: { ...(preset.weights ?? {}), ...(raw?.weights ?? {}) }, ppr: { ...(preset.ppr ?? {}), ...(raw?.ppr ?? {}) }, semantic: { ...(preset.semantic ?? {}), ...(raw?.semantic ?? {}) } });
748
+ }
749
+
750
+ export function defaultMemoryConfig() {
751
+ return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy() };
752
+ }
753
+
754
+ function normalizePathPattern(value = '') {
755
+ return value.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$|^\/+/, '');
756
+ }
757
+
758
+ function isValidPathPattern(value) {
759
+ if (typeof value !== 'string' || !value.trim()) return false;
760
+ const normalized = normalizePathPattern(value);
761
+ if (!normalized || normalized.startsWith('../') || normalized.includes('/../') || normalized === '..') return false;
762
+ if (normalized.includes('*') && !(normalized.endsWith('/**') || normalized.startsWith('**/'))) return false;
763
+ return true;
764
+ }
765
+
766
+ function matchMemoryPattern(path, pattern) {
767
+ const normalized = normalizePathPattern(path);
768
+ const normalizedPattern = normalizePathPattern(pattern);
769
+ if (normalizedPattern.endsWith('/**')) {
770
+ const prefix = normalizedPattern.slice(0, -3);
771
+ return normalized === prefix || normalized.startsWith(`${prefix}/`);
772
+ }
773
+ if (normalizedPattern.startsWith('**/')) {
774
+ const suffix = normalizedPattern.slice(3);
775
+ return normalized === suffix || normalized.endsWith(`/${suffix}`);
776
+ }
777
+ return normalized === normalizedPattern;
778
+ }
779
+
780
+ function areaMatchesPath(area, path) {
781
+ const excluded = (area.excludePaths ?? []).some((pattern) => matchMemoryPattern(path, pattern));
782
+ if (excluded) return false;
783
+ return (area.paths ?? []).some((pattern) => matchMemoryPattern(path, pattern));
784
+ }
785
+
786
+ function resolveMemoryArea(path = '', config = defaultMemoryConfig()) {
787
+ return (config.areas ?? []).find((area) => areaMatchesPath(area, path));
788
+ }
789
+
790
+ export function memoryAreaForPath(path = '', config = defaultMemoryConfig()) {
791
+ return resolveMemoryArea(path, config)?.id;
792
+ }
793
+
794
+ export function memoryRoleForPath(path = '', config = defaultMemoryConfig()) {
795
+ return resolveMemoryArea(path, config)?.role;
796
+ }
797
+
798
+ export function retrievalPriorityForPath(path = '', config = defaultMemoryConfig()) {
799
+ return resolveMemoryArea(path, config)?.priority ?? 30;
800
+ }
801
+
802
+ function normalizeTraceabilityPolicy(raw) {
803
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneTraceabilityPolicy();
804
+ return {
805
+ required: Array.isArray(raw.required) ? raw.required.map(normalizePathPattern) : [],
806
+ exclude: Array.isArray(raw.exclude) ? raw.exclude.map(normalizePathPattern) : [],
807
+ };
808
+ }
809
+
810
+ function normalizeValidationPolicy(raw) {
811
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneValidationPolicy();
812
+ const markdown = raw.markdown && typeof raw.markdown === 'object' && !Array.isArray(raw.markdown) ? raw.markdown : {};
813
+ return cloneValidationPolicy({
814
+ markdown: {
815
+ maxLines: Number.isInteger(markdown.maxLines) ? markdown.maxLines : DEFAULT_VALIDATION_POLICY.markdown.maxLines,
816
+ maxChars: Number.isInteger(markdown.maxChars) ? markdown.maxChars : DEFAULT_VALIDATION_POLICY.markdown.maxChars,
817
+ exclude: Array.isArray(markdown.exclude) ? markdown.exclude.map(normalizePathPattern) : [],
818
+ },
819
+ });
820
+ }
821
+
822
+ function isMarkdownSizeExcluded(path = '', config = defaultMemoryConfig()) {
823
+ const policy = config.validation ?? DEFAULT_VALIDATION_POLICY;
824
+ return (policy.markdown?.exclude ?? []).some((pattern) => matchMemoryPattern(path, pattern));
825
+ }
826
+
827
+ export function requiresTraceability(path = '', config = defaultMemoryConfig()) {
828
+ const policy = config.traceability ?? DEFAULT_TRACEABILITY_POLICY;
829
+ const excluded = (policy.exclude ?? []).some((pattern) => matchMemoryPattern(path, pattern));
830
+ if (excluded) return false;
831
+ return (policy.required ?? []).some((pattern) => matchMemoryPattern(path, pattern));
832
+ }
833
+
834
+ function normalizeMemoryArea(raw) {
835
+ return {
836
+ id: raw.id,
837
+ label: raw.label ?? raw.id,
838
+ paths: Array.isArray(raw.paths) ? raw.paths.map(normalizePathPattern) : [],
839
+ excludePaths: Array.isArray(raw.excludePaths) ? raw.excludePaths.map(normalizePathPattern) : [],
840
+ scope: raw.scope,
841
+ freshness: raw.freshness,
842
+ role: raw.role ?? raw.id,
843
+ priority: typeof raw.priority === 'number' ? raw.priority : 30,
844
+ includeBodiesByDefault: raw.includeBodiesByDefault !== false,
845
+ };
846
+ }
847
+
848
+ function isFiniteNumberInRange(value, min, max) {
849
+ return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
850
+ }
851
+
852
+ function validateNumberMap(map, keys, min, max) {
853
+ return map && typeof map === 'object' && !Array.isArray(map) && Object.entries(map).every(([key, value]) => keys.has(key) && isFiniteNumberInRange(value, min, max));
854
+ }
855
+
856
+ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.config.json') {
857
+ const errors = [];
858
+ const add = (code, field, message, fix = null) => errors.push({
859
+ file: rel(root, resolve(root, file)),
860
+ code,
861
+ message: `${field ? `Field "${field}": ` : ''}${message}\nFix: ${fix ?? `update ${field ?? 'this config'} in ${file} to match the expected dotdotgod config schema.`}`,
862
+ });
863
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
864
+ add('MEMORY_CONFIG_INVALID', null, 'Config must be a JSON object.');
865
+ return errors;
866
+ }
867
+ const traceability = data.traceability;
868
+ if (traceability !== undefined) {
869
+ if (!traceability || typeof traceability !== 'object' || Array.isArray(traceability)) {
870
+ add('TRACEABILITY_CONFIG_INVALID', 'traceability', 'Expected an object.');
871
+ } else {
872
+ if (!Array.isArray(traceability.required)) add('TRACEABILITY_CONFIG_INVALID_REQUIRED', 'traceability.required', 'Expected an array of path strings.');
873
+ else if (traceability.required.some((value) => !isValidPathPattern(value))) add('TRACEABILITY_CONFIG_INVALID_REQUIRED', 'traceability.required', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
874
+ if (traceability.exclude !== undefined && !Array.isArray(traceability.exclude)) add('TRACEABILITY_CONFIG_INVALID_EXCLUDE', 'traceability.exclude', 'Expected an array of path strings.');
875
+ else if (Array.isArray(traceability.exclude) && traceability.exclude.some((value) => !isValidPathPattern(value))) add('TRACEABILITY_CONFIG_INVALID_EXCLUDE', 'traceability.exclude', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
876
+ }
877
+ }
878
+ const validation = data.validation;
879
+ if (validation !== undefined) {
880
+ if (!validation || typeof validation !== 'object' || Array.isArray(validation)) {
881
+ add('VALIDATION_CONFIG_INVALID', 'validation', 'Expected an object.');
882
+ } else if (validation.markdown !== undefined) {
883
+ const markdown = validation.markdown;
884
+ if (!markdown || typeof markdown !== 'object' || Array.isArray(markdown)) {
885
+ add('VALIDATION_CONFIG_INVALID_MARKDOWN', 'validation.markdown', 'Expected an object.');
886
+ } else {
887
+ if (markdown.maxLines !== undefined && (!Number.isInteger(markdown.maxLines) || markdown.maxLines < 1)) add('VALIDATION_CONFIG_INVALID_MAX_LINES', 'validation.markdown.maxLines', 'Expected a positive integer.');
888
+ if (markdown.maxChars !== undefined && (!Number.isInteger(markdown.maxChars) || markdown.maxChars < 1)) add('VALIDATION_CONFIG_INVALID_MAX_CHARS', 'validation.markdown.maxChars', 'Expected a positive integer.');
889
+ if (markdown.exclude !== undefined && !Array.isArray(markdown.exclude)) add('VALIDATION_CONFIG_INVALID_EXCLUDE', 'validation.markdown.exclude', 'Expected an array of path strings.');
890
+ else if (Array.isArray(markdown.exclude) && markdown.exclude.some((value) => !isValidPathPattern(value))) add('VALIDATION_CONFIG_INVALID_EXCLUDE', 'validation.markdown.exclude', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
891
+ }
892
+ }
893
+ }
894
+ const referenceExpansion = data.referenceExpansion;
895
+ if (referenceExpansion !== undefined) {
896
+ if (!referenceExpansion || typeof referenceExpansion !== 'object' || Array.isArray(referenceExpansion)) {
897
+ add('REFERENCE_EXPANSION_CONFIG_INVALID', 'referenceExpansion', 'Expected an object.');
898
+ } else if (referenceExpansion.fuzzy !== undefined) {
899
+ const fuzzy = referenceExpansion.fuzzy;
900
+ if (!fuzzy || typeof fuzzy !== 'object' || Array.isArray(fuzzy)) {
901
+ add('REFERENCE_EXPANSION_CONFIG_INVALID_FUZZY', 'referenceExpansion.fuzzy', 'Expected an object.');
902
+ } else if (fuzzy.lowSignal !== undefined) {
903
+ const lowSignal = fuzzy.lowSignal;
904
+ if (!lowSignal || typeof lowSignal !== 'object' || Array.isArray(lowSignal)) {
905
+ add('REFERENCE_EXPANSION_CONFIG_INVALID_LOW_SIGNAL', 'referenceExpansion.fuzzy.lowSignal', 'Expected an object.');
906
+ } else {
907
+ for (const key of ['add', 'remove']) {
908
+ if (lowSignal[key] !== undefined && (!Array.isArray(lowSignal[key]) || lowSignal[key].some((value) => typeof value !== 'string' || !value.trim()))) add('REFERENCE_EXPANSION_CONFIG_INVALID_LOW_SIGNAL_TERMS', `referenceExpansion.fuzzy.lowSignal.${key}`, 'Expected an array of non-empty strings.');
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+ const impactRanking = data.impactRanking;
915
+ if (impactRanking !== undefined) {
916
+ if (!impactRanking || typeof impactRanking !== 'object' || Array.isArray(impactRanking)) {
917
+ add('IMPACT_RANKING_CONFIG_INVALID', 'impactRanking', 'Expected an object.');
918
+ } else {
919
+ if (impactRanking.preset !== undefined && !Object.hasOwn(IMPACT_RANKING_PRESETS, impactRanking.preset)) add('IMPACT_RANKING_CONFIG_INVALID_PRESET', 'impactRanking.preset', 'Expected one of balanced, docs-first, code-proximity, test-focused, or archive-aware.');
920
+ if (impactRanking.weights !== undefined && !validateNumberMap(impactRanking.weights, IMPACT_RANKING_WEIGHT_KEYS, -100, 100)) add('IMPACT_RANKING_CONFIG_INVALID_WEIGHTS', 'impactRanking.weights', 'Expected known numeric weight keys with finite values from -100 to 100.');
921
+ if (impactRanking.relationWeights !== undefined && !validateNumberMap(impactRanking.relationWeights, IMPACT_RANKING_RELATION_KEYS, 0, 20)) add('IMPACT_RANKING_CONFIG_INVALID_RELATION_WEIGHTS', 'impactRanking.relationWeights', 'Expected known relation keys with finite values from 0 to 20.');
922
+ for (const key of ['traceabilityBoosts', 'verificationBoosts', 'semanticBoosts', 'proximityBoosts']) {
923
+ if (impactRanking[key] !== undefined && !validateNumberMap(impactRanking[key], IMPACT_RANKING_REASON_KEYS, 0, 100)) add('IMPACT_RANKING_CONFIG_INVALID_BOOSTS', `impactRanking.${key}`, 'Expected known reason keys with finite values from 0 to 100.');
924
+ }
925
+ if (impactRanking.ppr !== undefined) {
926
+ const ppr = impactRanking.ppr;
927
+ if (!ppr || typeof ppr !== 'object' || Array.isArray(ppr)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr', 'Expected an object.');
928
+ else {
929
+ if (ppr.enabled !== undefined && typeof ppr.enabled !== 'boolean') add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.enabled', 'Expected a boolean.');
930
+ if (ppr.damping !== undefined && !isFiniteNumberInRange(ppr.damping, 0.01, 0.99)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.damping', 'Expected a number greater than 0 and less than 1.');
931
+ if (ppr.iterations !== undefined && (!Number.isInteger(ppr.iterations) || ppr.iterations < 1 || ppr.iterations > 100)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.iterations', 'Expected an integer from 1 to 100.');
932
+ if (ppr.tolerance !== undefined && !isFiniteNumberInRange(ppr.tolerance, 0, 1)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.tolerance', 'Expected a number from 0 to 1.');
933
+ }
934
+ }
935
+ if (impactRanking.semantic !== undefined) {
936
+ const semantic = impactRanking.semantic;
937
+ if (!semantic || typeof semantic !== 'object' || Array.isArray(semantic)) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic', 'Expected an object.');
938
+ else {
939
+ if (semantic.enabled !== undefined && typeof semantic.enabled !== 'boolean') add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.enabled', 'Expected a boolean.');
940
+ if (semantic.threshold !== undefined && !isFiniteNumberInRange(semantic.threshold, 0, 1)) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.threshold', 'Expected a number from 0 to 1.');
941
+ if (semantic.topKPerFile !== undefined && (!Number.isInteger(semantic.topKPerFile) || semantic.topKPerFile < 0 || semantic.topKPerFile > 20)) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.topKPerFile', 'Expected an integer from 0 to 20.');
942
+ if (semantic.includeArchiveBodies !== undefined && typeof semantic.includeArchiveBodies !== 'boolean') add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.includeArchiveBodies', 'Expected a boolean.');
943
+ if (semantic.signals !== undefined && (!Array.isArray(semantic.signals) || semantic.signals.some((value) => !SEMANTIC_SIGNAL_KEYS.has(value)))) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.signals', 'Expected an array of known deterministic signal names.');
944
+ }
945
+ }
946
+ }
947
+ }
948
+ const areas = data.memory?.areas;
949
+ if (areas === undefined) return errors;
950
+ if (!Array.isArray(areas)) {
951
+ add('MEMORY_CONFIG_INVALID_FIELD', 'memory.areas', 'Expected an array.');
952
+ return errors;
953
+ }
954
+ const ids = new Set();
955
+ const exactIncluded = new Map();
956
+ for (const [index, area] of areas.entries()) {
957
+ const prefix = `memory.areas[${index}]`;
958
+ if (!area || typeof area !== 'object' || Array.isArray(area)) {
959
+ add('MEMORY_CONFIG_INVALID_AREA', prefix, 'Expected an object.');
960
+ continue;
961
+ }
962
+ if (typeof area.id !== 'string' || !isKebabCase(area.id)) add('MEMORY_CONFIG_INVALID_ID', `${prefix}.id`, 'Expected a kebab-case string.');
963
+ else if (ids.has(area.id)) add('MEMORY_CONFIG_DUPLICATE_ID', `${prefix}.id`, `Duplicate memory area id: ${area.id}`);
964
+ else ids.add(area.id);
965
+ if (!Array.isArray(area.paths) || area.paths.length === 0 || area.paths.some((value) => !isValidPathPattern(value))) add('MEMORY_CONFIG_INVALID_PATHS', `${prefix}.paths`, 'Expected a non-empty array of path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
966
+ if (area.excludePaths !== undefined && (!Array.isArray(area.excludePaths) || area.excludePaths.some((value) => !isValidPathPattern(value)))) add('MEMORY_CONFIG_INVALID_EXCLUDE_PATHS', `${prefix}.excludePaths`, 'Expected an array of path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
967
+ if (!MEMORY_SCOPES.has(area.scope)) add('MEMORY_CONFIG_INVALID_SCOPE', `${prefix}.scope`, 'Expected "shared" or "local".');
968
+ if (!MEMORY_FRESHNESS.has(area.freshness)) add('MEMORY_CONFIG_INVALID_FRESHNESS', `${prefix}.freshness`, 'Expected "fresh" or "stale".');
969
+ if (area.priority !== undefined && (!Number.isInteger(area.priority) || area.priority < 0 || area.priority > 100)) add('MEMORY_CONFIG_INVALID_PRIORITY', `${prefix}.priority`, 'Expected an integer from 0 to 100.');
970
+ if (area.includeBodiesByDefault !== undefined && typeof area.includeBodiesByDefault !== 'boolean') add('MEMORY_CONFIG_INVALID_INCLUDE_POLICY', `${prefix}.includeBodiesByDefault`, 'Expected a boolean.');
971
+ for (const pattern of area.paths ?? []) {
972
+ if (exactIncluded.has(pattern) && !(area.excludePaths ?? []).includes(pattern)) add('MEMORY_CONFIG_OVERLAP', `${prefix}.paths`, `Path pattern also appears in ${exactIncluded.get(pattern)}: ${pattern}`);
973
+ else exactIncluded.set(pattern, `${prefix}.paths`);
974
+ }
975
+ }
976
+ return errors;
977
+ }
978
+
979
+ export function readMemoryConfig(root = '.') {
980
+ for (const name of MEMORY_CONFIG_FILES) {
981
+ const path = join(root, name);
982
+ if (!existsSync(path)) continue;
983
+ try {
984
+ const data = JSON.parse(readFileSync(path, 'utf8'));
985
+ const errors = validateMemoryConfigData(data, root, name);
986
+ if (errors.length > 0) return { ...defaultMemoryConfig(), source: name, errors };
987
+ const configuredAreas = data.memory?.areas?.map(normalizeMemoryArea) ?? [];
988
+ const traceability = data.traceability === undefined ? cloneTraceabilityPolicy() : normalizeTraceabilityPolicy(data.traceability);
989
+ const validation = data.validation === undefined ? cloneValidationPolicy() : normalizeValidationPolicy(data.validation);
990
+ const impactRanking = normalizeImpactRankingPolicy(data.impactRanking);
991
+ const referenceExpansion = normalizeReferenceExpansionPolicy(data.referenceExpansion);
992
+ return configuredAreas.length > 0 ? { source: name, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, source: name, errors: [] };
993
+ } catch (error) {
994
+ return { ...defaultMemoryConfig(), source: name, errors: [{ file: name, code: 'MEMORY_CONFIG_INVALID_JSON', message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}\nFix: repair ${name} so it is valid JSON, or regenerate the default config with \`dotdotgod config init <root> --force\` if you want to reset it.` }] };
995
+ }
996
+ }
997
+ return defaultMemoryConfig();
998
+ }
999
+
1000
+ function serializableMemoryArea(area) {
1001
+ return {
1002
+ id: area.id,
1003
+ label: area.label,
1004
+ paths: [...(area.paths ?? [])],
1005
+ excludePaths: [...(area.excludePaths ?? [])],
1006
+ scope: area.scope,
1007
+ freshness: area.freshness,
1008
+ role: area.role,
1009
+ priority: area.priority,
1010
+ includeBodiesByDefault: area.includeBodiesByDefault !== false,
1011
+ };
1012
+ }
1013
+
1014
+ export function defaultDotdotgodConfigData() {
1015
+ const config = defaultMemoryConfig();
1016
+ return {
1017
+ memory: {
1018
+ areas: (config.areas ?? []).map(serializableMemoryArea),
1019
+ },
1020
+ traceability: cloneTraceabilityPolicy(config.traceability),
1021
+ validation: cloneValidationPolicy(config.validation),
1022
+ impactRanking: cloneImpactRankingPolicy(config.impactRanking),
1023
+ referenceExpansion: { fuzzy: { lowSignal: { add: [], remove: [] } } },
1024
+ };
1025
+ }
1026
+
1027
+ export function defaultDotdotgodConfigText() {
1028
+ return `${JSON.stringify(defaultDotdotgodConfigData(), null, 2)}\n`;
1029
+ }
1030
+
1031
+ function memoryConfigSummary(config) {
1032
+ return {
1033
+ source: config.source ?? 'default',
1034
+ areas: (config.areas ?? []).map((area) => ({
1035
+ id: area.id,
1036
+ label: area.label,
1037
+ paths: area.paths,
1038
+ excludePaths: area.excludePaths ?? [],
1039
+ scope: area.scope,
1040
+ freshness: area.freshness,
1041
+ role: area.role,
1042
+ priority: area.priority,
1043
+ includeBodiesByDefault: area.includeBodiesByDefault !== false,
1044
+ })),
1045
+ traceability: cloneTraceabilityPolicy(config.traceability ?? DEFAULT_TRACEABILITY_POLICY),
1046
+ validation: cloneValidationPolicy(config.validation ?? DEFAULT_VALIDATION_POLICY),
1047
+ impactRanking: cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY),
1048
+ referenceExpansion: cloneReferenceExpansionPolicy(config.referenceExpansion),
1049
+ };
1050
+ }
1051
+
1052
+ export function isReadmeIndexPath(path = '') {
1053
+ return basename(path) === 'README.md';
1054
+ }
1055
+
1056
+ function retrievalSignalsForPath(path = '', config = defaultMemoryConfig()) {
1057
+ const signals = [];
1058
+ const definition = resolveMemoryArea(path, config);
1059
+ if (definition) {
1060
+ signals.push('memory-area', `scope:${definition.scope}`, `freshness:${definition.freshness}`);
1061
+ }
1062
+ if (path.startsWith('docs/')) signals.push('docs-path');
1063
+ if (isReadmeIndexPath(path)) signals.push('readme-index');
1064
+ return signals;
1065
+ }
1066
+
1067
+ function retrievalMetadataForPath(path = '', config = defaultMemoryConfig()) {
1068
+ const definition = resolveMemoryArea(path, config);
1069
+ return {
1070
+ area: definition?.id,
1071
+ role: definition?.role,
1072
+ priority: definition?.priority ?? 30,
1073
+ scope: definition?.scope,
1074
+ freshness: definition?.freshness,
1075
+ includeBodiesByDefault: definition?.includeBodiesByDefault,
1076
+ signals: retrievalSignalsForPath(path, config),
1077
+ };
1078
+ }
1079
+
1080
+ function fileNodeMetadata(path, file, config = defaultMemoryConfig()) {
1081
+ const retrieval = retrievalMetadataForPath(path, config);
1082
+ return {
1083
+ path,
1084
+ extension: file ? extname(file) : extname(path),
1085
+ memoryArea: retrieval.area,
1086
+ memoryRole: retrieval.role,
1087
+ memoryScope: retrieval.scope,
1088
+ memoryFreshness: retrieval.freshness,
1089
+ retrievalPriority: retrieval.priority,
1090
+ retrieval,
1091
+ };
1092
+ }
1093
+
1094
+ function addMemoryAreaMembership(graph, fileId, path, config = defaultMemoryConfig()) {
1095
+ const definition = resolveMemoryArea(path, config);
1096
+ if (!definition) return;
1097
+ const areaId = `memory_area:${definition.id}`;
1098
+ addNode(graph, areaId, 'memory_area', { area: definition.id, label: definition.label, role: definition.role, scope: definition.scope, freshness: definition.freshness, retrievalPriority: definition.priority, includeBodiesByDefault: definition.includeBodiesByDefault !== false });
1099
+ addEdge(graph, fileId, areaId, 'belongs_to_area', { confidence: 'DETERMINISTIC', role: definition.role, scope: definition.scope, freshness: definition.freshness });
1100
+ }
1101
+
1102
+ function addPackageResource(graph, fileId, packagePath, name, target, kind) {
1103
+ if (!target || typeof target !== 'string') return;
1104
+ const id = `package_resource:${packagePath}#${kind}:${name}`;
1105
+ addNode(graph, id, 'package_resource', { name, target, kind, path: packagePath });
1106
+ addEdge(graph, fileId, id, 'includes_resource', { kind, confidence: 'EXTRACTED' });
1107
+ }
1108
+
1109
+ function addTraceabilityTarget(graph, sourceId, root, relation, targetPath, data = {}, config = defaultMemoryConfig()) {
1110
+ if (!isLocalRelativeTraceabilityPath(targetPath) || !existsSync(resolve(root, targetPath))) return;
1111
+ const targetId = `file:${targetPath}`;
1112
+ addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
1113
+ addEdge(graph, sourceId, targetId, relation, { confidence: 'CURATED_TRACEABILITY', ...data });
1114
+ }
1115
+
1116
+ function addTraceabilityGraph(root, fileId, content, graph, config = defaultMemoryConfig()) {
1117
+ for (const block of extractDotdotgodTraceabilityBlocks(content)) {
1118
+ if (block.error || !block.data || block.data.kind !== 'spec') continue;
1119
+ for (const target of block.data.implementedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'implemented_by', target, {}, config);
1120
+ for (const target of block.data.verifiedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'verified_by', target, {}, config);
1121
+ for (const target of block.data.relatedDocs ?? []) addTraceabilityTarget(graph, fileId, root, 'related_doc', target, {}, config);
1122
+ for (const [index, command] of (Array.isArray(block.data.verificationCommands) ? block.data.verificationCommands : []).entries()) {
1123
+ if (typeof command !== 'string' || command.trim().length === 0) continue;
1124
+ const id = `verification_command:${fileId}#${index}`;
1125
+ addNode(graph, id, 'verification_command', { command, path: fileId.replace(/^file:/, '') });
1126
+ addEdge(graph, fileId, id, 'verification_command', { confidence: 'CURATED_TRACEABILITY' });
1127
+ }
1128
+ }
1129
+ }
1130
+
1131
+ export function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig()) {
1132
+ const path = rel(root, file);
1133
+ const content = readFileSync(file, 'utf8');
1134
+ const fileId = `file:${path}`;
1135
+ const headingRe = /^(#{1,6})\s+(.+)$/gm;
1136
+ let match;
1137
+ while ((match = headingRe.exec(content)) !== null) {
1138
+ const title = match[2].trim();
1139
+ const id = `heading:${path}#${headingToAnchor(title)}`;
1140
+ addNode(graph, id, 'heading', { path, title, depth: match[1].length });
1141
+ addEdge(graph, fileId, id, 'contains_heading', { confidence: 'EXTRACTED' });
1142
+ }
1143
+ for (const { href, line } of extractLinks(content)) {
1144
+ const pathPart = href.split('#')[0];
1145
+ if (!pathPart) continue;
1146
+ const targetPath = rel(root, resolve(dirname(file), pathPart));
1147
+ const targetId = `file:${targetPath}`;
1148
+ addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
1149
+ addEdge(graph, fileId, targetId, 'links_to', { line, confidence: 'EXTRACTED' });
1150
+ if (isReadmeIndexPath(path)) addEdge(graph, fileId, targetId, 'routes_to', { line, confidence: 'CURATED_INDEX', sourceRole: 'readme-index' });
1151
+ }
1152
+ addTraceabilityGraph(root, fileId, content, graph, config);
1153
+ }
1154
+
1155
+ export function extractPackageGraph(root, file, graph) {
1156
+ const path = rel(root, file);
1157
+ const fileId = `file:${path}`;
1158
+ let pkg;
1159
+ try { pkg = JSON.parse(readFileSync(file, 'utf8')); } catch { return; }
1160
+ if (pkg.name) {
1161
+ const pkgId = `package:${pkg.name}`;
1162
+ addNode(graph, pkgId, 'package', { name: pkg.name, path });
1163
+ addEdge(graph, fileId, pkgId, 'declares_package', { confidence: 'EXTRACTED' });
1164
+ }
1165
+ for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
1166
+ const id = `script:${path}#${name}`;
1167
+ addNode(graph, id, 'script', { name, command, path });
1168
+ addEdge(graph, fileId, id, 'declares_script', { confidence: 'EXTRACTED' });
1169
+ }
1170
+ for (const [name, target] of Object.entries(typeof pkg.bin === 'string' ? { [pkg.name ?? 'bin']: pkg.bin } : pkg.bin ?? {})) {
1171
+ const id = `bin:${name}`;
1172
+ addNode(graph, id, 'binary', { name, target, path });
1173
+ addEdge(graph, fileId, id, 'declares_bin', { confidence: 'EXTRACTED' });
1174
+ addPackageResource(graph, fileId, path, name, target, 'bin');
1175
+ }
1176
+ for (const [index, target] of (Array.isArray(pkg.files) ? pkg.files : []).entries()) {
1177
+ addPackageResource(graph, fileId, path, `files:${index}`, target, 'files');
1178
+ }
1179
+ for (const [kind, value] of Object.entries(pkg.pi ?? {})) {
1180
+ for (const [index, target] of (Array.isArray(value) ? value : [value]).entries()) {
1181
+ addPackageResource(graph, fileId, path, `pi:${kind}:${index}`, target, `pi:${kind}`);
1182
+ }
1183
+ }
1184
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) {
1185
+ for (const name of Object.keys(pkg[section] ?? {})) {
1186
+ const id = `dependency:${name}`;
1187
+ addNode(graph, id, 'dependency', { name });
1188
+ addEdge(graph, fileId, id, 'depends_on', { section, confidence: 'EXTRACTED' });
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ function isTestPath(path = '') {
1194
+ return /(^|\/)(test|tests)\//.test(path) || /\.(test|spec)\.(mjs|cjs|js|jsx|ts|tsx)$/.test(path);
1195
+ }
1196
+
1197
+ const TOKEN_STOPWORDS = new Set(['a', 'an', 'and', 'app', 'bin', 'code', 'config', 'docs', 'file', 'index', 'lib', 'md', 'mjs', 'node', 'package', 'readme', 'src', 'test', 'tests', 'the', 'ts', 'tsx', 'utils']);
1198
+
1199
+ function splitCamelCase(value = '') {
1200
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
1201
+ }
1202
+
1203
+ function conceptTokens(value = '') {
1204
+ const tokens = new Set();
1205
+ for (const raw of splitCamelCase(String(value)).toLowerCase().split(/[^a-z0-9]+/)) {
1206
+ if (raw.length < 3 || TOKEN_STOPWORDS.has(raw)) continue;
1207
+ tokens.add(raw);
1208
+ }
1209
+ return tokens;
1210
+ }
1211
+
1212
+ function addTokens(target, value) {
1213
+ for (const token of conceptTokens(value)) target.add(token);
1214
+ }
1215
+
1216
+ function intersectTokens(a, b) {
1217
+ return [...a].filter((token) => b.has(token));
1218
+ }
1219
+
1220
+ function jaccard(a, b) {
1221
+ if (a.size === 0 || b.size === 0) return 0;
1222
+ const intersection = intersectTokens(a, b).length;
1223
+ const denominator = Math.min(a.size, b.size);
1224
+ return denominator === 0 ? 0 : intersection / denominator;
1225
+ }
1226
+
1227
+ function semanticSignalScore(source, target) {
1228
+ const path = jaccard(source.pathTokens, target.pathTokens);
1229
+ const heading = Math.max(jaccard(source.headingTokens, target.headingTokens), jaccard(source.headingTokens, target.allTokens), jaccard(source.allTokens, target.headingTokens));
1230
+ const pkg = Math.max(jaccard(source.packageTokens, target.packageTokens), jaccard(source.allTokens, target.packageTokens), jaccard(source.packageTokens, target.allTokens));
1231
+ return { path, filename: path, heading, package: pkg };
1232
+ }
1233
+
1234
+ function semanticProfileForFile(fileNode, graph) {
1235
+ const profile = {
1236
+ id: fileNode.id,
1237
+ path: fileNode.path,
1238
+ retrieval: fileNode.retrieval,
1239
+ pathTokens: new Set(),
1240
+ headingTokens: new Set(),
1241
+ packageTokens: new Set(),
1242
+ allTokens: new Set(),
1243
+ };
1244
+ addTokens(profile.pathTokens, fileNode.path ?? fileNode.id);
1245
+ addTokens(profile.pathTokens, basename(fileNode.path ?? ''));
1246
+
1247
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
1248
+ for (const edge of graph.edges) {
1249
+ if (edge.source !== fileNode.id) continue;
1250
+ const target = nodeById.get(edge.target);
1251
+ if (!target) continue;
1252
+ if (edge.relation === 'contains_heading') addTokens(profile.headingTokens, target.title ?? target.id);
1253
+ if (edge.relation === 'declares_package' || edge.relation === 'declares_bin' || edge.relation === 'includes_resource' || edge.relation === 'depends_on') addTokens(profile.packageTokens, target.name ?? target.target ?? target.id);
1254
+ }
1255
+ for (const set of [profile.pathTokens, profile.headingTokens, profile.packageTokens]) {
1256
+ for (const token of set) profile.allTokens.add(token);
1257
+ }
1258
+ return profile;
1259
+ }
1260
+
1261
+ function semanticRelationForProfiles(source, target, scores) {
1262
+ if (scores.package >= 0.5) return 'mentions_package';
1263
+ return 'semantic_similarity';
1264
+ }
1265
+
1266
+ export function addDeterministicSemanticEdges(graph, config = defaultMemoryConfig()) {
1267
+ const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
1268
+ if (policy.semantic.enabled === false || policy.semantic.topKPerFile === 0) return graph;
1269
+
1270
+ const threshold = policy.semantic.threshold ?? 0.5;
1271
+ const topK = policy.semantic.topKPerFile ?? 5;
1272
+ const includeArchiveBodies = policy.semantic.includeArchiveBodies === true;
1273
+ const enabledSignals = new Set(policy.semantic.signals ?? DEFAULT_IMPACT_RANKING_POLICY.semantic.signals);
1274
+ const baseGraph = { nodes: graph.nodes.map((node) => ({ ...node })), edges: graph.edges.filter((edge) => !SEMANTIC_RELATIONS.has(edge.relation)).map((edge) => ({ ...edge })) };
1275
+ const files = baseGraph.nodes.filter((node) => node.type === 'file' && node.path);
1276
+ const profiles = files.map((node) => semanticProfileForFile(node, baseGraph));
1277
+
1278
+ for (const source of profiles) {
1279
+ if (!includeArchiveBodies && source.retrieval?.area === 'archive-body') continue;
1280
+ const candidates = [];
1281
+ for (const target of profiles) {
1282
+ if (source.id === target.id) continue;
1283
+ if (!includeArchiveBodies && target.retrieval?.area === 'archive-body') continue;
1284
+ const scores = semanticSignalScore(source, target);
1285
+ const weighted = Object.entries(scores).filter(([signal]) => enabledSignals.has(signal));
1286
+ const score = weighted.length === 0 ? 0 : Math.max(...weighted.map(([, value]) => value));
1287
+ if (score < threshold) continue;
1288
+ const matchedTerms = intersectTokens(source.allTokens, target.allTokens).slice(0, 12);
1289
+ if (matchedTerms.length === 0) continue;
1290
+ const relation = semanticRelationForProfiles(source, target, scores);
1291
+ const signals = weighted.filter(([, value]) => value > 0).map(([signal]) => signal);
1292
+ candidates.push({ target, relation, score, matchedTerms, signals });
1293
+ }
1294
+ candidates
1295
+ .sort((a, b) => b.score - a.score || a.target.id.localeCompare(b.target.id))
1296
+ .slice(0, topK)
1297
+ .forEach((candidate) => {
1298
+ addEdge(baseGraph, source.id, candidate.target.id, candidate.relation, {
1299
+ confidence: 'INFERRED_LEXICAL_SEMANTIC',
1300
+ score: Math.round(candidate.score * 1000) / 1000,
1301
+ matchedTerms: candidate.matchedTerms,
1302
+ signals: candidate.signals,
1303
+ });
1304
+ });
1305
+ }
1306
+ return baseGraph;
1307
+ }
1308
+
1309
+ export function buildGraph(root, files, config = readMemoryConfig(root)) {
1310
+ const graph = { nodes: [], edges: [] };
1311
+ for (const file of files) {
1312
+ const path = rel(root, file);
1313
+ const fileId = `file:${path}`;
1314
+ addNode(graph, fileId, 'file', fileNodeMetadata(path, file, config));
1315
+ addMemoryAreaMembership(graph, fileId, path, config);
1316
+ if (path.endsWith('.md')) extractMarkdownGraph(root, file, graph, config);
1317
+ else if (basename(file) === 'package.json') extractPackageGraph(root, file, graph);
1318
+ }
1319
+ return graph;
1320
+ }
1321
+
1322
+ function collectFingerprints(root, config = readMemoryConfig(root)) {
1323
+ return collectIndexFiles(root, config).map((file) => {
1324
+ const stats = statSync(file);
1325
+ return { path: rel(root, file), sha256: fingerprint(file), size: stats.size, mtimeMs: Math.round(stats.mtimeMs) };
1326
+ });
1327
+ }
1328
+
1329
+ function nodeOwnedByPath(node, paths) {
1330
+ return paths.has(node.path) || paths.has(node.id?.replace(/^file:/, '')) || paths.has(node.id?.replace(/^test:/, ''));
1331
+ }
1332
+
1333
+ function mergeIncrementalGraph(previousGraph, changedGraph, changedPaths) {
1334
+ if (!previousGraph) return changedGraph;
1335
+ const changedSet = new Set(changedPaths);
1336
+ const changedNodeIds = new Set(previousGraph.nodes.filter((node) => nodeOwnedByPath(node, changedSet)).map((node) => node.id));
1337
+ for (const node of changedGraph.nodes) changedNodeIds.add(node.id);
1338
+ const graph = { nodes: [], edges: [] };
1339
+ for (const node of previousGraph.nodes) if (!changedNodeIds.has(node.id) && !nodeOwnedByPath(node, changedSet)) addNode(graph, node.id, node.type, Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'id' && key !== 'type')));
1340
+ for (const edge of previousGraph.edges) if (!changedNodeIds.has(edge.source) && !changedNodeIds.has(edge.target)) addEdge(graph, edge.source, edge.target, edge.relation, Object.fromEntries(Object.entries(edge).filter(([key]) => key !== 'source' && key !== 'target' && key !== 'relation')));
1341
+ for (const node of changedGraph.nodes) addNode(graph, node.id, node.type, Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'id' && key !== 'type')));
1342
+ for (const edge of changedGraph.edges) addEdge(graph, edge.source, edge.target, edge.relation, Object.fromEntries(Object.entries(edge).filter(([key]) => key !== 'source' && key !== 'target' && key !== 'relation')));
1343
+ return graph;
1344
+ }
1345
+
1346
+ export function buildIndex(root, previous = readIndex(root)) {
1347
+ const startedAt = Date.now();
1348
+ const memoryConfig = readMemoryConfig(root);
1349
+ const files = collectFingerprints(root, memoryConfig);
1350
+ const indexed = new Map((previous?.files ?? []).map((file) => [file.path, file.sha256]));
1351
+ const currentPaths = new Set(files.map((file) => file.path));
1352
+ const removedPaths = (previous?.files ?? []).filter((file) => !currentPaths.has(file.path)).map((file) => file.path);
1353
+ const changedPaths = [...files.filter((file) => indexed.get(file.path) !== file.sha256).map((file) => file.path), ...removedPaths];
1354
+ const changedFiles = files.filter((file) => changedPaths.includes(file.path)).map((file) => join(root, file.path));
1355
+ const fullRebuild = !previous?.graph || previous.version !== CACHE_VERSION;
1356
+ const refreshReason = !previous ? 'missing' : previous.version !== CACHE_VERSION ? 'schema-mismatch' : removedPaths.length > 0 ? 'content-removed' : changedPaths.length > 0 ? 'content-changed' : 'fresh';
1357
+ const rawGraph = fullRebuild ? buildGraph(root, files.map((file) => join(root, file.path)), memoryConfig) : mergeIncrementalGraph(previous.graph, buildGraph(root, changedFiles, memoryConfig), changedPaths);
1358
+ const graph = addDeterministicSemanticEdges(rawGraph, memoryConfig);
1359
+ const archiveBodiesIncluded = (memoryConfig.areas ?? []).some((area) => area.id === 'archive-body' && area.includeBodiesByDefault !== false);
1360
+ return { version: CACHE_VERSION, schemaVersion: CACHE_VERSION, generatedAt: new Date().toISOString(), archiveBodiesIncluded, memoryConfig: memoryConfigSummary(memoryConfig), files, graph, stats: graphStats(graph), incremental: { enabled: true, fullRebuild, changedFiles: changedPaths.length, refreshReason, elapsedMs: Date.now() - startedAt } };
1361
+ }
1362
+
1363
+ export function writeIndex(root, index) {
1364
+ const compact = compactGraph(index.graph);
1365
+ for (const [name, rows] of Object.entries(compact.nodes)) writeJson(shardFile(root, 'nodes', name), rows);
1366
+ for (const [name, rows] of Object.entries(compact.edges)) writeJson(shardFile(root, 'edges', name), rows);
1367
+ const graphShards = {
1368
+ nodes: Object.fromEntries(Object.keys(compact.nodes).map((name) => [name, rel(root, shardFile(root, 'nodes', name))])),
1369
+ edges: Object.fromEntries(Object.keys(compact.edges).map((name) => [name, rel(root, shardFile(root, 'edges', name))])),
1370
+ };
1371
+ const manifest = { version: index.version, schemaVersion: index.schemaVersion ?? index.version, generatedAt: index.generatedAt, archiveBodiesIncluded: index.archiveBodiesIncluded, memoryConfig: index.memoryConfig, files: index.files, graph: { ...graphStats(index.graph), compactSchema: true, shards: graphShards }, incremental: index.incremental };
1372
+ writeJson(cacheFile(root), manifest);
1373
+ const shardBytes = [...Object.values(graphShards.nodes), ...Object.values(graphShards.edges)].reduce((sum, path) => sum + jsonSize(join(root, path)), 0);
1374
+ const manifestBytes = jsonSize(cacheFile(root));
1375
+ return { ...manifest, indexSizeBytes: manifestBytes + shardBytes, manifestBytes, shardBytes };
1376
+ }
1377
+
1378
+ export function readIndex(root) {
1379
+ const file = cacheFile(root);
1380
+ if (!existsSync(file)) return null;
1381
+ try {
1382
+ const manifest = JSON.parse(readFileSync(file, 'utf8'));
1383
+ const shards = manifest.graph?.shards;
1384
+ if (!shards) return manifest;
1385
+ const compact = { nodes: {}, edges: {} };
1386
+ for (const [name, path] of Object.entries(shards.nodes ?? {})) compact.nodes[name] = existsSync(join(root, path)) ? JSON.parse(readFileSync(join(root, path), 'utf8')) : [];
1387
+ for (const [name, path] of Object.entries(shards.edges ?? {})) compact.edges[name] = existsSync(join(root, path)) ? JSON.parse(readFileSync(join(root, path), 'utf8')) : [];
1388
+ return { ...manifest, graph: expandGraph(compact) };
1389
+ } catch { return null; }
1390
+ }
1391
+
1392
+ export function getStatus(root) {
1393
+ const index = readIndex(root);
1394
+ const memoryConfig = readMemoryConfig(root);
1395
+ const currentFiles = collectFingerprints(root, memoryConfig);
1396
+ if (!index) return { ok: false, status: 'missing', cachePath: rel(root, cacheFile(root)), indexedFiles: 0, currentFiles: currentFiles.length, staleFiles: currentFiles.length, archiveBodiesIncluded: false, schemaVersion: null, expectedSchemaVersion: CACHE_VERSION, schemaOk: false, reason: 'missing' };
1397
+ const indexed = new Map((index.files ?? []).map((file) => [file.path, file.sha256]));
1398
+ const currentMap = new Map(currentFiles.map((file) => [file.path, file.sha256]));
1399
+ const stale = currentFiles.filter((file) => indexed.get(file.path) !== file.sha256).map((file) => file.path);
1400
+ const removed = [...indexed.keys()].filter((path) => !currentMap.has(path));
1401
+ const staleFiles = [...stale, ...removed];
1402
+ const schemaVersion = index.schemaVersion ?? index.version ?? null;
1403
+ const schemaOk = schemaVersion === CACHE_VERSION;
1404
+ const ok = staleFiles.length === 0 && schemaOk;
1405
+ const reason = ok ? 'fresh' : !schemaOk ? 'schema-mismatch' : staleFiles.length > 0 ? 'content-changed' : 'unknown';
1406
+ return { ok, status: ok ? 'fresh' : 'stale', cachePath: rel(root, cacheFile(root)), indexedFiles: index.files?.length ?? 0, currentFiles: currentFiles.length, staleFiles: staleFiles.length, examples: staleFiles.slice(0, 10), archiveBodiesIncluded: index.archiveBodiesIncluded === true, schemaVersion, expectedSchemaVersion: CACHE_VERSION, schemaOk, reason, graph: graphStats(index.graph ?? { nodes: [], edges: [] }) };
1407
+ }
1408
+
1409
+ export function runIndex(argv) {
1410
+ const options = parseCommon(argv);
1411
+ const index = buildIndex(options.root);
1412
+ const manifest = writeIndex(options.root, index);
1413
+ const result = { ok: true, cachePath: rel(options.root, cacheFile(options.root)), schemaVersion: CACHE_VERSION, indexedFiles: index.files.length, nodes: index.graph.nodes.length, edges: index.graph.edges.length, indexSizeBytes: manifest.indexSizeBytes, shards: manifest.graph.shards, incremental: index.incremental, archiveBodiesIncluded: index.archiveBodiesIncluded === true, memoryConfig: index.memoryConfig };
1414
+ if (options.json) console.log(JSON.stringify(result, null, 2));
1415
+ else console.log(`✅ index written (${result.indexedFiles} files, ${result.nodes} nodes, ${result.edges} edges, ${(result.indexSizeBytes / 1024).toFixed(1)} KiB, cache: ${result.cachePath})`);
1416
+ }
1417
+
1418
+ export function runStatus(argv) {
1419
+ const options = parseCommon(argv);
1420
+ const status = getStatus(options.root);
1421
+ if (options.json) console.log(JSON.stringify(status, null, 2));
1422
+ else console.log(`dotdotgod index status: ${status.status} (${status.indexedFiles}/${status.currentFiles} files, cache: ${status.cachePath})`);
1423
+ process.exit(status.ok ? 0 : 1);
1424
+ }
1425
+
1426
+ export function readFreshIndex(root) {
1427
+ const startedAt = Date.now();
1428
+ const initialStatus = getStatus(root);
1429
+ if (initialStatus.ok) return { status: initialStatus, index: readIndex(root), metadata: { cacheRefreshed: false, elapsedMs: Date.now() - startedAt, refreshReason: 'fresh', schemaVersion: CACHE_VERSION } };
1430
+
1431
+ const index = buildIndex(root);
1432
+ const manifest = writeIndex(root, index);
1433
+ const status = getStatus(root);
1434
+ return {
1435
+ status,
1436
+ index,
1437
+ metadata: {
1438
+ cacheRefreshed: true,
1439
+ previousStatus: initialStatus.status,
1440
+ previousReason: initialStatus.reason,
1441
+ refreshReason: index.incremental?.refreshReason ?? initialStatus.reason,
1442
+ changedFiles: index.incremental?.changedFiles ?? initialStatus.staleFiles,
1443
+ fullRebuild: index.incremental?.fullRebuild === true,
1444
+ indexedFiles: index.files.length,
1445
+ indexSizeBytes: manifest.indexSizeBytes,
1446
+ manifestBytes: manifest.manifestBytes,
1447
+ shardBytes: manifest.shardBytes,
1448
+ elapsedMs: Date.now() - startedAt,
1449
+ indexElapsedMs: index.incremental?.elapsedMs,
1450
+ schemaVersion: CACHE_VERSION,
1451
+ archiveBodiesIncluded: index.archiveBodiesIncluded === true,
1452
+ },
1453
+ };
1454
+ }
1455
+
1456
+ export function graphSummary(index) {
1457
+ const graph = index?.graph ?? { nodes: [], edges: [] };
1458
+ const byType = graph.nodes.reduce((acc, node) => ({ ...acc, [node.type]: (acc[node.type] ?? 0) + 1 }), {});
1459
+ const byRelation = graph.edges.reduce((acc, edge) => ({ ...acc, [edge.relation]: (acc[edge.relation] ?? 0) + 1 }), {});
1460
+ return { nodes: graph.nodes.length, edges: graph.edges.length, byType, byRelation };
1461
+ }
1462
+
1463
+ export function neighborhood(index, changedPath) {
1464
+ return buildImpactReport(index, changedPath).related;
1465
+ }
1466
+
1467
+ function addImpactItem(group, item, limit = 10) {
1468
+ if (group.items.some((existing) => existing.id === item.id)) return;
1469
+ if (group.items.length >= limit) {
1470
+ group.omitted += 1;
1471
+ return;
1472
+ }
1473
+ group.items.push(item);
1474
+ }
1475
+
1476
+ function docsArea(path = '') {
1477
+ if (path.startsWith('docs/spec/')) return 'spec';
1478
+ if (path.startsWith('docs/arch/')) return 'arch';
1479
+ if (path.startsWith('docs/test/')) return 'test-docs';
1480
+ if (path.startsWith('docs/plan/')) return 'plan';
1481
+ if (path.startsWith('docs/archive/')) return 'archive-index';
1482
+ if (path.startsWith('docs/')) return 'docs';
1483
+ return undefined;
1484
+ }
1485
+
1486
+ function clamp(value, min, max) {
1487
+ return Math.max(min, Math.min(max, value));
1488
+ }
1489
+
1490
+ function roundScore(value) {
1491
+ return Math.round(value * 10) / 10;
1492
+ }
1493
+
1494
+ function sumReasonBoosts(reasons, boosts) {
1495
+ return reasons.reduce((sum, reason) => sum + (boosts[reason] ?? 0), 0);
1496
+ }
1497
+
1498
+ function cappedTraceabilityScore(reasons, boosts, cap) {
1499
+ const values = reasons.map((reason) => boosts[reason] ?? 0).filter((value) => value > 0);
1500
+ if (values.length === 0) return 0;
1501
+ return clamp(Math.max(...values) + Math.min(5, Math.max(0, values.length - 1) * 2), 0, cap);
1502
+ }
1503
+
1504
+ function buildPersonalizedPageRank(graph, seed, policy) {
1505
+ if (policy.ppr.enabled === false) return new Map();
1506
+ const damping = policy.ppr.damping ?? 0.85;
1507
+ const iterations = policy.ppr.iterations ?? 20;
1508
+ const tolerance = policy.ppr.tolerance ?? 0.000001;
1509
+ const ids = new Set(graph.nodes.map((node) => node.id));
1510
+ ids.add(seed);
1511
+ const adjacency = new Map([...ids].map((id) => [id, []]));
1512
+ for (const edge of graph.edges) {
1513
+ const weight = policy.relationWeights[edge.relation] ?? relationWeight(edge.relation);
1514
+ if (weight <= 0) continue;
1515
+ if (!adjacency.has(edge.source)) adjacency.set(edge.source, []);
1516
+ if (!adjacency.has(edge.target)) adjacency.set(edge.target, []);
1517
+ adjacency.get(edge.source).push([edge.target, weight]);
1518
+ adjacency.get(edge.target).push([edge.source, weight]);
1519
+ }
1520
+ let ranks = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 : 0]));
1521
+ for (let i = 0; i < iterations; i += 1) {
1522
+ const next = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 - damping : 0]));
1523
+ for (const [id, edges] of adjacency.entries()) {
1524
+ const rank = ranks.get(id) ?? 0;
1525
+ const total = edges.reduce((sum, [, weight]) => sum + weight, 0);
1526
+ if (total === 0) continue;
1527
+ for (const [target, weight] of edges) next.set(target, (next.get(target) ?? 0) + damping * rank * (weight / total));
1528
+ }
1529
+ const delta = [...next.entries()].reduce((sum, [id, rank]) => sum + Math.abs(rank - (ranks.get(id) ?? 0)), 0);
1530
+ ranks = next;
1531
+ if (delta < tolerance) break;
1532
+ }
1533
+ return ranks;
1534
+ }
1535
+
1536
+ function scoreImpactItem(item, seed, changedPath, policy, pprScores, maxPpr) {
1537
+ if (item.id === seed) {
1538
+ return { impactScore: 100, scoreBreakdown: { seed: 100, ppr: 40, traceability: 0, memoryPolicy: 0, verification: 0, proximity: 0, semantic: 0, freshness: 0, archivePenalty: 0 } };
1539
+ }
1540
+ const reasons = item.reasons ?? [];
1541
+ const retrieval = item.retrieval ?? {};
1542
+ const pprNormalized = maxPpr > 0 ? (pprScores.get(item.id) ?? 0) / maxPpr : 0;
1543
+ const ppr = clamp(pprNormalized * (policy.weights.ppr ?? 40), 0, Math.abs(policy.weights.ppr ?? 40));
1544
+ const traceability = cappedTraceabilityScore(reasons, policy.traceabilityBoosts, Math.abs(policy.weights.traceability ?? 30));
1545
+ const memoryPolicy = clamp(((retrieval.priority ?? 30) / 100) * Math.abs(policy.weights.memoryPolicy ?? 10), 0, Math.abs(policy.weights.memoryPolicy ?? 10));
1546
+ const verification = clamp(sumReasonBoosts(reasons, policy.verificationBoosts) + (item.type === 'test' || isTestPath(item.path ?? '') ? 10 : 0) + (item.type === 'verification_command' ? 12 : 0), 0, Math.abs(policy.weights.verification ?? 15));
1547
+ const proximity = clamp(sumReasonBoosts(reasons, policy.proximityBoosts), 0, Math.abs(policy.weights.proximity ?? 10));
1548
+ const semantic = clamp(sumReasonBoosts(reasons, policy.semanticBoosts), 0, Math.abs(policy.weights.semantic ?? 10));
1549
+ const freshness = retrieval.freshness === 'fresh' ? Math.abs(policy.weights.freshness ?? 5) : retrieval.freshness === 'stale' ? -Math.abs(policy.weights.freshness ?? 5) : 0;
1550
+ let archivePenalty = 0;
1551
+ if (!changedPath.startsWith('docs/archive/')) {
1552
+ if (retrieval.area === 'archive-body') archivePenalty -= Math.abs(policy.weights.archivePenalty ?? -25);
1553
+ if (retrieval.includeBodiesByDefault === false) archivePenalty -= 10;
1554
+ if (retrieval.freshness === 'stale' && retrieval.area !== 'archive-map') archivePenalty -= 5;
1555
+ }
1556
+ archivePenalty = clamp(archivePenalty, -Math.abs(policy.weights.archivePenalty ?? -25), 0);
1557
+ const impactScore = clamp(ppr + traceability + memoryPolicy + verification + proximity + semantic + freshness + archivePenalty, 0, 100);
1558
+ return {
1559
+ impactScore: roundScore(impactScore),
1560
+ scoreBreakdown: {
1561
+ ppr: roundScore(ppr),
1562
+ traceability: roundScore(traceability),
1563
+ memoryPolicy: roundScore(memoryPolicy),
1564
+ verification: roundScore(verification),
1565
+ proximity: roundScore(proximity),
1566
+ semantic: roundScore(semantic),
1567
+ freshness: roundScore(freshness),
1568
+ archivePenalty: roundScore(archivePenalty),
1569
+ },
1570
+ };
1571
+ }
1572
+
1573
+ const CURATED_IMPACT_REASONS = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', 'links_to', 'routes_to', 'belongs_to_area']);
1574
+ const LOW_ACTIONABILITY_IMPACT_TYPES = new Set(['dependency', 'package', 'script', 'binary', 'heading', 'memory_area']);
1575
+
1576
+ function baseImpactReason(reason = '') {
1577
+ return reason.replace(/^incoming:/, '');
1578
+ }
1579
+
1580
+ function isSemanticImpactReason(reason = '') {
1581
+ return SEMANTIC_RELATIONS.has(baseImpactReason(reason));
1582
+ }
1583
+
1584
+ function isCuratedImpactReason(reason = '') {
1585
+ return CURATED_IMPACT_REASONS.has(baseImpactReason(reason));
1586
+ }
1587
+
1588
+ function hasCuratedImpactReason(item) {
1589
+ return (item.reasons ?? []).some((reason) => isCuratedImpactReason(reason));
1590
+ }
1591
+
1592
+ function isSemanticOnlyImpactItem(item) {
1593
+ const reasons = item.reasons ?? [];
1594
+ return reasons.length > 0 && reasons.every((reason) => isSemanticImpactReason(reason));
1595
+ }
1596
+
1597
+ function isLowActionabilityImpactItem(item) {
1598
+ return LOW_ACTIONABILITY_IMPACT_TYPES.has(item.type);
1599
+ }
1600
+
1601
+ function impactSelectionScore(item) {
1602
+ let score = item.impactScore ?? 0;
1603
+ if (hasCuratedImpactReason(item)) score += 4;
1604
+ if (item.type === 'file' || item.type === 'test' || item.type === 'verification_command') score += 2;
1605
+ if (isSemanticOnlyImpactItem(item)) score -= 12;
1606
+ if (isLowActionabilityImpactItem(item)) score -= 15;
1607
+ return score;
1608
+ }
1609
+
1610
+ function compareImpactItems(seed) {
1611
+ return (a, b) => {
1612
+ if (a.id === seed) return -1;
1613
+ if (b.id === seed) return 1;
1614
+ return impactSelectionScore(b) - impactSelectionScore(a) || (b.impactScore ?? 0) - (a.impactScore ?? 0) || a.id.localeCompare(b.id);
1615
+ };
1616
+ }
1617
+
1618
+ function selectImpactItems(sortedItems, maxRelated, seed) {
1619
+ const selected = [];
1620
+ const selectedIds = new Set();
1621
+ const deferred = [];
1622
+ const firstPageLimit = Math.min(maxRelated, 10);
1623
+ let semanticOnlyInFirstPage = 0;
1624
+ let lowActionabilityInFirstPage = 0;
1625
+ const firstPagePathCounts = new Map();
1626
+ const add = (item, force = false) => {
1627
+ if (selected.length >= maxRelated || selectedIds.has(item.id)) return;
1628
+ const pathKey = item.path ?? item.id;
1629
+ if (!force && item.id !== seed && selected.length < firstPageLimit) {
1630
+ if (pathKey && (firstPagePathCounts.get(pathKey) ?? 0) >= 2) {
1631
+ deferred.push(item);
1632
+ return;
1633
+ }
1634
+ if (isLowActionabilityImpactItem(item) && lowActionabilityInFirstPage >= 2) {
1635
+ deferred.push(item);
1636
+ return;
1637
+ }
1638
+ if (isSemanticOnlyImpactItem(item) && semanticOnlyInFirstPage >= 3) {
1639
+ deferred.push(item);
1640
+ return;
1641
+ }
1642
+ }
1643
+ selected.push(item);
1644
+ selectedIds.add(item.id);
1645
+ if (selected.length <= firstPageLimit && item.id !== seed) {
1646
+ if (pathKey) firstPagePathCounts.set(pathKey, (firstPagePathCounts.get(pathKey) ?? 0) + 1);
1647
+ if (isLowActionabilityImpactItem(item)) lowActionabilityInFirstPage += 1;
1648
+ if (isSemanticOnlyImpactItem(item)) semanticOnlyInFirstPage += 1;
1649
+ }
1650
+ };
1651
+ for (const item of sortedItems) if (item.id === seed) add(item, true);
1652
+ for (const item of sortedItems) if (item.id !== seed) add(item);
1653
+ for (const item of deferred) add(item, true);
1654
+ return selected;
1655
+ }
1656
+
1657
+ export function buildImpactReport(index, changedPath, limits = {}) {
1658
+ const graph = index?.graph ?? { nodes: [], edges: [] };
1659
+ const config = index?.memoryConfig ? { ...defaultMemoryConfig(), ...index.memoryConfig } : defaultMemoryConfig();
1660
+ const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
1661
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
1662
+ const seed = `file:${changedPath}`;
1663
+ const maxRelated = limits.related ?? 25;
1664
+ const groups = {
1665
+ files: { items: [], omitted: 0 },
1666
+ docs: { items: [], omitted: 0 },
1667
+ tests: { items: [], omitted: 0 },
1668
+ commands: { items: [], omitted: 0 },
1669
+ events: { items: [], omitted: 0 },
1670
+ packageResources: { items: [], omitted: 0 },
1671
+ symbols: { items: [], omitted: 0 },
1672
+ };
1673
+ const relatedIds = new Set([seed]);
1674
+ const reasons = new Map([[seed, new Set(['changed-file'])]]);
1675
+ const addReason = (id, reason) => {
1676
+ relatedIds.add(id);
1677
+ if (!reasons.has(id)) reasons.set(id, new Set());
1678
+ reasons.get(id).add(reason);
1679
+ };
1680
+
1681
+ for (const edge of graph.edges) {
1682
+ if (edge.source === seed) addReason(edge.target, edge.relation);
1683
+ if (edge.target === seed) addReason(edge.source, `incoming:${edge.relation}`);
1684
+ }
1685
+
1686
+ const expansionRelations = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', ...SEMANTIC_RELATIONS]);
1687
+ for (const edge of graph.edges) {
1688
+ if (!expansionRelations.has(edge.relation)) continue;
1689
+ if (relatedIds.has(edge.source) && edge.target !== seed) addReason(edge.target, edge.relation);
1690
+ }
1691
+
1692
+ const pprScores = buildPersonalizedPageRank(graph, seed, policy);
1693
+ const candidatePprMax = Math.max(0, ...[...relatedIds].filter((id) => id !== seed).map((id) => pprScores.get(id) ?? 0));
1694
+ const relatedAll = [...relatedIds]
1695
+ .map((id) => {
1696
+ const node = nodeById.get(id) ?? { id };
1697
+ const path = node.path ?? id.replace(/^file:/, '').replace(/^test:/, '');
1698
+ const reasonList = [...(reasons.get(id) ?? [])];
1699
+ const retrieval = node.retrieval ?? retrievalMetadataForPath(path);
1700
+ const reasonSignals = reasonList.map((reason) => `reason:${reason}`);
1701
+ const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seed, changedPath, policy, pprScores, candidatePprMax);
1702
+ return { ...node, reasons: reasonList, retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
1703
+ })
1704
+ .sort(compareImpactItems(seed));
1705
+ const related = selectImpactItems(relatedAll, maxRelated, seed);
1706
+ for (const item of related) {
1707
+ if (item.type === 'file') {
1708
+ const area = docsArea(item.path);
1709
+ if (area) addImpactItem(groups.docs, { ...item, area }, limits.docs ?? 10);
1710
+ else if (isTestPath(item.path)) addImpactItem(groups.tests, item, limits.tests ?? 10);
1711
+ else addImpactItem(groups.files, item, limits.files ?? 10);
1712
+ } else if (item.type === 'test') addImpactItem(groups.tests, item, limits.tests ?? 10);
1713
+ else if (item.type === 'command') addImpactItem(groups.commands, item, limits.commands ?? 10);
1714
+ else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
1715
+ else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
1716
+ }
1717
+
1718
+ return {
1719
+ changed: changedPath,
1720
+ ranking: { method: policy.ppr.enabled === false ? 'policy-score' : 'personalized-pagerank+policy', preset: policy.preset, configSource: index?.memoryConfig?.source ?? 'default', weights: policy.weights, ppr: policy.ppr },
1721
+ related,
1722
+ groups,
1723
+ omittedRelated: Math.max(0, relatedAll.length - related.length),
1724
+ };
1725
+ }
1726
+
1727
+ function compactScoreBreakdown(scoreBreakdown = {}) {
1728
+ const compact = {};
1729
+ for (const [key, value] of Object.entries(scoreBreakdown)) {
1730
+ if (value !== 0 && value !== undefined) compact[key] = value;
1731
+ }
1732
+ return compact;
1733
+ }
1734
+
1735
+ function compactImpactItem(item) {
1736
+ const compact = {
1737
+ id: item.id,
1738
+ type: item.type,
1739
+ impactScore: item.impactScore,
1740
+ reasons: (item.reasons ?? []).slice(0, 6),
1741
+ scoreBreakdown: compactScoreBreakdown(item.scoreBreakdown),
1742
+ };
1743
+ for (const key of ['path', 'area', 'name', 'command', 'target', 'kind', 'specifier', 'title']) {
1744
+ if (item[key] !== undefined) compact[key] = item[key];
1745
+ }
1746
+ if (item.retrieval) {
1747
+ compact.retrieval = {
1748
+ area: item.retrieval.area,
1749
+ role: item.retrieval.role,
1750
+ priority: item.retrieval.priority,
1751
+ freshness: item.retrieval.freshness,
1752
+ };
1753
+ }
1754
+ return compact;
1755
+ }
1756
+
1757
+ function compactImpactGroup(group = { items: [], omitted: 0 }, limit = 5) {
1758
+ const items = (group.items ?? []).slice(0, limit).map(compactImpactItem);
1759
+ return { items, omitted: (group.omitted ?? 0) + Math.max(0, (group.items?.length ?? 0) - items.length) };
1760
+ }
1761
+
1762
+ export function buildCompactImpactReport(impact, limits = {}) {
1763
+ const relatedLimit = limits.related ?? 10;
1764
+ const groupLimit = limits.groupItems ?? 5;
1765
+ const related = (impact.related ?? []).slice(0, relatedLimit).map(compactImpactItem);
1766
+ const groupNames = ['files', 'docs', 'tests', 'commands', 'events', 'packageResources', 'symbols'];
1767
+ const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
1768
+ const top10 = (impact.related ?? []).filter((item) => item.id !== `file:${impact.changed}`).slice(0, 10);
1769
+ return {
1770
+ changed: impact.changed,
1771
+ compact: true,
1772
+ ranking: {
1773
+ method: impact.ranking?.method,
1774
+ preset: impact.ranking?.preset,
1775
+ configSource: impact.ranking?.configSource,
1776
+ },
1777
+ related,
1778
+ groups,
1779
+ omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length),
1780
+ quality: {
1781
+ rawRelated: impact.related?.length ?? 0,
1782
+ compactRelated: related.length,
1783
+ semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length,
1784
+ curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length,
1785
+ lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length,
1786
+ },
1787
+ };
1788
+ }
1789
+
1790
+ function formatCompactImpactGroup(name, group) {
1791
+ const items = group?.items ?? [];
1792
+ if (items.length === 0) return [];
1793
+ return [
1794
+ `${name}:`,
1795
+ ...items.map((item) => {
1796
+ const label = item.path ?? item.command ?? item.name ?? item.target ?? item.id;
1797
+ const reasons = (item.reasons ?? []).slice(0, 3).join(', ');
1798
+ return `- ${label} (${item.impactScore}; ${reasons})`;
1799
+ }),
1800
+ ];
1801
+ }
1802
+
1803
+ function formatCompactImpactOutput(payload, impact) {
1804
+ const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
1805
+ const lines = [`graph impact compact: ${impact.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${payload.status.status}${refreshNote} index)`];
1806
+ for (const name of ['docs', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatCompactImpactGroup(name, impact.groups[name]));
1807
+ return lines.join('\n');
1808
+ }
1809
+
1810
+ const DURABLE_COMMUNITY_NODE_TYPES = new Set(['file', 'memory_area', 'package_resource', 'package', 'script', 'binary']);
1811
+
1812
+ function communityKeyForNode(node) {
1813
+ if (node.type === 'memory_area') return `memory-${node.area}`;
1814
+ const path = node.path ?? node.id?.replace(/^file:/, '').replace(/^test:/, '') ?? '';
1815
+ if (path.startsWith('packages/pi/extensions/plan-mode/')) return 'pi-plan-mode';
1816
+ if (path.startsWith('packages/pi/extensions/load-project/')) return 'pi-load-project';
1817
+ if (path.startsWith('packages/pi/extensions/context-metrics/')) return 'pi-context-metrics';
1818
+ if (path.startsWith('packages/cli/')) return 'cli';
1819
+ if (path.startsWith('packages/claude-code/')) return 'claude-code-adapter';
1820
+ if (path.startsWith('packages/codex/')) return 'codex-adapter';
1821
+ if (path.startsWith('packages/shared/')) return 'shared-adapter-resources';
1822
+ const area = docsArea(path);
1823
+ if (area) return `docs-${area}`;
1824
+ if (node.type === 'package' || node.type === 'script' || node.type === 'binary' || node.type === 'dependency' || node.type === 'package_resource') return 'package-metadata';
1825
+ if (node.type === 'command') return `command-${node.name}`;
1826
+ if (node.type === 'event') return `event-${node.name.split(':')[0]}`;
1827
+ return 'project-root';
1828
+ }
1829
+
1830
+ function communityLabel(id) {
1831
+ return id.split('-').map((part) => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
1832
+ }
1833
+
1834
+ function addBounded(list, value, limit) {
1835
+ if (!value || list.includes(value)) return 0;
1836
+ if (list.length >= limit) return 1;
1837
+ list.push(value);
1838
+ return 0;
1839
+ }
1840
+
1841
+ function relationWeight(relation) {
1842
+ if (relation === 'implemented_by' || relation === 'verified_by') return 4;
1843
+ if (relation === 'includes_resource' || relation === 'routes_to' || relation === 'related_doc' || relation === 'verification_command') return 3;
1844
+ if (relation === 'links_to' || relation === 'belongs_to_area' || relation === 'declares_package' || relation === 'declares_bin' || relation === 'semantic_similarity') return 2;
1845
+ if (relation === 'mentions_package') return 1;
1846
+ return 1;
1847
+ }
1848
+
1849
+ function addCommunityDetails(community, node, itemLimit) {
1850
+ community.nodeCount += 1;
1851
+ const path = node.path ?? node.id?.replace(/^file:/, '').replace(/^test:/, '');
1852
+ if (node.type === 'file') {
1853
+ const area = docsArea(path);
1854
+ community.omitted += addBounded(area ? community.docs : community.files, path, itemLimit);
1855
+ } else if (node.type === 'heading' && node.path) community.omitted += addBounded(community.docs, node.path, itemLimit);
1856
+ else if (node.type === 'memory_area') community.omitted += addBounded(community.docs, `memory_area:${node.area}`, itemLimit);
1857
+ else if (node.type === 'command') community.omitted += addBounded(community.commands, node.name, itemLimit);
1858
+ else if (node.type === 'event') community.omitted += addBounded(community.events, node.name, itemLimit);
1859
+ else if (node.type === 'test') community.omitted += addBounded(community.tests, node.path, itemLimit);
1860
+ else if (node.type === 'package_resource') community.omitted += addBounded(community.packageResources, `${node.kind}:${node.target}`, itemLimit);
1861
+ }
1862
+
1863
+ function makeCommunity(id, label = communityLabel(id)) {
1864
+ return { id, label, files: [], docs: [], commands: [], events: [], tests: [], packageResources: [], nodeCount: 0, edgeCount: 0, omitted: 0 };
1865
+ }
1866
+
1867
+ function deterministicCommunities(graph, maxCommunities, itemLimit, fallback = false) {
1868
+ const map = new Map();
1869
+ for (const node of graph.nodes) {
1870
+ const id = communityKeyForNode(node);
1871
+ if (!map.has(id)) map.set(id, makeCommunity(id));
1872
+ addCommunityDetails(map.get(id), node, itemLimit);
1873
+ }
1874
+ const nodeToCommunity = new Map(graph.nodes.map((node) => [node.id, communityKeyForNode(node)]));
1875
+ for (const edge of graph.edges) {
1876
+ const source = nodeToCommunity.get(edge.source);
1877
+ const target = nodeToCommunity.get(edge.target);
1878
+ if (source && source === target && map.has(source)) map.get(source).edgeCount += 1;
1879
+ }
1880
+ const all = [...map.values()].sort((a, b) => (b.nodeCount + b.edgeCount) - (a.nodeCount + a.edgeCount) || a.id.localeCompare(b.id));
1881
+ return { communities: all.slice(0, maxCommunities), omitted: Math.max(0, all.length - maxCommunities), total: all.length, method: 'deterministic-domain-grouping', fallback };
1882
+ }
1883
+
1884
+ function buildLeidenProjection(graph) {
1885
+ const durable = graph.nodes.filter((node) => DURABLE_COMMUNITY_NODE_TYPES.has(node.type));
1886
+ const durableIds = new Set(durable.map((node) => node.id));
1887
+ const nodeIndex = new Map(durable.map((node, index) => [node.id, index]));
1888
+ const adjacency = new Map();
1889
+ const addProjectionEdge = (a, b, weight) => {
1890
+ if (a === b || !nodeIndex.has(a) || !nodeIndex.has(b)) return;
1891
+ const [source, target] = nodeIndex.get(a) < nodeIndex.get(b) ? [nodeIndex.get(a), nodeIndex.get(b)] : [nodeIndex.get(b), nodeIndex.get(a)];
1892
+ const key = `${source}:${target}`;
1893
+ adjacency.set(key, (adjacency.get(key) ?? 0) + weight);
1894
+ };
1895
+
1896
+ const edgesByNode = new Map();
1897
+ for (const edge of graph.edges) {
1898
+ if (!edgesByNode.has(edge.source)) edgesByNode.set(edge.source, []);
1899
+ if (!edgesByNode.has(edge.target)) edgesByNode.set(edge.target, []);
1900
+ edgesByNode.get(edge.source).push(edge);
1901
+ edgesByNode.get(edge.target).push(edge);
1902
+ if (durableIds.has(edge.source) && durableIds.has(edge.target)) addProjectionEdge(edge.source, edge.target, relationWeight(edge.relation));
1903
+ }
1904
+
1905
+ for (const node of graph.nodes) {
1906
+ if (durableIds.has(node.id)) continue;
1907
+ const neighbors = (edgesByNode.get(node.id) ?? []).map((edge) => edge.source === node.id ? edge.target : edge.source).filter((id) => durableIds.has(id));
1908
+ for (let i = 0; i < neighbors.length; i += 1) for (let j = i + 1; j < neighbors.length; j += 1) addProjectionEdge(neighbors[i], neighbors[j], 1);
1909
+ }
1910
+
1911
+ return { durable, edges: [...adjacency.entries()].map(([key, weight]) => [...key.split(':').map(Number), weight]) };
1912
+ }
1913
+
1914
+ function labelForLeidenCommunity(nodes) {
1915
+ const counts = new Map();
1916
+ for (const node of nodes) counts.set(communityKeyForNode(node), (counts.get(communityKeyForNode(node)) ?? 0) + 1);
1917
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? 'community';
1918
+ }
1919
+
1920
+ export function buildCommunities(index, limits = {}) {
1921
+ const graph = index?.graph ?? { nodes: [], edges: [] };
1922
+ const maxCommunities = limits.communities ?? 8;
1923
+ const itemLimit = limits.items ?? 8;
1924
+ const projection = buildLeidenProjection(graph);
1925
+ if (projection.durable.length < 3 || projection.edges.length === 0) return deterministicCommunities(graph, maxCommunities, itemLimit, true);
1926
+
1927
+ try {
1928
+ const result = leiden(Graph.fromEdgeList(projection.durable.length, projection.edges), { seed: 42, resolution: limits.resolution ?? 1.0 });
1929
+ const byCommunity = new Map();
1930
+ Array.from(result.partition.assignments).forEach((communityId, index) => {
1931
+ if (!byCommunity.has(communityId)) byCommunity.set(communityId, []);
1932
+ byCommunity.get(communityId).push(projection.durable[index]);
1933
+ });
1934
+ const communities = [...byCommunity.entries()].map(([communityId, nodes]) => {
1935
+ const labelKey = labelForLeidenCommunity(nodes);
1936
+ const community = makeCommunity(`leiden-${communityId}`, communityLabel(labelKey));
1937
+ for (const node of nodes.sort((a, b) => a.id.localeCompare(b.id))) addCommunityDetails(community, node, itemLimit);
1938
+ return community;
1939
+ });
1940
+ const nodeToCommunity = new Map();
1941
+ for (const [communityId, nodes] of byCommunity.entries()) for (const node of nodes) nodeToCommunity.set(node.id, communityId);
1942
+ for (const edge of graph.edges) {
1943
+ const source = nodeToCommunity.get(edge.source);
1944
+ const target = nodeToCommunity.get(edge.target);
1945
+ if (source !== undefined && source === target) communities.find((community) => community.id === `leiden-${source}`).edgeCount += 1;
1946
+ }
1947
+ const all = communities.sort((a, b) => (b.nodeCount + b.edgeCount) - (a.nodeCount + a.edgeCount) || a.id.localeCompare(b.id));
1948
+ return { communities: all.slice(0, maxCommunities), omitted: Math.max(0, all.length - maxCommunities), total: all.length, method: 'leiden', fallback: false, modularity: result.modularity };
1949
+ } catch {
1950
+ return deterministicCommunities(graph, maxCommunities, itemLimit, true);
1951
+ }
1952
+ }
1953
+
1954
+ export function detectPackageManager(root) {
1955
+ const packageFile = join(root, 'package.json');
1956
+ if (existsSync(packageFile)) {
1957
+ try {
1958
+ const packageJson = JSON.parse(readFileSync(packageFile, 'utf8'));
1959
+ if (typeof packageJson.packageManager === 'string' && packageJson.packageManager.trim()) {
1960
+ const [name] = packageJson.packageManager.split('@');
1961
+ if (name) return name;
1962
+ }
1963
+ } catch {}
1964
+ }
1965
+ if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm';
1966
+ if (existsSync(join(root, 'yarn.lock'))) return 'yarn';
1967
+ if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock'))) return 'bun';
1968
+ if (existsSync(join(root, 'package-lock.json')) || existsSync(join(root, 'npm-shrinkwrap.json'))) return 'npm';
1969
+ return 'npm';
1970
+ }
1971
+
1972
+ function hasCliDependency(packageJson) {
1973
+ return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
1974
+ .some((field) => packageJson?.[field] && Object.prototype.hasOwnProperty.call(packageJson[field], '@dotdotgod/cli'));
1975
+ }
1976
+
1977
+ function readRootPackageJson(root) {
1978
+ try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
1979
+ }
1980
+
1981
+ export function detectCommandGuidance(root) {
1982
+ const packageManager = detectPackageManager(root);
1983
+ const packageJson = readRootPackageJson(root);
1984
+ const hasLocalSource = existsSync(join(root, 'packages/cli/bin/dotdotgod.mjs')) && packageJson?.name === 'dotdotgod-workspace';
1985
+ const hasProjectInstall = hasCliDependency(packageJson) || existsSync(join(root, 'node_modules/.bin/dotdotgod'));
1986
+ const prefix = hasLocalSource ? 'node packages/cli/bin/dotdotgod.mjs' : 'npx dotdotgod';
1987
+ const source = hasLocalSource ? 'local-source' : hasProjectInstall ? 'project-install' : 'missing-install';
1988
+ return {
1989
+ source,
1990
+ packageManager,
1991
+ install: source === 'missing-install' ? 'npm install -D @dotdotgod/cli' : null,
1992
+ validate: source === 'local-source' ? `${prefix} validate . --include-local-memory` : `${prefix} validate .`,
1993
+ loadSnapshot: `${prefix} load-snapshot . --json`,
1994
+ index: `${prefix} index . --json`,
1995
+ status: `${prefix} status . --json`,
1996
+ verify: packageJson?.scripts?.verify ? `${packageManager} run verify` : null,
1997
+ };
1998
+ }
1999
+
2000
+ export function buildMemoryAreas(index, limits = {}) {
2001
+ const graph = index?.graph ?? { nodes: [], edges: [] };
2002
+ const itemLimit = limits.items ?? 4;
2003
+ const config = index?.memoryConfig?.areas ? index.memoryConfig : defaultMemoryConfig();
2004
+ const areas = new Map();
2005
+ for (const definition of config.areas ?? []) {
2006
+ areas.set(definition.id, { area: definition.id, label: definition.label, role: definition.role, scope: definition.scope, freshness: definition.freshness, priority: definition.priority, includeBodiesByDefault: definition.includeBodiesByDefault !== false, files: [], count: 0, omitted: 0 });
2007
+ }
2008
+ for (const node of graph.nodes) {
2009
+ if (node.type !== 'file') continue;
2010
+ const area = node.memoryArea ?? memoryAreaForPath(node.path, config);
2011
+ if (!area || !areas.has(area)) continue;
2012
+ const summary = areas.get(area);
2013
+ summary.count += 1;
2014
+ if (summary.files.length < itemLimit) summary.files.push(node.path);
2015
+ else summary.omitted += 1;
2016
+ }
2017
+ const all = [...areas.values()]
2018
+ .filter((area) => area.count > 0)
2019
+ .sort((a, b) => b.priority - a.priority || a.area.localeCompare(b.area));
2020
+ return { areas: all, total: all.length, omitted: 0, method: 'configured-path-classification', source: config.source ?? 'default' };
2021
+ }
2022
+
2023
+ function parseConfigOptions(argv, allowForce = false, usageKey = 'config') {
2024
+ const options = { root: '.', json: false, force: false };
2025
+ let rootSet = false;
2026
+ for (let i = 0; i < argv.length; i += 1) {
2027
+ const arg = argv[i];
2028
+ if (arg === '--json') options.json = true;
2029
+ else if (allowForce && arg === '--force') options.force = true;
2030
+ else if (!arg.startsWith('-') && !rootSet) {
2031
+ options.root = arg;
2032
+ rootSet = true;
2033
+ } else if (!arg.startsWith('-')) usage(`Unexpected argument: ${arg}`, usageKey);
2034
+ else usage(`Unknown option: ${arg}`, usageKey);
2035
+ }
2036
+ options.root = resolve(options.root);
2037
+ return options;
2038
+ }
2039
+
2040
+ function configSourcePath(root, source) {
2041
+ return source && source !== 'default' ? join(root, source) : null;
2042
+ }
2043
+
2044
+ function configInitError(options, code, message, path = null) {
2045
+ const payload = { ok: false, command: 'config init', root: options.root, path, created: false, overwritten: false, error: { code, message } };
2046
+ if (options.json) {
2047
+ console.log(JSON.stringify(payload, null, 2));
2048
+ process.exit(2);
2049
+ }
2050
+ console.error(message);
2051
+ process.exit(2);
2052
+ }
2053
+
2054
+ function formatConfigOutput(payload) {
2055
+ const errors = payload.errors ?? [];
2056
+ const lines = [`dotdotgod config: ${payload.source}${errors.length > 0 ? ' (invalid; using defaults)' : ''}`];
2057
+ lines.push(`- path: ${payload.path ?? 'none'}`);
2058
+ lines.push(`- memory areas: ${payload.config.areas.length}`);
2059
+ lines.push(`- traceability required: ${(payload.config.traceability.required ?? []).join(', ') || 'none'}`);
2060
+ lines.push(`- traceability exclude: ${(payload.config.traceability.exclude ?? []).join(', ') || 'none'}`);
2061
+ lines.push(`- validation markdown: maxLines=${payload.config.validation.markdown.maxLines}, maxChars=${payload.config.validation.markdown.maxChars}`);
2062
+ lines.push(`- validation markdown exclude: ${(payload.config.validation.markdown.exclude ?? []).join(', ') || 'none'}`);
2063
+ lines.push(`- impact ranking preset: ${payload.config.impactRanking.preset}`);
2064
+ const lowSignal = payload.config.referenceExpansion?.fuzzy?.lowSignal ?? { terms: [], add: [], remove: [] };
2065
+ lines.push(`- fuzzy low-signal terms: ${(lowSignal.terms ?? []).join(', ') || 'none'} (add: ${(lowSignal.add ?? []).join(', ') || 'none'}; remove: ${(lowSignal.remove ?? []).join(', ') || 'none'})`);
2066
+ if (errors.length > 0) {
2067
+ lines.push('- errors:');
2068
+ for (const error of errors) lines.push(` - ${error.code} ${error.file}: ${error.message}`);
2069
+ }
2070
+ return lines.join('\n');
2071
+ }
2072
+
2073
+ export function runConfig(argv) {
2074
+ const isInit = argv[0] === 'init';
2075
+ const options = parseConfigOptions(isInit ? argv.slice(1) : argv, isInit, isInit ? 'config init' : 'config');
2076
+ if (isInit) {
2077
+ if (!existsSync(options.root)) configInitError(options, 'ROOT_NOT_FOUND', `Project root not found: ${options.root}`);
2078
+ try {
2079
+ if (!statSync(options.root).isDirectory()) configInitError(options, 'ROOT_NOT_DIRECTORY', `Project root is not a directory: ${options.root}`);
2080
+ } catch {
2081
+ configInitError(options, 'ROOT_NOT_FOUND', `Project root not found: ${options.root}`);
2082
+ }
2083
+ const target = join(options.root, 'dotdotgod.config.json');
2084
+ const rcPath = join(options.root, '.dotdotgodrc.json');
2085
+ if (existsSync(rcPath)) configInitError(options, 'CONFIG_RC_EXISTS', `.dotdotgodrc.json already exists; remove or migrate it before initializing dotdotgod.config.json.`, rcPath);
2086
+ const existed = existsSync(target);
2087
+ if (existed && !options.force) configInitError(options, 'CONFIG_EXISTS', `dotdotgod.config.json already exists. Re-run with --force to overwrite it.`, target);
2088
+ writeFileSync(target, defaultDotdotgodConfigText());
2089
+ const payload = { ok: true, command: 'config init', root: options.root, path: target, created: !existed, overwritten: existed };
2090
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
2091
+ else console.log(`dotdotgod config init: ${existed ? 'overwrote' : 'created'} ${target}`);
2092
+ return;
2093
+ }
2094
+
2095
+ const config = readMemoryConfig(options.root);
2096
+ const errors = config.errors ?? [];
2097
+ const payload = { ok: errors.length === 0, command: 'config', root: options.root, source: config.source ?? 'default', path: configSourcePath(options.root, config.source), config: memoryConfigSummary(config), errors };
2098
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
2099
+ else console.log(formatConfigOutput(payload));
2100
+ if (errors.length > 0) process.exit(1);
2101
+ }
2102
+
2103
+ export function runLoadSnapshot(argv) {
2104
+ const options = parseCommon(argv);
2105
+ const { status, index, metadata } = readFreshIndex(options.root);
2106
+ const summary = graphSummary(index);
2107
+ const communities = buildCommunities(index, { communities: 5, items: 5 });
2108
+ const memoryAreas = buildMemoryAreas(index, { items: 4 });
2109
+ const memoryConfig = index.memoryConfig ?? memoryConfigSummary(readMemoryConfig(options.root));
2110
+ const memoryPolicy = {
2111
+ source: memoryConfig.source ?? 'default',
2112
+ sharedAreas: (memoryConfig.areas ?? []).filter((area) => area.scope === 'shared').map((area) => area.id),
2113
+ localAreas: (memoryConfig.areas ?? []).filter((area) => area.scope === 'local').map((area) => area.id),
2114
+ freshAreas: (memoryConfig.areas ?? []).filter((area) => area.freshness === 'fresh').map((area) => area.id),
2115
+ staleAreas: (memoryConfig.areas ?? []).filter((area) => area.freshness === 'stale').map((area) => area.id),
2116
+ };
2117
+ const bounds = { communities: 5, communityItems: 5, memoryAreaItems: 4, fullGraphIncluded: false, archiveBodiesIncluded: status.archiveBodiesIncluded, archiveMapIncluded: true };
2118
+ const quality = {
2119
+ indexedFiles: status.indexedFiles,
2120
+ currentFiles: status.currentFiles,
2121
+ shownCommunities: communities.communities.length,
2122
+ totalCommunities: communities.total,
2123
+ omittedCommunities: communities.omitted,
2124
+ omittedCommunityItems: communities.communities.reduce((sum, community) => sum + (community.omitted ?? 0), 0),
2125
+ memoryAreas: memoryAreas.total,
2126
+ omittedMemoryAreaItems: memoryAreas.areas.reduce((sum, area) => sum + (area.omitted ?? 0), 0),
2127
+ graphNodes: summary.nodes,
2128
+ graphEdges: summary.edges,
2129
+ };
2130
+ const commandGuidance = detectCommandGuidance(options.root);
2131
+ let payload = { root: options.root, cache: status, metadata, graph: summary, memoryConfig, memoryPolicy, memoryAreas, communities, bounds, quality, commandGuidance };
2132
+ const serialized = JSON.stringify(payload);
2133
+ payload = { ...payload, quality: { ...quality, snapshotBytes: Buffer.byteLength(serialized), approxSnapshotTokens: Math.ceil(serialized.length / 4) } };
2134
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
2135
+ else console.log(`dotdotgod load snapshot\n- cache: ${status.status}${metadata.cacheRefreshed ? ' (refreshed)' : ''}\n- indexed files: ${status.indexedFiles}\n- current files: ${status.currentFiles}\n- archive bodies included: ${status.archiveBodiesIncluded ? 'yes' : 'no'}\n- graph: ${summary.nodes} nodes, ${summary.edges} edges\n- memory areas: ${memoryAreas.areas.length}/${memoryAreas.total} shown\n- communities: ${communities.communities.length}/${communities.total} shown, ${communities.omitted} omitted`);
2136
+ }
2137
+
2138
+ const DEFAULT_REFERENCE_LIMIT = 5;
2139
+
2140
+ export function extractBracketReferences(prompt = '') {
2141
+ const refs = [];
2142
+ const seen = new Set();
2143
+ for (const match of String(prompt).matchAll(/\[\[([^\]]+)\]\]/g)) {
2144
+ const raw = match[1].trim();
2145
+ if (!raw) continue;
2146
+ const target = raw.split('|')[0].trim();
2147
+ if (!target || seen.has(target)) continue;
2148
+ seen.add(target);
2149
+ refs.push({ raw: match[0], target, label: raw.includes('|') ? raw.split('|').slice(1).join('|').trim() : undefined });
2150
+ }
2151
+ return refs;
2152
+ }
2153
+
2154
+ function fuzzyLowSignalSet(policy = defaultMemoryConfig().referenceExpansion) {
2155
+ return new Set(cloneReferenceExpansionPolicy(policy).fuzzy.lowSignal.terms);
2156
+ }
2157
+
2158
+ function fuzzyPhraseAllowed(value = '', lowSignalTerms = fuzzyLowSignalSet()) {
2159
+ const normalized = normalizeLowSignalTerm(value);
2160
+ if (normalized.length < 3) return false;
2161
+ if (lowSignalTerms.has(normalized)) return false;
2162
+ return /[a-z0-9가-힣]/i.test(normalized);
2163
+ }
2164
+
2165
+ function fuzzyTokenKey(value = '') {
2166
+ return String(value).trim().replace(/\s+/g, ' ').toLowerCase();
2167
+ }
2168
+
2169
+ export function normalizeReferenceAlias(value = '') {
2170
+ return String(value)
2171
+ .trim()
2172
+ .replace(/^\[\[/, '')
2173
+ .replace(/\]\]$/, '')
2174
+ .split('|')[0]
2175
+ .trim()
2176
+ .replace(/\\/g, '/')
2177
+ .replace(/^\.\//, '')
2178
+ .replace(/\.md$/i, '')
2179
+ .toLowerCase()
2180
+ .replace(/[\s_-]+/g, '')
2181
+ .replace(/[^a-z0-9/#.]/g, '');
2182
+ }
2183
+
2184
+ function referencePathForNode(node) {
2185
+ if (node?.path) return node.path;
2186
+ if (typeof node?.id === 'string' && node.id.startsWith('file:')) return node.id.slice(5);
2187
+ if (typeof node?.id === 'string' && node.id.startsWith('heading:')) return node.id.slice(8).split('#')[0];
2188
+ return '';
2189
+ }
2190
+
2191
+ function isArchiveBodyPath(path = '') {
2192
+ return path.startsWith('docs/archive/') && path !== 'docs/archive/README.md';
2193
+ }
2194
+
2195
+ function aliasEntriesForPath(path = '') {
2196
+ const entries = [];
2197
+ const base = basename(path);
2198
+ const ext = extname(base);
2199
+ const stem = ext ? base.slice(0, -ext.length) : base;
2200
+ const withoutMd = path.replace(/\.md$/i, '');
2201
+ const parts = withoutMd.split('/').filter(Boolean);
2202
+ for (let i = 0; i < parts.length; i += 1) entries.push({ alias: parts.slice(i).join('/'), kind: 'path-suffix' });
2203
+ if (base === 'README.md') {
2204
+ const dir = dirname(path).replace(/\\/g, '/');
2205
+ entries.push({ alias: basename(dir), kind: 'path' });
2206
+ }
2207
+ for (const alias of [path, withoutMd, base, stem]) entries.push({ alias, kind: 'path' });
2208
+ return entries.filter((entry) => entry.alias);
2209
+ }
2210
+
2211
+ function referenceCandidateAliases(node) {
2212
+ const path = referencePathForNode(node);
2213
+ const entries = [];
2214
+ if (node.type === 'file' && path) entries.push(...aliasEntriesForPath(path));
2215
+ if (node.type === 'heading') {
2216
+ const fileStem = path ? basename(path, extname(path)) : '';
2217
+ const anchor = typeof node.id === 'string' && node.id.includes('#') ? node.id.split('#').pop() : '';
2218
+ for (const alias of [node.title, anchor, fileStem && node.title ? `${fileStem}#${node.title}` : '', fileStem && anchor ? `${fileStem}#${anchor}` : '', path && node.title ? `${path}#${node.title}` : '', path && anchor ? `${path}#${anchor}` : '']) entries.push({ alias, kind: 'heading' });
2219
+ }
2220
+ return entries.filter((entry) => entry.alias);
2221
+ }
2222
+
2223
+ export function extractFuzzyReferences(prompt = '', index = null, options = {}) {
2224
+ const text = String(prompt ?? '');
2225
+ const masked = text.replace(/\[\[[^\]\n]+\]\]/g, ' ');
2226
+ const seen = new Set((options.existingTargets ?? []).map((value) => fuzzyTokenKey(value)));
2227
+ const lowSignalTerms = fuzzyLowSignalSet(options.referenceExpansion ?? options.memoryConfig?.referenceExpansion ?? index?.memoryConfig?.referenceExpansion);
2228
+ const refs = [];
2229
+ const add = (target, reason, confidence = 'medium') => {
2230
+ const clean = String(target ?? '').trim().replace(/^['"`]+|['"`]+$/g, '').replace(/\s+/g, ' ');
2231
+ if (!fuzzyPhraseAllowed(clean, lowSignalTerms)) return;
2232
+ const key = fuzzyTokenKey(clean);
2233
+ if (seen.has(key)) return;
2234
+ seen.add(key);
2235
+ refs.push({ raw: clean, target: clean, source: 'fuzzy', confidence, reasons: [reason] });
2236
+ };
2237
+
2238
+ for (const match of masked.matchAll(/(?:^|\s)([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?:#[A-Za-z0-9 _-]+)?|[A-Z0-9]{3,})(?=$|\s|[.,:;!?])/g)) add(match[1], 'uppercase_identifier', 'high');
2239
+ for (const match of masked.matchAll(/(?:^|\s)((?:\.?\/?(?:docs|packages|src|test|spec|arch|plan|archive)\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+(?:\.md)?(?:#[A-Za-z0-9 _-]+)?)(?=$|\s|[.,:;!?])/g)) add(match[1].replace(/^\.\//, ''), 'path_like', 'high');
2240
+ for (const match of masked.matchAll(/[`"']([^`"'\n]{4,80})[`"']/g)) add(match[1], 'quoted_phrase', 'medium');
2241
+
2242
+ const graph = index?.graph ?? null;
2243
+ if (graph) {
2244
+ const lower = ` ${masked.toLowerCase().replace(/[^a-z0-9가-힣/#._-]+/gi, ' ')} `;
2245
+ const aliasByKey = new Map();
2246
+ for (const node of graph.nodes ?? []) {
2247
+ if (!['file', 'heading'].includes(node.type)) continue;
2248
+ const path = referencePathForNode(node);
2249
+ if (options.includeArchive !== true && isArchiveBodyPath(path)) continue;
2250
+ for (const entry of referenceCandidateAliases(node)) {
2251
+ const alias = String(entry.alias ?? '').replace(/\.md$/i, '').replace(/[_-]+/g, ' ').trim();
2252
+ if (!fuzzyPhraseAllowed(alias, lowSignalTerms)) continue;
2253
+ const words = alias.split(/[\s/]+/).filter(Boolean);
2254
+ if (words.length > 3 || words.some((word) => lowSignalTerms.has(normalizeLowSignalTerm(word)))) continue;
2255
+ const key = fuzzyTokenKey(alias);
2256
+ const previous = aliasByKey.get(key);
2257
+ const priority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
2258
+ if (!previous || priority > previous.priority) aliasByKey.set(key, { alias, priority });
2259
+ }
2260
+ }
2261
+ for (const { alias } of [...aliasByKey.values()].sort((a, b) => b.priority - a.priority || b.alias.length - a.alias.length)) {
2262
+ const escaped = alias.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '[\\s_-]+');
2263
+ const pattern = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i');
2264
+ if (pattern.test(lower)) add(alias, 'known_alias', 'medium');
2265
+ if (refs.length >= (options.maxFuzzyRefs ?? 5)) break;
2266
+ }
2267
+ }
2268
+ return refs.slice(0, options.maxFuzzyRefs ?? 5);
2269
+ }
2270
+
2271
+ function incomingRouteBonus(graph, nodeId) {
2272
+ return graph.edges.some((edge) => edge.target === nodeId && edge.relation === 'routes_to') ? 6 : 0;
2273
+ }
2274
+
2275
+ function exactReferenceScore(ref, alias, kind) {
2276
+ const rawRef = String(ref).trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim().replace(/\\/g, '/').replace(/^\.\//, '');
2277
+ const rawAlias = String(alias).trim().replace(/\\/g, '/').replace(/^\.\//, '');
2278
+ if (rawRef === rawAlias) return kind === 'heading' ? 92 : 110;
2279
+ if (rawRef.toLowerCase() === rawAlias.toLowerCase()) return kind === 'heading' ? 88 : 105;
2280
+ return 0;
2281
+ }
2282
+
2283
+ export function resolveReferenceCandidates(index, ref, options = {}) {
2284
+ const graph = index?.graph ?? { nodes: [], edges: [] };
2285
+ const includeArchive = options.includeArchive === true;
2286
+ const limit = Number.isFinite(options.maxResults) ? options.maxResults : DEFAULT_REFERENCE_LIMIT;
2287
+ const query = String(ref ?? '').trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim();
2288
+ const normalized = normalizeReferenceAlias(query);
2289
+ const byId = new Map();
2290
+ for (const node of graph.nodes ?? []) {
2291
+ if (!['file', 'heading'].includes(node.type)) continue;
2292
+ const path = referencePathForNode(node);
2293
+ if (!includeArchive && isArchiveBodyPath(path)) continue;
2294
+ let best = null;
2295
+ for (const entry of referenceCandidateAliases(node)) {
2296
+ const aliasKey = normalizeReferenceAlias(entry.alias);
2297
+ if (!aliasKey) continue;
2298
+ let score = exactReferenceScore(query, entry.alias, entry.kind);
2299
+ if (aliasKey === normalized) score = Math.max(score, entry.kind === 'heading' ? 86 : 96);
2300
+ else if (aliasKey.endsWith(normalized) && normalized.length >= 4) score = Math.max(score, entry.kind === 'heading' ? 62 : 70);
2301
+ if (score <= 0) continue;
2302
+ if (!best || score > best.score) best = { score, alias: entry.alias, aliasKind: entry.kind };
2303
+ }
2304
+ if (!best) continue;
2305
+ const retrievalPriority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
2306
+ const routeBonus = incomingRouteBonus(graph, node.id);
2307
+ const memoryBonus = Math.min(12, Math.max(0, retrievalPriority / 10));
2308
+ const headingBonus = node.type === 'heading' ? 2 : 0;
2309
+ const score = Number((best.score + routeBonus + memoryBonus + headingBonus).toFixed(1));
2310
+ const reasons = [best.aliasKind === 'heading' ? 'heading_alias' : 'path_alias'];
2311
+ if (routeBonus) reasons.push('readme_routed');
2312
+ if (memoryBonus) reasons.push('memory_priority');
2313
+ const candidate = { id: node.id, type: node.type, path, title: node.title, score, matchedAlias: best.alias, reasons, retrieval: node.retrieval, memoryArea: node.memoryArea };
2314
+ const previous = byId.get(node.id);
2315
+ if (!previous || candidate.score > previous.score) byId.set(node.id, candidate);
2316
+ }
2317
+ const candidates = [...byId.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
2318
+ const shown = candidates.slice(0, limit);
2319
+ return { input: ref, query, normalized, candidates: shown, top: shown[0] ?? null, ambiguous: shown.length > 1 && shown[0].score - shown[1].score < 5, omitted: Math.max(0, candidates.length - shown.length) };
2320
+ }
2321
+
2322
+ function parseReferenceOptions(argv, command) {
2323
+ const filtered = [];
2324
+ const options = { maxResults: DEFAULT_REFERENCE_LIMIT, includeArchive: false, withImpact: false, fuzzy: false };
2325
+ for (let i = 0; i < argv.length; i += 1) {
2326
+ const arg = argv[i];
2327
+ if (arg === '--max-results') {
2328
+ const next = argv[i + 1];
2329
+ if (!next || next.startsWith('-') || Number.isNaN(Number.parseInt(next, 10))) usage('Missing or invalid value for --max-results.', command);
2330
+ options.maxResults = Math.max(1, Number.parseInt(next, 10));
2331
+ i += 1;
2332
+ } else if (arg === '--include-archive') options.includeArchive = true;
2333
+ else if (arg === '--with-impact') options.withImpact = true;
2334
+ else if (arg === '--fuzzy') {
2335
+ if (command !== 'expand') usage(`Unknown option: ${arg}`, command);
2336
+ options.fuzzy = true;
2337
+ }
2338
+ else if (arg === '--json') filtered.push(arg);
2339
+ else if (arg.startsWith('-')) usage(`Unknown option: ${arg}`, command);
2340
+ else filtered.push(arg);
2341
+ }
2342
+ const operands = filtered.filter((arg) => !arg.startsWith('-'));
2343
+ return { ...options, root: resolve(operands[0] ?? '.'), json: filtered.includes('--json'), rootArgv: operands.slice(1) };
2344
+ }
2345
+
2346
+ function attachImpactToRef(index, refResult) {
2347
+ const topPath = refResult.top?.path;
2348
+ if (!topPath) return refResult;
2349
+ const impact = buildCompactImpactReport(buildImpactReport(index, topPath));
2350
+ return { ...refResult, impact: { changed: topPath, related: impact.related, groups: impact.groups, omittedRelated: impact.omittedRelated, quality: impact.quality } };
2351
+ }
2352
+
2353
+ function formatReferenceOutput(payload) {
2354
+ const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
2355
+ const lines = [`${payload.command}: ${payload.refs.length} reference(s) (${payload.status.status}${refreshNote} index)`];
2356
+ for (const ref of payload.refs) {
2357
+ const marker = ref.ambiguous ? ' ambiguous' : '';
2358
+ lines.push(`- ${ref.query || ref.input}:${marker} ${ref.candidates.length} candidate(s), ${ref.omitted} omitted`);
2359
+ for (const candidate of ref.candidates) {
2360
+ const title = candidate.title ? `#${candidate.title}` : '';
2361
+ lines.push(` - ${candidate.path}${title} (${candidate.score}; ${candidate.reasons.join(', ')})`);
2362
+ }
2363
+ }
2364
+ return lines.join('\n');
2365
+ }
2366
+
2367
+ export function runResolve(argv) {
2368
+ const options = parseReferenceOptions(argv, 'resolve');
2369
+ const ref = options.rootArgv?.join(' ');
2370
+ if (!ref) usage('Missing required argument: <ref>.', 'resolve');
2371
+ const { status, index, metadata } = readFreshIndex(options.root);
2372
+ const refs = [resolveReferenceCandidates(index, ref, options)];
2373
+ const payload = { ok: status.ok, command: 'resolve', root: options.root, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
2374
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
2375
+ else console.log(formatReferenceOutput(payload));
2376
+ }
2377
+
2378
+ export function runExpand(argv) {
2379
+ const options = parseReferenceOptions(argv, 'expand');
2380
+ const prompt = options.rootArgv?.join(' ');
2381
+ if (!prompt) usage('Missing required argument: <prompt>.', 'expand');
2382
+ const refsInPrompt = extractBracketReferences(prompt);
2383
+ if (refsInPrompt.length === 0 && !options.fuzzy) usage('No [[refs]] found in prompt.', 'expand');
2384
+ const { status, index, metadata } = readFreshIndex(options.root);
2385
+ const fuzzyRefs = options.fuzzy ? extractFuzzyReferences(prompt, index, { ...options, memoryConfig: readMemoryConfig(options.root), existingTargets: refsInPrompt.map((item) => item.target) }) : [];
2386
+ let refs = [
2387
+ ...refsInPrompt.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'explicit', raw: item.raw, label: item.label })),
2388
+ ...fuzzyRefs.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'fuzzy', raw: item.raw, confidence: item.confidence, reasons: item.reasons })),
2389
+ ];
2390
+ if (options.withImpact) refs = refs.map((item) => attachImpactToRef(index, item));
2391
+ const payload = { ok: status.ok, command: 'expand', root: options.root, prompt, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
2392
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
2393
+ else console.log(formatReferenceOutput(payload));
2394
+ }
2395
+
2396
+ export function parseGraphOptions(argv) {
2397
+ const filtered = [];
2398
+ let changed;
2399
+ let compact = false;
2400
+ for (let i = 0; i < argv.length; i += 1) {
2401
+ if (argv[i] === '--changed') {
2402
+ const next = argv[i + 1];
2403
+ if (next && !next.startsWith('-')) {
2404
+ changed = next;
2405
+ i += 1;
2406
+ }
2407
+ } else if (argv[i] === '--compact') compact = true;
2408
+ else filtered.push(argv[i]);
2409
+ }
2410
+ const options = parseCommon(filtered);
2411
+ options.changed = changed;
2412
+ options.compact = compact;
2413
+ return options;
2414
+ }
2415
+
2416
+ export function runGraph(argv) {
2417
+ const sub = argv[0];
2418
+ const isImpact = sub === 'impact';
2419
+ if (!['impact', 'communities'].includes(sub)) usage(sub ? `Unknown graph command: ${sub}` : 'Missing graph command.', 'graph');
2420
+ const options = parseGraphOptions(argv.slice(1));
2421
+ if (isImpact && !options.changed) {
2422
+ const message = 'Missing required option: --changed <path>. Run `dotdotgod graph impact <root> --changed <path>`.';
2423
+ if (options.json) {
2424
+ console.log(JSON.stringify({ ok: false, command: 'graph impact', compact: options.compact || undefined, root: options.root, error: { code: 'MISSING_CHANGED', message }, usage: commandUsage('graph impact') }, null, 2));
2425
+ process.exit(2);
2426
+ }
2427
+ usage(message, 'graph impact');
2428
+ }
2429
+ const { status, index, metadata } = readFreshIndex(options.root);
2430
+ const rawImpact = isImpact ? buildImpactReport(index, options.changed) : undefined;
2431
+ const impact = isImpact && options.compact ? buildCompactImpactReport(rawImpact) : rawImpact;
2432
+ const payload = isImpact
2433
+ ? { ok: status.ok, command: 'graph impact', compact: options.compact || undefined, root: options.root, status, metadata, changed: options.changed, related: impact.related, impact }
2434
+ : { ok: status.ok, command: 'graph communities', root: options.root, status, metadata, graph: graphSummary(index), communities: buildCommunities(index) };
2435
+ const refreshNote = metadata.cacheRefreshed ? ', refreshed' : '';
2436
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
2437
+ else if (isImpact && options.compact) console.log(formatCompactImpactOutput(payload, impact));
2438
+ else if (isImpact) console.log(`graph impact: ${payload.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${status.status}${refreshNote} index)`);
2439
+ else console.log(`graph communities: ${payload.communities.communities.length}/${payload.communities.total} shown, ${payload.communities.omitted} omitted (${status.status}${refreshNote} index)`);
2440
+ }
2441
+
2442
+ export function runCli(argv = process.argv.slice(2)) {
2443
+ const [command = 'help', ...args] = argv;
2444
+ if (isVersionToken(command)) printVersion();
2445
+ if (command === 'help') usage('', helpCommandFromArgs(args));
2446
+ if (isHelpToken(command)) usage('');
2447
+ if (hasHelpToken(args)) usage('', helpCommandFromArgs([command, ...args]));
2448
+ if (command === 'validate') runValidate(args);
2449
+ else if (command === 'init') runInit(args, usage);
2450
+ else if (command === 'index') runIndex(args);
2451
+ else if (command === 'config') runConfig(args);
2452
+ else if (command === 'status') runStatus(args);
2453
+ else if (command === 'load-snapshot') runLoadSnapshot(args);
2454
+ else if (command === 'resolve') runResolve(args);
2455
+ else if (command === 'expand') runExpand(args);
2456
+ else if (command === 'graph') runGraph(args);
2457
+ else usage(`Unknown command: ${command}`);
2458
+ }