@dotdotgod/cli 0.1.35 → 0.1.36

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 CHANGED
@@ -1,1511 +1,48 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
- import { spawnSync } from 'node:child_process';
4
- import { basename, dirname, extname, join, relative, resolve } from 'node:path';
5
1
  import { runInit } from './init.mjs';
6
- import { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, parseCommon, printVersion, usage } from './cli/usage.mjs';
7
- import { rel } from './common/paths.mjs';
8
- import { extractAnchors, extractLinks, headingToAnchor, isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from './docs/markdown.mjs';
9
- import { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
10
- import { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, validateMemoryConfigData } from './memory/config.mjs';
11
- import { buildCommunities, relationWeight } from './graph/communities.mjs';
12
- import { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
13
- import { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
14
- import { buildMemoryAreas, detectCommandGuidance, detectPackageManager } from './load-snapshot/summary.mjs';
15
-
2
+ import { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, printVersion, usage } from './cli/usage.mjs';
3
+ import { runValidate } from './validate/run.mjs';
4
+ import { runIndex, runStatus } from './index/cache.mjs';
5
+ import { runConfig } from './commands/config.mjs';
6
+ import { runLoadSnapshot } from './commands/load-snapshot.mjs';
7
+ import { runResolve, runExpand } from './reference/resolve.mjs';
8
+ import { runTraceability } from './commands/traceability.mjs';
9
+ import { runGraph } from './commands/graph.mjs';
10
+ import { runPlan } from './commands/plan.mjs';
11
+ import { runTrelloSync } from './trello/sync.mjs';
12
+
13
+ export { CACHE_VERSION } from './index/constants.mjs';
16
14
  export { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, parseCommon, printVersion, usage } from './cli/usage.mjs';
17
15
  export { rel } from './common/paths.mjs';
18
16
  export { extractAnchors, extractLinks, headingToAnchor, isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from './docs/markdown.mjs';
19
17
  export { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
20
- export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, validateMemoryConfigData } from './memory/config.mjs';
18
+ export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_INTEGRATIONS_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneIntegrationsPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, trelloSyncPaths, validateMemoryConfigData } from './memory/config.mjs';
21
19
  export { buildCommunities, relationWeight } from './graph/communities.mjs';
22
20
  export { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
23
21
  export { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
24
22
  export { buildMemoryAreas, detectCommandGuidance, detectPackageManager } from './load-snapshot/summary.mjs';
25
- export const CACHE_VERSION = 11;
26
- const CACHE_DIR = '.dotdotgod';
27
- const MANIFEST_FILE = 'manifest.json';
28
- export function runValidate(argv) {
29
- const options = { root: '.', includeLocalMemory: false, checkIndex: false, maxLines: null, maxChars: null, linkCheck: true, json: false };
30
- for (let i = 0; i < argv.length; i += 1) {
31
- const arg = argv[i];
32
- if (arg === '--include-local-memory') options.includeLocalMemory = true;
33
- else if (arg === '--check-index') options.checkIndex = true;
34
- else if (arg === '--max-lines') options.maxLines = Number(argv[++i]);
35
- else if (arg === '--max-chars') options.maxChars = Number(argv[++i]);
36
- else if (arg === '--no-link-check') options.linkCheck = false;
37
- else if (arg === '--json') options.json = true;
38
- else if (!arg.startsWith('-')) options.root = arg;
39
- else usage(`Unknown option: ${arg}`, 'validate');
40
- }
41
-
42
- const root = resolve(options.root);
43
- const docs = join(root, 'docs');
44
- const errors = [];
45
- const markdownFiles = [];
46
- const fileCache = new Map();
47
- const addError = (file, code, message, line = null, fix = null) => errors.push({ file: rel(root, file), line, code, message: fix ? `${message}\nFix: ${fix}` : message });
48
- const shouldSkipDir = (dir) => {
49
- const path = rel(root, dir);
50
- if (!path || path === '.') return false;
51
- if (path.includes('/node_modules') || path === 'node_modules') return true;
52
- if (path.includes('/.git') || path === '.git') return true;
53
- if (path === CACHE_DIR || path.startsWith(`${CACHE_DIR}/`)) return true;
54
- if (!options.includeLocalMemory && (path === 'docs/plan' || path.startsWith('docs/plan/') || path === 'docs/archive' || path.startsWith('docs/archive/'))) return true;
55
- return false;
56
- };
57
- const walk = (dir) => {
58
- if (!existsSync(dir) || shouldSkipDir(dir)) return;
59
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
60
- const path = join(dir, entry.name);
61
- if (entry.isDirectory()) {
62
- const docsRel = rel(docs, path);
63
- if (docsRel && !docsRel.startsWith('..')) {
64
- 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.');
65
- }
66
- walk(path);
67
- } else if (entry.isFile() && entry.name.endsWith('.md')) markdownFiles.push(path);
68
- }
69
- };
70
-
71
- if (!existsSync(docs)) usage(`docs directory not found: ${docs}`);
72
- const memoryConfig = readMemoryConfig(root);
73
- const validationPolicy = cloneValidationPolicy(memoryConfig.validation ?? DEFAULT_VALIDATION_POLICY);
74
- const maxLines = options.maxLines ?? validationPolicy.markdown.maxLines;
75
- const maxChars = options.maxChars ?? validationPolicy.markdown.maxChars;
76
- for (const error of memoryConfig.errors ?? []) errors.push(error);
77
- walk(docs);
78
- for (const file of markdownFiles) {
79
- const name = basename(file);
80
- const docsRel = rel(docs, file);
81
- 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.');
82
- const content = readFileSync(file, 'utf8');
83
- fileCache.set(file, content);
84
- const budgetContent = stripTraceabilityLinksRegion(content);
85
- const lines = budgetContent.split('\n').length;
86
- const chars = budgetContent.length;
87
- const path = rel(root, file);
88
- const skipSizeChecks = isMarkdownSizeExcluded(path, memoryConfig);
89
- if (!skipSizeChecks && lines > maxLines) addError(file, 'FILE_TOO_LONG', `Markdown file has ${lines} lines excluding generated traceability links and traceability JSON blocks; 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.');
90
- if (!skipSizeChecks && chars > maxChars) addError(file, 'FILE_TOO_LARGE', `Markdown file has ${chars} characters excluding generated traceability links and traceability JSON blocks; 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.');
91
- if (requiresTraceability(rel(root, file), memoryConfig)) {
92
- const blocks = extractDotdotgodTraceabilityBlocks(content);
93
- 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()}`);
94
- else for (const error of validateTraceabilityPlacement(content, root, file)) errors.push(error);
95
- for (const error of validateTraceabilityLinksRegion(content, root, file)) errors.push(error);
96
- for (const block of blocks) {
97
- 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);
98
- else {
99
- for (const error of validateTraceabilityBlock(block.data, root, file, block.line, memoryConfig)) errors.push(error);
100
- const synced = syncTraceabilityLinksInContent(content, block.data, root, file);
101
- if (synced.ok && synced.changed) addError(file, 'TRACEABILITY_LINKS_STALE', 'Generated traceability links or compact traceability JSON are missing or stale.', block.line, 'run `dotdotgod traceability links <root> --write` to synchronize the generated Markdown links and compact JSON block.');
102
- }
103
- }
104
- }
105
- }
106
- const byDir = new Map();
107
- for (const file of markdownFiles) byDir.set(dirname(file), [...(byDir.get(dirname(file)) ?? []), file]);
108
- 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.');
109
- if (options.linkCheck) {
110
- for (const [file, content] of fileCache) {
111
- const fileDir = dirname(file);
112
- for (const { href, line } of extractLinks(content)) {
113
- const hashIndex = href.indexOf('#');
114
- const pathPart = hashIndex === -1 ? href : href.slice(0, hashIndex);
115
- const anchor = hashIndex === -1 ? '' : href.slice(hashIndex + 1);
116
- const target = pathPart ? resolve(fileDir, pathPart) : file;
117
- if (pathPart && !existsSync(target)) {
118
- 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.');
119
- continue;
120
- }
121
- if (anchor && extname(target) === '.md') {
122
- const targetContent = fileCache.get(target) ?? (existsSync(target) ? readFileSync(target, 'utf8') : '');
123
- 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.');
124
- }
125
- }
126
- }
127
- }
128
- if (options.checkIndex) {
129
- const index = readIndex(root);
130
- if (!index) {
131
- 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`.');
132
- } else {
133
- const schemaVersion = index.schemaVersion ?? index.version ?? null;
134
- 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.');
135
- const indexed = new Map((index.files ?? []).map((file) => [file.path, file.sha256]));
136
- const indexableMarkdownPaths = new Set(collectIndexFiles(root, memoryConfig).map((file) => rel(root, file)).filter((path) => path.endsWith('.md')));
137
- for (const file of markdownFiles) {
138
- const path = rel(root, file);
139
- if (!indexableMarkdownPaths.has(path)) continue;
140
- const indexedHash = indexed.get(path);
141
- const currentHash = fingerprint(file);
142
- 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.');
143
- 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`.');
144
- }
145
- }
146
- }
147
-
148
- if (options.includeLocalMemory) {
149
- for (const area of ['plan', 'archive/plan', 'archive/report']) {
150
- const areaRoot = join(docs, area);
151
- if (!existsSync(areaRoot)) continue;
152
- for (const entry of readdirSync(areaRoot, { withFileTypes: true })) {
153
- if (!entry.isDirectory()) continue;
154
- 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.');
155
- const readme = join(areaRoot, entry.name, 'README.md');
156
- 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.');
157
- }
158
- }
159
- }
160
- const gitignore = join(root, '.gitignore');
161
- if (!existsSync(gitignore)) addError(gitignore, 'MISSING_GITIGNORE', 'Expected .gitignore', null, 'create .gitignore and include docs/plan, docs/archive, and .dotdotgod entries.');
162
- else {
163
- const content = readFileSync(gitignore, 'utf8').split('\n').map((line) => line.trim());
164
- 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.`);
165
- }
166
-
167
- if (options.json) console.log(JSON.stringify({ ok: errors.length === 0, errors }, null, 2));
168
- else if (errors.length === 0) console.log(`✅ docs validation passed (${markdownFiles.length} markdown files)`);
169
- else {
170
- for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}`);
171
- console.log(`\n❌ ${errors.length} docs validation error(s)`);
172
- }
173
- process.exit(errors.length === 0 ? 0 : 1);
174
- }
175
-
176
- export function isReadmeIndexPath(path = '') {
177
- return basename(path) === 'README.md';
178
- }
179
-
180
- function retrievalSignalsForPath(path = '', config = defaultMemoryConfig()) {
181
- const signals = [];
182
- const definition = resolveMemoryArea(path, config);
183
- if (definition) {
184
- signals.push('memory-area', `scope:${definition.scope}`, `freshness:${definition.freshness}`);
185
- }
186
- if (path.startsWith('docs/')) signals.push('docs-path');
187
- if (isReadmeIndexPath(path)) signals.push('readme-index');
188
- return signals;
189
- }
190
-
191
- function retrievalMetadataForPath(path = '', config = defaultMemoryConfig()) {
192
- const definition = resolveMemoryArea(path, config);
193
- return {
194
- area: definition?.id,
195
- role: definition?.role,
196
- priority: definition?.priority ?? 30,
197
- scope: definition?.scope,
198
- freshness: definition?.freshness,
199
- includeBodiesByDefault: definition?.includeBodiesByDefault,
200
- signals: retrievalSignalsForPath(path, config),
201
- };
202
- }
203
-
204
- function fileNodeMetadata(path, file, config = defaultMemoryConfig()) {
205
- const retrieval = retrievalMetadataForPath(path, config);
206
- return {
207
- path,
208
- extension: file ? extname(file) : extname(path),
209
- memoryArea: retrieval.area,
210
- memoryRole: retrieval.role,
211
- memoryScope: retrieval.scope,
212
- memoryFreshness: retrieval.freshness,
213
- retrievalPriority: retrieval.priority,
214
- retrieval,
215
- };
216
- }
217
-
218
- function addMemoryAreaMembership(graph, fileId, path, config = defaultMemoryConfig()) {
219
- const definition = resolveMemoryArea(path, config);
220
- if (!definition) return;
221
- const areaId = `memory_area:${definition.id}`;
222
- 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 });
223
- addEdge(graph, fileId, areaId, 'belongs_to_area', { confidence: 'DETERMINISTIC', role: definition.role, scope: definition.scope, freshness: definition.freshness });
224
- }
225
-
226
- function addPackageResource(graph, fileId, packagePath, name, target, kind) {
227
- if (!target || typeof target !== 'string') return;
228
- const id = `package_resource:${packagePath}#${kind}:${name}`;
229
- addNode(graph, id, 'package_resource', { name, target, kind, path: packagePath });
230
- addEdge(graph, fileId, id, 'includes_resource', { kind, confidence: 'EXTRACTED' });
231
- }
232
-
233
- function addTraceabilityTarget(graph, sourceId, root, relation, targetPath, data = {}, config = defaultMemoryConfig()) {
234
- if (!isLocalRelativeTraceabilityPath(targetPath) || !existsSync(resolve(root, targetPath))) return;
235
- const targetId = `file:${targetPath}`;
236
- addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
237
- addEdge(graph, sourceId, targetId, relation, { confidence: 'CURATED_TRACEABILITY', ...data });
238
- }
239
-
240
- function addTraceabilityGraph(root, fileId, content, graph, config = defaultMemoryConfig()) {
241
- for (const block of extractDotdotgodTraceabilityBlocks(content)) {
242
- if (block.error || !block.data || block.data.kind !== 'spec') continue;
243
- for (const target of block.data.implementedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'implemented_by', target, {}, config);
244
- for (const target of block.data.verifiedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'verified_by', target, {}, config);
245
- for (const target of block.data.relatedDocs ?? []) addTraceabilityTarget(graph, fileId, root, 'related_doc', target, {}, config);
246
- for (const [index, command] of (Array.isArray(block.data.verificationCommands) ? block.data.verificationCommands : []).entries()) {
247
- if (typeof command !== 'string' || command.trim().length === 0) continue;
248
- const id = `verification_command:${fileId}#${index}`;
249
- addNode(graph, id, 'verification_command', { command, path: fileId.replace(/^file:/, '') });
250
- addEdge(graph, fileId, id, 'verification_command', { confidence: 'CURATED_TRACEABILITY' });
251
- }
252
- }
253
- }
254
-
255
- export function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig()) {
256
- const path = rel(root, file);
257
- const content = readFileSync(file, 'utf8');
258
- const fileId = `file:${path}`;
259
- const headingRe = /^(#{1,6})\s+(.+)$/gm;
260
- let match;
261
- while ((match = headingRe.exec(content)) !== null) {
262
- const title = match[2].trim();
263
- const id = `heading:${path}#${headingToAnchor(title)}`;
264
- addNode(graph, id, 'heading', { path, title, depth: match[1].length });
265
- addEdge(graph, fileId, id, 'contains_heading', { confidence: 'EXTRACTED' });
266
- }
267
- for (const { href, line } of extractLinks(content)) {
268
- const pathPart = href.split('#')[0];
269
- if (!pathPart) continue;
270
- const targetPath = rel(root, resolve(dirname(file), pathPart));
271
- const targetId = `file:${targetPath}`;
272
- addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
273
- addEdge(graph, fileId, targetId, 'links_to', { line, confidence: 'EXTRACTED' });
274
- if (isReadmeIndexPath(path)) addEdge(graph, fileId, targetId, 'routes_to', { line, confidence: 'CURATED_INDEX', sourceRole: 'readme-index' });
275
- }
276
- addTraceabilityGraph(root, fileId, content, graph, config);
277
- }
278
-
279
- export function extractPackageGraph(root, file, graph) {
280
- const path = rel(root, file);
281
- const fileId = `file:${path}`;
282
- let pkg;
283
- try { pkg = JSON.parse(readFileSync(file, 'utf8')); } catch { return; }
284
- if (pkg.name) {
285
- const pkgId = `package:${pkg.name}`;
286
- addNode(graph, pkgId, 'package', { name: pkg.name, path });
287
- addEdge(graph, fileId, pkgId, 'declares_package', { confidence: 'EXTRACTED' });
288
- }
289
- for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
290
- const id = `script:${path}#${name}`;
291
- addNode(graph, id, 'script', { name, command, path });
292
- addEdge(graph, fileId, id, 'declares_script', { confidence: 'EXTRACTED' });
293
- }
294
- for (const [name, target] of Object.entries(typeof pkg.bin === 'string' ? { [pkg.name ?? 'bin']: pkg.bin } : pkg.bin ?? {})) {
295
- const id = `bin:${name}`;
296
- addNode(graph, id, 'binary', { name, target, path });
297
- addEdge(graph, fileId, id, 'declares_bin', { confidence: 'EXTRACTED' });
298
- addPackageResource(graph, fileId, path, name, target, 'bin');
299
- }
300
- for (const [index, target] of (Array.isArray(pkg.files) ? pkg.files : []).entries()) {
301
- addPackageResource(graph, fileId, path, `files:${index}`, target, 'files');
302
- }
303
- for (const [kind, value] of Object.entries(pkg.pi ?? {})) {
304
- for (const [index, target] of (Array.isArray(value) ? value : [value]).entries()) {
305
- addPackageResource(graph, fileId, path, `pi:${kind}:${index}`, target, `pi:${kind}`);
306
- }
307
- }
308
- for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) {
309
- for (const name of Object.keys(pkg[section] ?? {})) {
310
- const id = `dependency:${name}`;
311
- addNode(graph, id, 'dependency', { name });
312
- addEdge(graph, fileId, id, 'depends_on', { section, confidence: 'EXTRACTED' });
313
- }
314
- }
315
- }
316
-
317
- function isTestPath(path = '') {
318
- return /(^|\/)(test|tests)\//.test(path) || /\.(test|spec)\.(mjs|cjs|js|jsx|ts|tsx)$/.test(path);
319
- }
320
-
321
- 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']);
322
-
323
- function splitCamelCase(value = '') {
324
- return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
325
- }
326
-
327
- function conceptTokens(value = '') {
328
- const tokens = new Set();
329
- for (const raw of splitCamelCase(String(value)).toLowerCase().split(/[^a-z0-9]+/)) {
330
- if (raw.length < 3 || TOKEN_STOPWORDS.has(raw)) continue;
331
- tokens.add(raw);
332
- }
333
- return tokens;
334
- }
335
-
336
- function addTokens(target, value) {
337
- for (const token of conceptTokens(value)) target.add(token);
338
- }
339
-
340
- function intersectTokens(a, b) {
341
- return [...a].filter((token) => b.has(token));
342
- }
343
-
344
- function jaccard(a, b) {
345
- if (a.size === 0 || b.size === 0) return 0;
346
- const intersection = intersectTokens(a, b).length;
347
- const denominator = Math.min(a.size, b.size);
348
- return denominator === 0 ? 0 : intersection / denominator;
349
- }
350
-
351
- function semanticSignalScore(source, target) {
352
- const path = jaccard(source.pathTokens, target.pathTokens);
353
- const heading = Math.max(jaccard(source.headingTokens, target.headingTokens), jaccard(source.headingTokens, target.allTokens), jaccard(source.allTokens, target.headingTokens));
354
- const pkg = Math.max(jaccard(source.packageTokens, target.packageTokens), jaccard(source.allTokens, target.packageTokens), jaccard(source.packageTokens, target.allTokens));
355
- return { path, filename: path, heading, package: pkg };
356
- }
357
-
358
- function semanticProfileForFile(fileNode, graph) {
359
- const profile = {
360
- id: fileNode.id,
361
- path: fileNode.path,
362
- retrieval: fileNode.retrieval,
363
- pathTokens: new Set(),
364
- headingTokens: new Set(),
365
- packageTokens: new Set(),
366
- allTokens: new Set(),
367
- };
368
- addTokens(profile.pathTokens, fileNode.path ?? fileNode.id);
369
- addTokens(profile.pathTokens, basename(fileNode.path ?? ''));
370
-
371
- const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
372
- for (const edge of graph.edges) {
373
- if (edge.source !== fileNode.id) continue;
374
- const target = nodeById.get(edge.target);
375
- if (!target) continue;
376
- if (edge.relation === 'contains_heading') addTokens(profile.headingTokens, target.title ?? target.id);
377
- 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);
378
- }
379
- for (const set of [profile.pathTokens, profile.headingTokens, profile.packageTokens]) {
380
- for (const token of set) profile.allTokens.add(token);
381
- }
382
- return profile;
383
- }
384
-
385
- function semanticRelationForProfiles(source, target, scores) {
386
- if (scores.package >= 0.5) return 'mentions_package';
387
- return 'semantic_similarity';
388
- }
389
-
390
- export function addDeterministicSemanticEdges(graph, config = defaultMemoryConfig()) {
391
- const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
392
- if (policy.semantic.enabled === false || policy.semantic.topKPerFile === 0) return graph;
393
-
394
- const threshold = policy.semantic.threshold ?? 0.5;
395
- const topK = policy.semantic.topKPerFile ?? 5;
396
- const includeArchiveBodies = policy.semantic.includeArchiveBodies === true;
397
- const enabledSignals = new Set(policy.semantic.signals ?? DEFAULT_IMPACT_RANKING_POLICY.semantic.signals);
398
- const baseGraph = { nodes: graph.nodes.map((node) => ({ ...node })), edges: graph.edges.filter((edge) => !SEMANTIC_RELATIONS.has(edge.relation)).map((edge) => ({ ...edge })) };
399
- const files = baseGraph.nodes.filter((node) => node.type === 'file' && node.path);
400
- const profiles = files.map((node) => semanticProfileForFile(node, baseGraph));
401
-
402
- for (const source of profiles) {
403
- if (!includeArchiveBodies && source.retrieval?.area === 'archive-body') continue;
404
- const candidates = [];
405
- for (const target of profiles) {
406
- if (source.id === target.id) continue;
407
- if (!includeArchiveBodies && target.retrieval?.area === 'archive-body') continue;
408
- const scores = semanticSignalScore(source, target);
409
- const weighted = Object.entries(scores).filter(([signal]) => enabledSignals.has(signal));
410
- const score = weighted.length === 0 ? 0 : Math.max(...weighted.map(([, value]) => value));
411
- if (score < threshold) continue;
412
- const matchedTerms = intersectTokens(source.allTokens, target.allTokens).slice(0, 12);
413
- if (matchedTerms.length === 0) continue;
414
- const relation = semanticRelationForProfiles(source, target, scores);
415
- const signals = weighted.filter(([, value]) => value > 0).map(([signal]) => signal);
416
- candidates.push({ target, relation, score, matchedTerms, signals });
417
- }
418
- candidates
419
- .sort((a, b) => b.score - a.score || a.target.id.localeCompare(b.target.id))
420
- .slice(0, topK)
421
- .forEach((candidate) => {
422
- addEdge(baseGraph, source.id, candidate.target.id, candidate.relation, {
423
- confidence: 'INFERRED_LEXICAL_SEMANTIC',
424
- score: Math.round(candidate.score * 1000) / 1000,
425
- matchedTerms: candidate.matchedTerms,
426
- signals: candidate.signals,
427
- });
428
- });
429
- }
430
- return baseGraph;
431
- }
432
-
433
- export function buildGraph(root, files, config = readMemoryConfig(root)) {
434
- const graph = { nodes: [], edges: [] };
435
- for (const file of files) {
436
- const path = rel(root, file);
437
- const fileId = `file:${path}`;
438
- addNode(graph, fileId, 'file', fileNodeMetadata(path, file, config));
439
- addMemoryAreaMembership(graph, fileId, path, config);
440
- if (path.endsWith('.md')) extractMarkdownGraph(root, file, graph, config);
441
- else if (basename(file) === 'package.json') extractPackageGraph(root, file, graph);
442
- }
443
- return graph;
444
- }
445
-
446
- function collectFingerprints(root, config = readMemoryConfig(root)) {
447
- return collectIndexFiles(root, config).map((file) => {
448
- const stats = statSync(file);
449
- return { path: rel(root, file), sha256: fingerprint(file), size: stats.size, mtimeMs: Math.round(stats.mtimeMs) };
450
- });
451
- }
452
-
453
- function nodeOwnedByPath(node, paths) {
454
- return paths.has(node.path) || paths.has(node.id?.replace(/^file:/, '')) || paths.has(node.id?.replace(/^test:/, ''));
455
- }
456
-
457
- function mergeIncrementalGraph(previousGraph, changedGraph, changedPaths) {
458
- if (!previousGraph) return changedGraph;
459
- const changedSet = new Set(changedPaths);
460
- const changedNodeIds = new Set(previousGraph.nodes.filter((node) => nodeOwnedByPath(node, changedSet)).map((node) => node.id));
461
- for (const node of changedGraph.nodes) changedNodeIds.add(node.id);
462
- const graph = { nodes: [], edges: [] };
463
- 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')));
464
- 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')));
465
- for (const node of changedGraph.nodes) addNode(graph, node.id, node.type, Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'id' && key !== 'type')));
466
- 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')));
467
- return graph;
468
- }
469
-
470
- export function buildIndex(root, previous = readIndex(root)) {
471
- const startedAt = Date.now();
472
- const memoryConfig = readMemoryConfig(root);
473
- const files = collectFingerprints(root, memoryConfig);
474
- const indexed = new Map((previous?.files ?? []).map((file) => [file.path, file.sha256]));
475
- const currentPaths = new Set(files.map((file) => file.path));
476
- const removedPaths = (previous?.files ?? []).filter((file) => !currentPaths.has(file.path)).map((file) => file.path);
477
- const changedPaths = [...files.filter((file) => indexed.get(file.path) !== file.sha256).map((file) => file.path), ...removedPaths];
478
- const changedFiles = files.filter((file) => changedPaths.includes(file.path)).map((file) => join(root, file.path));
479
- const fullRebuild = !previous?.graph || previous.version !== CACHE_VERSION;
480
- const refreshReason = !previous ? 'missing' : previous.version !== CACHE_VERSION ? 'schema-mismatch' : removedPaths.length > 0 ? 'content-removed' : changedPaths.length > 0 ? 'content-changed' : 'fresh';
481
- const rawGraph = fullRebuild ? buildGraph(root, files.map((file) => join(root, file.path)), memoryConfig) : mergeIncrementalGraph(previous.graph, buildGraph(root, changedFiles, memoryConfig), changedPaths);
482
- const graph = addDeterministicSemanticEdges(rawGraph, memoryConfig);
483
- const archiveBodiesIncluded = (memoryConfig.areas ?? []).some((area) => area.id === 'archive-body' && area.includeBodiesByDefault !== false);
484
- 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 } };
485
- }
486
-
487
- export function writeIndex(root, index) {
488
- const compact = compactGraph(index.graph);
489
- for (const [name, rows] of Object.entries(compact.nodes)) writeJson(shardFile(root, 'nodes', name), rows);
490
- for (const [name, rows] of Object.entries(compact.edges)) writeJson(shardFile(root, 'edges', name), rows);
491
- const graphShards = {
492
- nodes: Object.fromEntries(Object.keys(compact.nodes).map((name) => [name, rel(root, shardFile(root, 'nodes', name))])),
493
- edges: Object.fromEntries(Object.keys(compact.edges).map((name) => [name, rel(root, shardFile(root, 'edges', name))])),
494
- };
495
- 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 };
496
- writeJson(cacheFile(root), manifest);
497
- const shardBytes = [...Object.values(graphShards.nodes), ...Object.values(graphShards.edges)].reduce((sum, path) => sum + jsonSize(join(root, path)), 0);
498
- const manifestBytes = jsonSize(cacheFile(root));
499
- return { ...manifest, indexSizeBytes: manifestBytes + shardBytes, manifestBytes, shardBytes };
500
- }
501
-
502
- export function readIndex(root) {
503
- const file = cacheFile(root);
504
- if (!existsSync(file)) return null;
505
- try {
506
- const manifest = JSON.parse(readFileSync(file, 'utf8'));
507
- const shards = manifest.graph?.shards;
508
- if (!shards) return manifest;
509
- const compact = { nodes: {}, edges: {} };
510
- for (const [name, path] of Object.entries(shards.nodes ?? {})) compact.nodes[name] = existsSync(join(root, path)) ? JSON.parse(readFileSync(join(root, path), 'utf8')) : [];
511
- for (const [name, path] of Object.entries(shards.edges ?? {})) compact.edges[name] = existsSync(join(root, path)) ? JSON.parse(readFileSync(join(root, path), 'utf8')) : [];
512
- return { ...manifest, graph: expandGraph(compact) };
513
- } catch { return null; }
514
- }
515
-
516
- export function getStatus(root) {
517
- const index = readIndex(root);
518
- const memoryConfig = readMemoryConfig(root);
519
- const currentFiles = collectFingerprints(root, memoryConfig);
520
- 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' };
521
- const indexed = new Map((index.files ?? []).map((file) => [file.path, file.sha256]));
522
- const currentMap = new Map(currentFiles.map((file) => [file.path, file.sha256]));
523
- const stale = currentFiles.filter((file) => indexed.get(file.path) !== file.sha256).map((file) => file.path);
524
- const removed = [...indexed.keys()].filter((path) => !currentMap.has(path));
525
- const staleFiles = [...stale, ...removed];
526
- const schemaVersion = index.schemaVersion ?? index.version ?? null;
527
- const schemaOk = schemaVersion === CACHE_VERSION;
528
- const ok = staleFiles.length === 0 && schemaOk;
529
- const reason = ok ? 'fresh' : !schemaOk ? 'schema-mismatch' : staleFiles.length > 0 ? 'content-changed' : 'unknown';
530
- 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: [] }) };
531
- }
532
-
533
- export function runIndex(argv) {
534
- const options = parseCommon(argv);
535
- const index = buildIndex(options.root);
536
- const manifest = writeIndex(options.root, index);
537
- 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 };
538
- if (options.json) console.log(JSON.stringify(result, null, 2));
539
- else console.log(`✅ index written (${result.indexedFiles} files, ${result.nodes} nodes, ${result.edges} edges, ${(result.indexSizeBytes / 1024).toFixed(1)} KiB, cache: ${result.cachePath})`);
540
- }
541
-
542
- export function runStatus(argv) {
543
- const options = parseCommon(argv);
544
- const status = getStatus(options.root);
545
- if (options.json) console.log(JSON.stringify(status, null, 2));
546
- else console.log(`dotdotgod index status: ${status.status} (${status.indexedFiles}/${status.currentFiles} files, cache: ${status.cachePath})`);
547
- process.exit(status.ok ? 0 : 1);
548
- }
549
-
550
- export function readFreshIndex(root) {
551
- const startedAt = Date.now();
552
- const initialStatus = getStatus(root);
553
- if (initialStatus.ok) return { status: initialStatus, index: readIndex(root), metadata: { cacheRefreshed: false, elapsedMs: Date.now() - startedAt, refreshReason: 'fresh', schemaVersion: CACHE_VERSION } };
554
-
555
- const index = buildIndex(root);
556
- const manifest = writeIndex(root, index);
557
- const status = getStatus(root);
558
- return {
559
- status,
560
- index,
561
- metadata: {
562
- cacheRefreshed: true,
563
- previousStatus: initialStatus.status,
564
- previousReason: initialStatus.reason,
565
- refreshReason: index.incremental?.refreshReason ?? initialStatus.reason,
566
- changedFiles: index.incremental?.changedFiles ?? initialStatus.staleFiles,
567
- fullRebuild: index.incremental?.fullRebuild === true,
568
- indexedFiles: index.files.length,
569
- indexSizeBytes: manifest.indexSizeBytes,
570
- manifestBytes: manifest.manifestBytes,
571
- shardBytes: manifest.shardBytes,
572
- elapsedMs: Date.now() - startedAt,
573
- indexElapsedMs: index.incremental?.elapsedMs,
574
- schemaVersion: CACHE_VERSION,
575
- archiveBodiesIncluded: index.archiveBodiesIncluded === true,
576
- },
577
- };
578
- }
579
-
580
- export function graphSummary(index) {
581
- const graph = index?.graph ?? { nodes: [], edges: [] };
582
- const byType = graph.nodes.reduce((acc, node) => ({ ...acc, [node.type]: (acc[node.type] ?? 0) + 1 }), {});
583
- const byRelation = graph.edges.reduce((acc, edge) => ({ ...acc, [edge.relation]: (acc[edge.relation] ?? 0) + 1 }), {});
584
- return { nodes: graph.nodes.length, edges: graph.edges.length, byType, byRelation };
585
- }
586
-
587
- export function neighborhood(index, changedPath) {
588
- return buildImpactReport(index, changedPath).related;
589
- }
590
-
591
- function addImpactItem(group, item, limit = 10) {
592
- if (group.items.some((existing) => existing.id === item.id)) return;
593
- if (group.items.length >= limit) {
594
- group.omitted += 1;
595
- return;
596
- }
597
- group.items.push(item);
598
- }
599
-
600
- function docsArea(path = '') {
601
- if (path.startsWith('docs/spec/')) return 'spec';
602
- if (path.startsWith('docs/arch/')) return 'arch';
603
- if (path.startsWith('docs/test/')) return 'test-docs';
604
- if (path.startsWith('docs/plan/')) return 'plan';
605
- if (path.startsWith('docs/archive/')) return 'archive-index';
606
- if (path.startsWith('docs/')) return 'docs';
607
- return undefined;
608
- }
609
-
610
- function clamp(value, min, max) {
611
- return Math.max(min, Math.min(max, value));
612
- }
613
-
614
- function roundScore(value) {
615
- return Math.round(value * 10) / 10;
616
- }
617
-
618
- function sumReasonBoosts(reasons, boosts) {
619
- return reasons.reduce((sum, reason) => sum + (boosts[reason] ?? 0), 0);
620
- }
621
-
622
- function cappedTraceabilityScore(reasons, boosts, cap) {
623
- const values = reasons.map((reason) => boosts[reason] ?? 0).filter((value) => value > 0);
624
- if (values.length === 0) return 0;
625
- return clamp(Math.max(...values) + Math.min(5, Math.max(0, values.length - 1) * 2), 0, cap);
626
- }
627
-
628
- function buildPersonalizedPageRank(graph, seed, policy) {
629
- if (policy.ppr.enabled === false) return new Map();
630
- const damping = policy.ppr.damping ?? 0.85;
631
- const iterations = policy.ppr.iterations ?? 20;
632
- const tolerance = policy.ppr.tolerance ?? 0.000001;
633
- const ids = new Set(graph.nodes.map((node) => node.id));
634
- ids.add(seed);
635
- const adjacency = new Map([...ids].map((id) => [id, []]));
636
- for (const edge of graph.edges) {
637
- const weight = policy.relationWeights[edge.relation] ?? relationWeight(edge.relation);
638
- if (weight <= 0) continue;
639
- if (!adjacency.has(edge.source)) adjacency.set(edge.source, []);
640
- if (!adjacency.has(edge.target)) adjacency.set(edge.target, []);
641
- adjacency.get(edge.source).push([edge.target, weight]);
642
- adjacency.get(edge.target).push([edge.source, weight]);
643
- }
644
- let ranks = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 : 0]));
645
- for (let i = 0; i < iterations; i += 1) {
646
- const next = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 - damping : 0]));
647
- for (const [id, edges] of adjacency.entries()) {
648
- const rank = ranks.get(id) ?? 0;
649
- const total = edges.reduce((sum, [, weight]) => sum + weight, 0);
650
- if (total === 0) continue;
651
- for (const [target, weight] of edges) next.set(target, (next.get(target) ?? 0) + damping * rank * (weight / total));
652
- }
653
- const delta = [...next.entries()].reduce((sum, [id, rank]) => sum + Math.abs(rank - (ranks.get(id) ?? 0)), 0);
654
- ranks = next;
655
- if (delta < tolerance) break;
656
- }
657
- return ranks;
658
- }
659
-
660
- function scoreImpactItem(item, seed, changedPath, policy, pprScores, maxPpr) {
661
- if (item.id === seed) {
662
- return { impactScore: 100, scoreBreakdown: { seed: 100, ppr: 40, traceability: 0, memoryPolicy: 0, verification: 0, proximity: 0, semantic: 0, freshness: 0, archivePenalty: 0 } };
663
- }
664
- const reasons = item.reasons ?? [];
665
- const retrieval = item.retrieval ?? {};
666
- const pprNormalized = maxPpr > 0 ? (pprScores.get(item.id) ?? 0) / maxPpr : 0;
667
- const ppr = clamp(pprNormalized * (policy.weights.ppr ?? 40), 0, Math.abs(policy.weights.ppr ?? 40));
668
- const traceability = cappedTraceabilityScore(reasons, policy.traceabilityBoosts, Math.abs(policy.weights.traceability ?? 30));
669
- const memoryPolicy = clamp(((retrieval.priority ?? 30) / 100) * Math.abs(policy.weights.memoryPolicy ?? 10), 0, Math.abs(policy.weights.memoryPolicy ?? 10));
670
- 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));
671
- const proximity = clamp(sumReasonBoosts(reasons, policy.proximityBoosts), 0, Math.abs(policy.weights.proximity ?? 10));
672
- const semantic = clamp(sumReasonBoosts(reasons, policy.semanticBoosts), 0, Math.abs(policy.weights.semantic ?? 10));
673
- const freshness = retrieval.freshness === 'fresh' ? Math.abs(policy.weights.freshness ?? 5) : retrieval.freshness === 'stale' ? -Math.abs(policy.weights.freshness ?? 5) : 0;
674
- let archivePenalty = 0;
675
- if (!changedPath.startsWith('docs/archive/')) {
676
- if (retrieval.area === 'archive-body') archivePenalty -= Math.abs(policy.weights.archivePenalty ?? -25);
677
- if (retrieval.includeBodiesByDefault === false) archivePenalty -= 10;
678
- if (retrieval.freshness === 'stale' && retrieval.area !== 'archive-map') archivePenalty -= 5;
679
- }
680
- archivePenalty = clamp(archivePenalty, -Math.abs(policy.weights.archivePenalty ?? -25), 0);
681
- const impactScore = clamp(ppr + traceability + memoryPolicy + verification + proximity + semantic + freshness + archivePenalty, 0, 100);
682
- return {
683
- impactScore: roundScore(impactScore),
684
- scoreBreakdown: {
685
- ppr: roundScore(ppr),
686
- traceability: roundScore(traceability),
687
- memoryPolicy: roundScore(memoryPolicy),
688
- verification: roundScore(verification),
689
- proximity: roundScore(proximity),
690
- semantic: roundScore(semantic),
691
- freshness: roundScore(freshness),
692
- archivePenalty: roundScore(archivePenalty),
693
- },
694
- };
695
- }
696
-
697
- const CURATED_IMPACT_REASONS = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', 'links_to', 'routes_to', 'belongs_to_area']);
698
- const LOW_ACTIONABILITY_IMPACT_TYPES = new Set(['dependency', 'package', 'script', 'binary', 'heading', 'memory_area']);
699
-
700
- function baseImpactReason(reason = '') {
701
- return reason.replace(/^incoming:/, '');
702
- }
703
-
704
- function isSemanticImpactReason(reason = '') {
705
- return SEMANTIC_RELATIONS.has(baseImpactReason(reason));
706
- }
707
-
708
- function isCuratedImpactReason(reason = '') {
709
- return CURATED_IMPACT_REASONS.has(baseImpactReason(reason));
710
- }
711
-
712
- function hasCuratedImpactReason(item) {
713
- return (item.reasons ?? []).some((reason) => isCuratedImpactReason(reason));
714
- }
715
-
716
- function isSemanticOnlyImpactItem(item) {
717
- const reasons = item.reasons ?? [];
718
- return reasons.length > 0 && reasons.every((reason) => isSemanticImpactReason(reason));
719
- }
720
-
721
- function isLowActionabilityImpactItem(item) {
722
- return LOW_ACTIONABILITY_IMPACT_TYPES.has(item.type);
723
- }
724
-
725
- function impactSelectionScore(item) {
726
- let score = item.impactScore ?? 0;
727
- if (hasCuratedImpactReason(item)) score += 4;
728
- if (item.type === 'file' || item.type === 'test' || item.type === 'verification_command') score += 2;
729
- if (isSemanticOnlyImpactItem(item)) score -= 12;
730
- if (isLowActionabilityImpactItem(item)) score -= 15;
731
- return score;
732
- }
733
-
734
- function compareImpactItems(seed) {
735
- return (a, b) => {
736
- if (a.id === seed) return -1;
737
- if (b.id === seed) return 1;
738
- return impactSelectionScore(b) - impactSelectionScore(a) || (b.impactScore ?? 0) - (a.impactScore ?? 0) || a.id.localeCompare(b.id);
739
- };
740
- }
741
-
742
- function selectImpactItems(sortedItems, maxRelated, seed) {
743
- const selected = [];
744
- const selectedIds = new Set();
745
- const deferred = [];
746
- const firstPageLimit = Math.min(maxRelated, 10);
747
- let semanticOnlyInFirstPage = 0;
748
- let lowActionabilityInFirstPage = 0;
749
- const firstPagePathCounts = new Map();
750
- const add = (item, force = false) => {
751
- if (selected.length >= maxRelated || selectedIds.has(item.id)) return;
752
- const pathKey = item.path ?? item.id;
753
- if (!force && item.id !== seed && selected.length < firstPageLimit) {
754
- if (pathKey && (firstPagePathCounts.get(pathKey) ?? 0) >= 2) {
755
- deferred.push(item);
756
- return;
757
- }
758
- if (isLowActionabilityImpactItem(item) && lowActionabilityInFirstPage >= 2) {
759
- deferred.push(item);
760
- return;
761
- }
762
- if (isSemanticOnlyImpactItem(item) && semanticOnlyInFirstPage >= 3) {
763
- deferred.push(item);
764
- return;
765
- }
766
- }
767
- selected.push(item);
768
- selectedIds.add(item.id);
769
- if (selected.length <= firstPageLimit && item.id !== seed) {
770
- if (pathKey) firstPagePathCounts.set(pathKey, (firstPagePathCounts.get(pathKey) ?? 0) + 1);
771
- if (isLowActionabilityImpactItem(item)) lowActionabilityInFirstPage += 1;
772
- if (isSemanticOnlyImpactItem(item)) semanticOnlyInFirstPage += 1;
773
- }
774
- };
775
- for (const item of sortedItems) if (item.id === seed) add(item, true);
776
- for (const item of sortedItems) if (item.id !== seed) add(item);
777
- for (const item of deferred) add(item, true);
778
- return selected;
779
- }
780
-
781
- export function buildImpactReport(index, changedPath, limits = {}) {
782
- const graph = index?.graph ?? { nodes: [], edges: [] };
783
- const config = index?.memoryConfig ? { ...defaultMemoryConfig(), ...index.memoryConfig } : defaultMemoryConfig();
784
- const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
785
- const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
786
- const seed = `file:${changedPath}`;
787
- const maxRelated = limits.related ?? 25;
788
- const groups = {
789
- files: { items: [], omitted: 0 },
790
- docs: { items: [], omitted: 0 },
791
- tests: { items: [], omitted: 0 },
792
- commands: { items: [], omitted: 0 },
793
- events: { items: [], omitted: 0 },
794
- packageResources: { items: [], omitted: 0 },
795
- symbols: { items: [], omitted: 0 },
796
- };
797
- const relatedIds = new Set([seed]);
798
- const reasons = new Map([[seed, new Set(['changed-file'])]]);
799
- const addReason = (id, reason) => {
800
- relatedIds.add(id);
801
- if (!reasons.has(id)) reasons.set(id, new Set());
802
- reasons.get(id).add(reason);
803
- };
804
-
805
- for (const edge of graph.edges) {
806
- if (edge.source === seed) addReason(edge.target, edge.relation);
807
- if (edge.target === seed) addReason(edge.source, `incoming:${edge.relation}`);
808
- }
809
-
810
- const expansionRelations = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', ...SEMANTIC_RELATIONS]);
811
- for (const edge of graph.edges) {
812
- if (!expansionRelations.has(edge.relation)) continue;
813
- if (relatedIds.has(edge.source) && edge.target !== seed) addReason(edge.target, edge.relation);
814
- }
815
-
816
- const pprScores = buildPersonalizedPageRank(graph, seed, policy);
817
- const candidatePprMax = Math.max(0, ...[...relatedIds].filter((id) => id !== seed).map((id) => pprScores.get(id) ?? 0));
818
- const relatedAll = [...relatedIds]
819
- .map((id) => {
820
- const node = nodeById.get(id) ?? { id };
821
- const path = node.path ?? id.replace(/^file:/, '').replace(/^test:/, '');
822
- const reasonList = [...(reasons.get(id) ?? [])];
823
- const retrieval = node.retrieval ?? retrievalMetadataForPath(path);
824
- const reasonSignals = reasonList.map((reason) => `reason:${reason}`);
825
- const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seed, changedPath, policy, pprScores, candidatePprMax);
826
- return { ...node, reasons: reasonList, retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
827
- })
828
- .sort(compareImpactItems(seed));
829
- const related = selectImpactItems(relatedAll, maxRelated, seed);
830
- for (const item of related) {
831
- if (item.type === 'file') {
832
- const area = docsArea(item.path);
833
- if (area) addImpactItem(groups.docs, { ...item, area }, limits.docs ?? 10);
834
- else if (isTestPath(item.path)) addImpactItem(groups.tests, item, limits.tests ?? 10);
835
- else addImpactItem(groups.files, item, limits.files ?? 10);
836
- } else if (item.type === 'test') addImpactItem(groups.tests, item, limits.tests ?? 10);
837
- else if (item.type === 'command') addImpactItem(groups.commands, item, limits.commands ?? 10);
838
- else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
839
- else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
840
- }
841
-
842
- return {
843
- changed: changedPath,
844
- 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 },
845
- related,
846
- groups,
847
- omittedRelated: Math.max(0, relatedAll.length - related.length),
848
- };
849
- }
850
-
851
- function compactScoreBreakdown(scoreBreakdown = {}) {
852
- const compact = {};
853
- for (const [key, value] of Object.entries(scoreBreakdown)) {
854
- if (value !== 0 && value !== undefined) compact[key] = value;
855
- }
856
- return compact;
857
- }
858
-
859
- function compactImpactItem(item) {
860
- const compact = {
861
- id: item.id,
862
- type: item.type,
863
- impactScore: item.impactScore,
864
- reasons: (item.reasons ?? []).slice(0, 6),
865
- scoreBreakdown: compactScoreBreakdown(item.scoreBreakdown),
866
- };
867
- for (const key of ['path', 'area', 'name', 'command', 'target', 'kind', 'specifier', 'title']) {
868
- if (item[key] !== undefined) compact[key] = item[key];
869
- }
870
- if (item.retrieval) {
871
- compact.retrieval = {
872
- area: item.retrieval.area,
873
- role: item.retrieval.role,
874
- priority: item.retrieval.priority,
875
- freshness: item.retrieval.freshness,
876
- };
877
- }
878
- return compact;
879
- }
880
-
881
- function compactImpactGroup(group = { items: [], omitted: 0 }, limit = 5) {
882
- const items = (group.items ?? []).slice(0, limit).map(compactImpactItem);
883
- return { items, omitted: (group.omitted ?? 0) + Math.max(0, (group.items?.length ?? 0) - items.length) };
884
- }
885
-
886
- export function buildCompactImpactReport(impact, limits = {}) {
887
- const relatedLimit = limits.related ?? 10;
888
- const groupLimit = limits.groupItems ?? 5;
889
- const related = (impact.related ?? []).slice(0, relatedLimit).map(compactImpactItem);
890
- const groupNames = ['files', 'docs', 'tests', 'commands', 'events', 'packageResources', 'symbols'];
891
- const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
892
- const top10 = (impact.related ?? []).filter((item) => item.id !== `file:${impact.changed}`).slice(0, 10);
893
- return {
894
- changed: impact.changed,
895
- compact: true,
896
- ranking: {
897
- method: impact.ranking?.method,
898
- preset: impact.ranking?.preset,
899
- configSource: impact.ranking?.configSource,
900
- },
901
- related,
902
- groups,
903
- omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length),
904
- quality: {
905
- rawRelated: impact.related?.length ?? 0,
906
- compactRelated: related.length,
907
- semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length,
908
- curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length,
909
- lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length,
910
- },
911
- };
912
- }
913
-
914
- function formatCompactImpactGroup(name, group) {
915
- const items = group?.items ?? [];
916
- if (items.length === 0) return [];
917
- return [
918
- `${name}:`,
919
- ...items.map((item) => {
920
- const label = item.path ?? item.command ?? item.name ?? item.target ?? item.id;
921
- const reasons = (item.reasons ?? []).slice(0, 3).join(', ');
922
- return `- ${label} (${item.impactScore}; ${reasons})`;
923
- }),
924
- ];
925
- }
926
-
927
- function formatCompactImpactOutput(payload, impact) {
928
- const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
929
- const lines = [`graph impact compact: ${impact.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${payload.status.status}${refreshNote} index)`];
930
- for (const name of ['docs', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatCompactImpactGroup(name, impact.groups[name]));
931
- return lines.join('\n');
932
- }
933
-
934
- function ymlScalar(value) {
935
- if (value === null || value === undefined) return 'null';
936
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
937
- return JSON.stringify(String(value));
938
- }
939
-
940
- function ymlList(values = []) {
941
- return `[${values.map(ymlScalar).join(', ')}]`;
942
- }
943
-
944
- function formatYmlImpactItem(item) {
945
- const label = item.path ?? item.command ?? item.name ?? item.target ?? item.id;
946
- const lines = [` - path: ${ymlScalar(label)}`];
947
- lines.push(` id: ${ymlScalar(item.id)}`);
948
- lines.push(` type: ${ymlScalar(item.type)}`);
949
- lines.push(` score: ${ymlScalar(item.impactScore)}`);
950
- lines.push(` reasons: ${ymlList((item.reasons ?? []).slice(0, 6))}`);
951
- if (item.retrieval?.area) lines.push(` area: ${ymlScalar(item.retrieval.area)}`);
952
- if (item.retrieval?.role) lines.push(` role: ${ymlScalar(item.retrieval.role)}`);
953
- return lines;
954
- }
955
-
956
- function formatYmlImpactGroup(name, group) {
957
- const items = group?.items ?? [];
958
- const lines = [` ${name}:`, ` omitted: ${group?.omitted ?? 0}`, ' items:'];
959
- if (items.length === 0) lines.push(' []');
960
- else for (const item of items) lines.push(...formatYmlImpactItem(item));
961
- return lines;
962
- }
963
-
964
- function formatYmlImpactOutput(payload, impact) {
965
- const lines = [
966
- 'impact:',
967
- ` ok: ${payload.ok ? 'true' : 'false'}`,
968
- ` changed: ${ymlScalar(payload.changed)}`,
969
- ` root: ${ymlScalar(payload.root)}`,
970
- ' output: "yml"',
971
- ' status:',
972
- ` index: ${ymlScalar(payload.status.status)}`,
973
- ` cache_refreshed: ${payload.metadata.cacheRefreshed ? 'true' : 'false'}`,
974
- ` related_count: ${impact.related.length}`,
975
- ` omitted_related: ${impact.omittedRelated ?? 0}`,
976
- ' ranking:',
977
- ` method: ${ymlScalar(impact.ranking?.method)}`,
978
- ` preset: ${ymlScalar(impact.ranking?.preset)}`,
979
- ` config_source: ${ymlScalar(impact.ranking?.configSource)}`,
980
- ' groups:',
981
- ];
982
- for (const name of ['docs', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatYmlImpactGroup(name, impact.groups[name]));
983
- lines.push(' recommended_actions:');
984
- lines.push(' - "review_related_docs"');
985
- lines.push(' - "run_related_tests"');
986
- lines.push(' - "run_dotdotgod_validate"');
987
- return lines.join('\n');
988
- }
989
-
990
- function formatYmlGraphImpactError(options, message, code = 'GRAPH_IMPACT_ERROR') {
991
- return [
992
- 'impact:',
993
- ' ok: false',
994
- ' command: "graph impact"',
995
- ' output: "yml"',
996
- ` compact: ${options.compact ? 'true' : 'false'}`,
997
- ` root: ${ymlScalar(options.root)}`,
998
- ' error:',
999
- ` code: ${ymlScalar(code)}`,
1000
- ` message: ${ymlScalar(message)}`,
1001
- ` usage: ${ymlScalar(commandUsage('graph impact'))}`,
1002
- ].join('\n');
1003
- }
1004
-
1005
- function parseConfigOptions(argv, allowForce = false, usageKey = 'config') {
1006
- const options = { root: '.', json: false, force: false };
1007
- let rootSet = false;
1008
- for (let i = 0; i < argv.length; i += 1) {
1009
- const arg = argv[i];
1010
- if (arg === '--json') options.json = true;
1011
- else if (allowForce && arg === '--force') options.force = true;
1012
- else if (!arg.startsWith('-') && !rootSet) {
1013
- options.root = arg;
1014
- rootSet = true;
1015
- } else if (!arg.startsWith('-')) usage(`Unexpected argument: ${arg}`, usageKey);
1016
- else usage(`Unknown option: ${arg}`, usageKey);
1017
- }
1018
- options.root = resolve(options.root);
1019
- return options;
1020
- }
1021
-
1022
- function configSourcePath(root, source) {
1023
- return source && source !== 'default' ? join(root, source) : null;
1024
- }
1025
-
1026
- function configInitError(options, code, message, path = null) {
1027
- const payload = { ok: false, command: 'config init', root: options.root, path, created: false, overwritten: false, error: { code, message } };
1028
- if (options.json) {
1029
- console.log(JSON.stringify(payload, null, 2));
1030
- process.exit(2);
1031
- }
1032
- console.error(message);
1033
- process.exit(2);
1034
- }
1035
-
1036
- function formatConfigOutput(payload) {
1037
- const errors = payload.errors ?? [];
1038
- const lines = [`dotdotgod config: ${payload.source}${errors.length > 0 ? ' (invalid; using defaults)' : ''}`];
1039
- lines.push(`- path: ${payload.path ?? 'none'}`);
1040
- lines.push(`- memory areas: ${payload.config.areas.length}`);
1041
- lines.push(`- traceability required: ${(payload.config.traceability.required ?? []).join(', ') || 'none'}`);
1042
- lines.push(`- traceability exclude: ${(payload.config.traceability.exclude ?? []).join(', ') || 'none'}`);
1043
- lines.push(`- validation markdown: maxLines=${payload.config.validation.markdown.maxLines}, maxChars=${payload.config.validation.markdown.maxChars}`);
1044
- lines.push(`- validation markdown exclude: ${(payload.config.validation.markdown.exclude ?? []).join(', ') || 'none'}`);
1045
- lines.push(`- impact ranking preset: ${payload.config.impactRanking.preset}`);
1046
- const lowSignal = payload.config.referenceExpansion?.fuzzy?.lowSignal ?? { terms: [], add: [], remove: [] };
1047
- lines.push(`- fuzzy low-signal terms: ${(lowSignal.terms ?? []).join(', ') || 'none'} (add: ${(lowSignal.add ?? []).join(', ') || 'none'}; remove: ${(lowSignal.remove ?? []).join(', ') || 'none'})`);
1048
- if (errors.length > 0) {
1049
- lines.push('- errors:');
1050
- for (const error of errors) lines.push(` - ${error.code} ${error.file}: ${error.message}`);
1051
- }
1052
- return lines.join('\n');
1053
- }
1054
-
1055
- export function runConfig(argv) {
1056
- const isInit = argv[0] === 'init';
1057
- const options = parseConfigOptions(isInit ? argv.slice(1) : argv, isInit, isInit ? 'config init' : 'config');
1058
- if (isInit) {
1059
- if (!existsSync(options.root)) configInitError(options, 'ROOT_NOT_FOUND', `Project root not found: ${options.root}`);
1060
- try {
1061
- if (!statSync(options.root).isDirectory()) configInitError(options, 'ROOT_NOT_DIRECTORY', `Project root is not a directory: ${options.root}`);
1062
- } catch {
1063
- configInitError(options, 'ROOT_NOT_FOUND', `Project root not found: ${options.root}`);
1064
- }
1065
- const target = join(options.root, 'dotdotgod.config.json');
1066
- const rcPath = join(options.root, '.dotdotgodrc.json');
1067
- if (existsSync(rcPath)) configInitError(options, 'CONFIG_RC_EXISTS', `.dotdotgodrc.json already exists; remove or migrate it before initializing dotdotgod.config.json.`, rcPath);
1068
- const existed = existsSync(target);
1069
- if (existed && !options.force) configInitError(options, 'CONFIG_EXISTS', `dotdotgod.config.json already exists. Re-run with --force to overwrite it.`, target);
1070
- writeFileSync(target, defaultDotdotgodConfigText());
1071
- const payload = { ok: true, command: 'config init', root: options.root, path: target, created: !existed, overwritten: existed };
1072
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1073
- else console.log(`dotdotgod config init: ${existed ? 'overwrote' : 'created'} ${target}`);
1074
- return;
1075
- }
1076
-
1077
- const config = readMemoryConfig(options.root);
1078
- const errors = config.errors ?? [];
1079
- 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 };
1080
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1081
- else console.log(formatConfigOutput(payload));
1082
- if (errors.length > 0) process.exit(1);
1083
- }
1084
-
1085
- export function runLoadSnapshot(argv) {
1086
- const options = parseCommon(argv);
1087
- const { status, index, metadata } = readFreshIndex(options.root);
1088
- const summary = graphSummary(index);
1089
- const communities = buildCommunities(index, { communities: 5, items: 5 });
1090
- const memoryAreas = buildMemoryAreas(index, { items: 4 });
1091
- const memoryConfig = index.memoryConfig ?? memoryConfigSummary(readMemoryConfig(options.root));
1092
- const memoryPolicy = {
1093
- source: memoryConfig.source ?? 'default',
1094
- sharedAreas: (memoryConfig.areas ?? []).filter((area) => area.scope === 'shared').map((area) => area.id),
1095
- localAreas: (memoryConfig.areas ?? []).filter((area) => area.scope === 'local').map((area) => area.id),
1096
- freshAreas: (memoryConfig.areas ?? []).filter((area) => area.freshness === 'fresh').map((area) => area.id),
1097
- staleAreas: (memoryConfig.areas ?? []).filter((area) => area.freshness === 'stale').map((area) => area.id),
1098
- };
1099
- const bounds = { communities: 5, communityItems: 5, memoryAreaItems: 4, fullGraphIncluded: false, archiveBodiesIncluded: status.archiveBodiesIncluded, archiveMapIncluded: true };
1100
- const quality = {
1101
- indexedFiles: status.indexedFiles,
1102
- currentFiles: status.currentFiles,
1103
- shownCommunities: communities.communities.length,
1104
- totalCommunities: communities.total,
1105
- omittedCommunities: communities.omitted,
1106
- omittedCommunityItems: communities.communities.reduce((sum, community) => sum + (community.omitted ?? 0), 0),
1107
- memoryAreas: memoryAreas.total,
1108
- omittedMemoryAreaItems: memoryAreas.areas.reduce((sum, area) => sum + (area.omitted ?? 0), 0),
1109
- graphNodes: summary.nodes,
1110
- graphEdges: summary.edges,
1111
- };
1112
- const commandGuidance = detectCommandGuidance(options.root);
1113
- let payload = { root: options.root, cache: status, metadata, graph: summary, memoryConfig, memoryPolicy, memoryAreas, communities, bounds, quality, commandGuidance };
1114
- const serialized = JSON.stringify(payload);
1115
- payload = { ...payload, quality: { ...quality, snapshotBytes: Buffer.byteLength(serialized), approxSnapshotTokens: Math.ceil(serialized.length / 4) } };
1116
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1117
- 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`);
1118
- }
1119
-
1120
- const DEFAULT_REFERENCE_LIMIT = 5;
1121
-
1122
- export function extractBracketReferences(prompt = '') {
1123
- const refs = [];
1124
- const seen = new Set();
1125
- for (const match of String(prompt).matchAll(/\[\[([^\]]+)\]\]/g)) {
1126
- const raw = match[1].trim();
1127
- if (!raw) continue;
1128
- const target = raw.split('|')[0].trim();
1129
- if (!target || seen.has(target)) continue;
1130
- seen.add(target);
1131
- refs.push({ raw: match[0], target, label: raw.includes('|') ? raw.split('|').slice(1).join('|').trim() : undefined });
1132
- }
1133
- return refs;
1134
- }
1135
-
1136
- function fuzzyLowSignalSet(policy = defaultMemoryConfig().referenceExpansion) {
1137
- return new Set(cloneReferenceExpansionPolicy(policy).fuzzy.lowSignal.terms);
1138
- }
1139
-
1140
- function fuzzyPhraseAllowed(value = '', lowSignalTerms = fuzzyLowSignalSet()) {
1141
- const normalized = normalizeLowSignalTerm(value);
1142
- if (normalized.length < 3) return false;
1143
- if (lowSignalTerms.has(normalized)) return false;
1144
- return /[a-z0-9가-힣]/i.test(normalized);
1145
- }
1146
-
1147
- function fuzzyTokenKey(value = '') {
1148
- return String(value).trim().replace(/\s+/g, ' ').toLowerCase();
1149
- }
1150
-
1151
- export function normalizeReferenceAlias(value = '') {
1152
- return String(value)
1153
- .trim()
1154
- .replace(/^\[\[/, '')
1155
- .replace(/\]\]$/, '')
1156
- .split('|')[0]
1157
- .trim()
1158
- .replace(/\\/g, '/')
1159
- .replace(/^\.\//, '')
1160
- .replace(/\.md$/i, '')
1161
- .toLowerCase()
1162
- .replace(/[\s_-]+/g, '')
1163
- .replace(/[^a-z0-9/#.]/g, '');
1164
- }
1165
-
1166
- function referencePathForNode(node) {
1167
- if (node?.path) return node.path;
1168
- if (typeof node?.id === 'string' && node.id.startsWith('file:')) return node.id.slice(5);
1169
- if (typeof node?.id === 'string' && node.id.startsWith('heading:')) return node.id.slice(8).split('#')[0];
1170
- return '';
1171
- }
1172
-
1173
- function isArchiveBodyPath(path = '') {
1174
- return path.startsWith('docs/archive/') && path !== 'docs/archive/README.md';
1175
- }
1176
-
1177
- function aliasEntriesForPath(path = '') {
1178
- const entries = [];
1179
- const base = basename(path);
1180
- const ext = extname(base);
1181
- const stem = ext ? base.slice(0, -ext.length) : base;
1182
- const withoutMd = path.replace(/\.md$/i, '');
1183
- const parts = withoutMd.split('/').filter(Boolean);
1184
- for (let i = 0; i < parts.length; i += 1) entries.push({ alias: parts.slice(i).join('/'), kind: 'path-suffix' });
1185
- if (base === 'README.md') {
1186
- const dir = dirname(path).replace(/\\/g, '/');
1187
- entries.push({ alias: basename(dir), kind: 'path' });
1188
- }
1189
- for (const alias of [path, withoutMd, base, stem]) entries.push({ alias, kind: 'path' });
1190
- return entries.filter((entry) => entry.alias);
1191
- }
1192
-
1193
- function referenceCandidateAliases(node) {
1194
- const path = referencePathForNode(node);
1195
- const entries = [];
1196
- if (node.type === 'file' && path) entries.push(...aliasEntriesForPath(path));
1197
- if (node.type === 'heading') {
1198
- const fileStem = path ? basename(path, extname(path)) : '';
1199
- const anchor = typeof node.id === 'string' && node.id.includes('#') ? node.id.split('#').pop() : '';
1200
- 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' });
1201
- }
1202
- return entries.filter((entry) => entry.alias);
1203
- }
1204
-
1205
- export function extractFuzzyReferences(prompt = '', index = null, options = {}) {
1206
- const text = String(prompt ?? '');
1207
- const masked = text.replace(/\[\[[^\]\n]+\]\]/g, ' ');
1208
- const seen = new Set((options.existingTargets ?? []).map((value) => fuzzyTokenKey(value)));
1209
- const lowSignalTerms = fuzzyLowSignalSet(options.referenceExpansion ?? options.memoryConfig?.referenceExpansion ?? index?.memoryConfig?.referenceExpansion);
1210
- const refs = [];
1211
- const add = (target, reason, confidence = 'medium') => {
1212
- const clean = String(target ?? '').trim().replace(/^['"`]+|['"`]+$/g, '').replace(/\s+/g, ' ');
1213
- if (!fuzzyPhraseAllowed(clean, lowSignalTerms)) return;
1214
- const key = fuzzyTokenKey(clean);
1215
- if (seen.has(key)) return;
1216
- seen.add(key);
1217
- refs.push({ raw: clean, target: clean, source: 'fuzzy', confidence, reasons: [reason] });
1218
- };
1219
-
1220
- 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');
1221
- 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');
1222
- for (const match of masked.matchAll(/[`"']([^`"'\n]{4,80})[`"']/g)) add(match[1], 'quoted_phrase', 'medium');
1223
-
1224
- const graph = index?.graph ?? null;
1225
- if (graph) {
1226
- const lower = ` ${masked.toLowerCase().replace(/[^a-z0-9가-힣/#._-]+/gi, ' ')} `;
1227
- const aliasByKey = new Map();
1228
- for (const node of graph.nodes ?? []) {
1229
- if (!['file', 'heading'].includes(node.type)) continue;
1230
- const path = referencePathForNode(node);
1231
- if (options.includeArchive !== true && isArchiveBodyPath(path)) continue;
1232
- for (const entry of referenceCandidateAliases(node)) {
1233
- const alias = String(entry.alias ?? '').replace(/\.md$/i, '').replace(/[_-]+/g, ' ').trim();
1234
- if (!fuzzyPhraseAllowed(alias, lowSignalTerms)) continue;
1235
- const words = alias.split(/[\s/]+/).filter(Boolean);
1236
- if (words.length > 3 || words.some((word) => lowSignalTerms.has(normalizeLowSignalTerm(word)))) continue;
1237
- const key = fuzzyTokenKey(alias);
1238
- const previous = aliasByKey.get(key);
1239
- const priority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
1240
- if (!previous || priority > previous.priority) aliasByKey.set(key, { alias, priority });
1241
- }
1242
- }
1243
- for (const { alias } of [...aliasByKey.values()].sort((a, b) => b.priority - a.priority || b.alias.length - a.alias.length)) {
1244
- const escaped = alias.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '[\\s_-]+');
1245
- const pattern = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i');
1246
- if (pattern.test(lower)) add(alias, 'known_alias', 'medium');
1247
- if (refs.length >= (options.maxFuzzyRefs ?? 5)) break;
1248
- }
1249
- }
1250
- return refs.slice(0, options.maxFuzzyRefs ?? 5);
1251
- }
1252
-
1253
- function incomingRouteBonus(graph, nodeId) {
1254
- return graph.edges.some((edge) => edge.target === nodeId && edge.relation === 'routes_to') ? 6 : 0;
1255
- }
1256
-
1257
- function exactReferenceScore(ref, alias, kind) {
1258
- const rawRef = String(ref).trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim().replace(/\\/g, '/').replace(/^\.\//, '');
1259
- const rawAlias = String(alias).trim().replace(/\\/g, '/').replace(/^\.\//, '');
1260
- if (rawRef === rawAlias) return kind === 'heading' ? 92 : 110;
1261
- if (rawRef.toLowerCase() === rawAlias.toLowerCase()) return kind === 'heading' ? 88 : 105;
1262
- return 0;
1263
- }
1264
-
1265
- export function resolveReferenceCandidates(index, ref, options = {}) {
1266
- const graph = index?.graph ?? { nodes: [], edges: [] };
1267
- const includeArchive = options.includeArchive === true;
1268
- const limit = Number.isFinite(options.maxResults) ? options.maxResults : DEFAULT_REFERENCE_LIMIT;
1269
- const query = String(ref ?? '').trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim();
1270
- const normalized = normalizeReferenceAlias(query);
1271
- const byId = new Map();
1272
- for (const node of graph.nodes ?? []) {
1273
- if (!['file', 'heading'].includes(node.type)) continue;
1274
- const path = referencePathForNode(node);
1275
- if (!includeArchive && isArchiveBodyPath(path)) continue;
1276
- let best = null;
1277
- for (const entry of referenceCandidateAliases(node)) {
1278
- const aliasKey = normalizeReferenceAlias(entry.alias);
1279
- if (!aliasKey) continue;
1280
- let score = exactReferenceScore(query, entry.alias, entry.kind);
1281
- if (aliasKey === normalized) score = Math.max(score, entry.kind === 'heading' ? 86 : 96);
1282
- else if (aliasKey.endsWith(normalized) && normalized.length >= 4) score = Math.max(score, entry.kind === 'heading' ? 62 : 70);
1283
- if (score <= 0) continue;
1284
- if (!best || score > best.score) best = { score, alias: entry.alias, aliasKind: entry.kind };
1285
- }
1286
- if (!best) continue;
1287
- const retrievalPriority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
1288
- const routeBonus = incomingRouteBonus(graph, node.id);
1289
- const memoryBonus = Math.min(12, Math.max(0, retrievalPriority / 10));
1290
- const headingBonus = node.type === 'heading' ? 2 : 0;
1291
- const score = Number((best.score + routeBonus + memoryBonus + headingBonus).toFixed(1));
1292
- const reasons = [best.aliasKind === 'heading' ? 'heading_alias' : 'path_alias'];
1293
- if (routeBonus) reasons.push('readme_routed');
1294
- if (memoryBonus) reasons.push('memory_priority');
1295
- const candidate = { id: node.id, type: node.type, path, title: node.title, score, matchedAlias: best.alias, reasons, retrieval: node.retrieval, memoryArea: node.memoryArea };
1296
- const previous = byId.get(node.id);
1297
- if (!previous || candidate.score > previous.score) byId.set(node.id, candidate);
1298
- }
1299
- const candidates = [...byId.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
1300
- const shown = candidates.slice(0, limit);
1301
- 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) };
1302
- }
1303
-
1304
- function parseReferenceOptions(argv, command) {
1305
- const filtered = [];
1306
- const options = { maxResults: DEFAULT_REFERENCE_LIMIT, includeArchive: false, withImpact: false, fuzzy: false };
1307
- for (let i = 0; i < argv.length; i += 1) {
1308
- const arg = argv[i];
1309
- if (arg === '--max-results') {
1310
- const next = argv[i + 1];
1311
- if (!next || next.startsWith('-') || Number.isNaN(Number.parseInt(next, 10))) usage('Missing or invalid value for --max-results.', command);
1312
- options.maxResults = Math.max(1, Number.parseInt(next, 10));
1313
- i += 1;
1314
- } else if (arg === '--include-archive') options.includeArchive = true;
1315
- else if (arg === '--with-impact') options.withImpact = true;
1316
- else if (arg === '--fuzzy') {
1317
- if (command !== 'expand') usage(`Unknown option: ${arg}`, command);
1318
- options.fuzzy = true;
1319
- }
1320
- else if (arg === '--json') filtered.push(arg);
1321
- else if (arg.startsWith('-')) usage(`Unknown option: ${arg}`, command);
1322
- else filtered.push(arg);
1323
- }
1324
- const operands = filtered.filter((arg) => !arg.startsWith('-'));
1325
- return { ...options, root: resolve(operands[0] ?? '.'), json: filtered.includes('--json'), rootArgv: operands.slice(1) };
1326
- }
1327
-
1328
- function attachImpactToRef(index, refResult) {
1329
- const topPath = refResult.top?.path;
1330
- if (!topPath) return refResult;
1331
- const impact = buildCompactImpactReport(buildImpactReport(index, topPath));
1332
- return { ...refResult, impact: { changed: topPath, related: impact.related, groups: impact.groups, omittedRelated: impact.omittedRelated, quality: impact.quality } };
1333
- }
1334
-
1335
- function formatReferenceOutput(payload) {
1336
- const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
1337
- const lines = [`${payload.command}: ${payload.refs.length} reference(s) (${payload.status.status}${refreshNote} index)`];
1338
- for (const ref of payload.refs) {
1339
- const marker = ref.ambiguous ? ' ambiguous' : '';
1340
- lines.push(`- ${ref.query || ref.input}:${marker} ${ref.candidates.length} candidate(s), ${ref.omitted} omitted`);
1341
- for (const candidate of ref.candidates) {
1342
- const title = candidate.title ? `#${candidate.title}` : '';
1343
- lines.push(` - ${candidate.path}${title} (${candidate.score}; ${candidate.reasons.join(', ')})`);
1344
- }
1345
- }
1346
- return lines.join('\n');
1347
- }
1348
-
1349
- export function runResolve(argv) {
1350
- const options = parseReferenceOptions(argv, 'resolve');
1351
- const ref = options.rootArgv?.join(' ');
1352
- if (!ref) usage('Missing required argument: <ref>.', 'resolve');
1353
- const { status, index, metadata } = readFreshIndex(options.root);
1354
- const refs = [resolveReferenceCandidates(index, ref, options)];
1355
- const payload = { ok: status.ok, command: 'resolve', root: options.root, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
1356
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1357
- else console.log(formatReferenceOutput(payload));
1358
- }
1359
-
1360
- export function runExpand(argv) {
1361
- const options = parseReferenceOptions(argv, 'expand');
1362
- const prompt = options.rootArgv?.join(' ');
1363
- if (!prompt) usage('Missing required argument: <prompt>.', 'expand');
1364
- const refsInPrompt = extractBracketReferences(prompt);
1365
- if (refsInPrompt.length === 0 && !options.fuzzy) usage('No [[refs]] found in prompt.', 'expand');
1366
- const { status, index, metadata } = readFreshIndex(options.root);
1367
- const fuzzyRefs = options.fuzzy ? extractFuzzyReferences(prompt, index, { ...options, memoryConfig: readMemoryConfig(options.root), existingTargets: refsInPrompt.map((item) => item.target) }) : [];
1368
- let refs = [
1369
- ...refsInPrompt.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'explicit', raw: item.raw, label: item.label })),
1370
- ...fuzzyRefs.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'fuzzy', raw: item.raw, confidence: item.confidence, reasons: item.reasons })),
1371
- ];
1372
- if (options.withImpact) refs = refs.map((item) => attachImpactToRef(index, item));
1373
- const payload = { ok: status.ok, command: 'expand', root: options.root, prompt, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
1374
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1375
- else console.log(formatReferenceOutput(payload));
1376
- }
1377
-
1378
- function collectDocsMarkdownFiles(root) {
1379
- const docs = join(root, 'docs');
1380
- const files = [];
1381
- const walk = (dir) => {
1382
- if (!existsSync(dir)) return;
1383
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1384
- const path = join(dir, entry.name);
1385
- if (entry.isDirectory()) walk(path);
1386
- else if (entry.isFile() && entry.name.endsWith('.md')) files.push(path);
1387
- }
1388
- };
1389
- walk(docs);
1390
- return files;
1391
- }
1392
-
1393
- export function parseTraceabilityOptions(argv) {
1394
- const sub = argv[0];
1395
- const action = argv[1];
1396
- if (sub !== 'links') usage(sub ? `Unknown traceability command: ${sub}` : 'Missing traceability command.', 'traceability');
1397
- const options = { root: '.', check: false, write: false, json: false };
1398
- for (let i = 1; i < argv.length; i += 1) {
1399
- const arg = argv[i];
1400
- if (arg === '--check') options.check = true;
1401
- else if (arg === '--write') options.write = true;
1402
- else if (arg === '--json') options.json = true;
1403
- else if (!arg.startsWith('-')) options.root = arg;
1404
- else usage(`Unknown option: ${arg}`, 'traceability links');
1405
- }
1406
- if (action === '--check' || action === '--write' || action === '--json') {
1407
- // no-op: action slot is an option, not a positional subcommand.
1408
- }
1409
- if (options.check && options.write) usage('Choose only one traceability links mode: --check or --write.', 'traceability links');
1410
- options.check = options.check || !options.write;
1411
- options.root = resolve(options.root);
1412
- return options;
1413
- }
1414
-
1415
- export function runTraceability(argv) {
1416
- const options = parseTraceabilityOptions(argv);
1417
- const files = collectDocsMarkdownFiles(options.root);
1418
- const results = [];
1419
- const errors = [];
1420
- for (const file of files) {
1421
- const content = readFileSync(file, 'utf8');
1422
- for (const error of validateTraceabilityLinksRegion(content, options.root, file)) errors.push(error);
1423
- const blocks = extractDotdotgodTraceabilityBlocks(content).filter((block) => !block.error);
1424
- if (blocks.length === 0) continue;
1425
- const result = syncTraceabilityLinksInContent(content, blocks.at(-1).data, options.root, file);
1426
- if (!result.ok) {
1427
- for (const error of result.errors ?? []) errors.push(error);
1428
- continue;
1429
- }
1430
- if (options.write && result.changed) writeFileSync(file, result.content);
1431
- if (result.changed) results.push({ file: rel(options.root, file), changed: true });
1432
- }
1433
- const ok = errors.length === 0 && (!options.check || results.length === 0);
1434
- const payload = { ok, command: 'traceability links', root: options.root, mode: options.write ? 'write' : 'check', changed: results.length, files: results, errors };
1435
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1436
- else if (errors.length > 0) {
1437
- for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}`);
1438
- console.log(`\n❌ ${errors.length} traceability link error(s)`);
1439
- } else if (options.check && results.length > 0) {
1440
- for (const result of results) console.log(`${result.file} [TRACEABILITY_LINKS_STALE] generated traceability links are missing or stale.`);
1441
- console.log(`\n❌ ${results.length} traceability link file(s) need sync`);
1442
- } else if (options.write) console.log(`traceability links: synced ${results.length} file(s)`);
1443
- else console.log('traceability links: all generated sections are up to date');
1444
- process.exit(ok ? 0 : 1);
1445
- }
1446
-
1447
- export function parseGraphOptions(argv) {
1448
- const filtered = [];
1449
- let changed;
1450
- let compact = false;
1451
- let yml = false;
1452
- for (let i = 0; i < argv.length; i += 1) {
1453
- if (argv[i] === '--changed') {
1454
- const next = argv[i + 1];
1455
- if (next && !next.startsWith('-')) {
1456
- changed = next;
1457
- i += 1;
1458
- }
1459
- } else if (argv[i] === '--compact') compact = true;
1460
- else if (argv[i] === '--yml' || argv[i] === '--yaml') yml = true;
1461
- else filtered.push(argv[i]);
1462
- }
1463
- const options = parseCommon(filtered);
1464
- options.changed = changed;
1465
- options.compact = compact;
1466
- options.yml = yml;
1467
- return options;
1468
- }
1469
-
1470
- export function runGraph(argv) {
1471
- const sub = argv[0];
1472
- const isImpact = sub === 'impact';
1473
- if (!['impact', 'communities'].includes(sub)) usage(sub ? `Unknown graph command: ${sub}` : 'Missing graph command.', 'graph');
1474
- const options = parseGraphOptions(argv.slice(1));
1475
- if (isImpact && [options.json, options.compact, options.yml].filter(Boolean).length > 1) {
1476
- const message = 'Choose only one graph impact output mode: --compact, --json, or --yml/--yaml.';
1477
- if (options.json) console.log(JSON.stringify({ ok: false, command: 'graph impact', compact: options.compact || undefined, yml: options.yml || undefined, root: options.root, error: { code: 'OUTPUT_MODE_CONFLICT', message }, usage: commandUsage('graph impact') }, null, 2));
1478
- else if (options.yml) console.log(formatYmlGraphImpactError(options, message, 'OUTPUT_MODE_CONFLICT'));
1479
- else usage(message, 'graph impact');
1480
- process.exit(2);
1481
- }
1482
- if (isImpact && !options.changed) {
1483
- const message = 'Missing required option: --changed <path>. Run `dotdotgod graph impact <root> --changed <path>`.';
1484
- if (options.json) {
1485
- 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));
1486
- process.exit(2);
1487
- }
1488
- if (options.yml) {
1489
- console.log(formatYmlGraphImpactError(options, message, 'MISSING_CHANGED'));
1490
- process.exit(2);
1491
- }
1492
- usage(message, 'graph impact');
1493
- }
1494
- const { status, index, metadata } = readFreshIndex(options.root);
1495
- const rawImpact = isImpact ? buildImpactReport(index, options.changed) : undefined;
1496
- const impact = isImpact && (options.compact || options.yml) ? buildCompactImpactReport(rawImpact) : rawImpact;
1497
- const payload = isImpact
1498
- ? { ok: status.ok, command: 'graph impact', compact: options.compact || undefined, root: options.root, status, metadata, changed: options.changed, related: impact.related, impact }
1499
- : { ok: status.ok, command: 'graph communities', root: options.root, status, metadata, graph: graphSummary(index), communities: buildCommunities(index) };
1500
- const refreshNote = metadata.cacheRefreshed ? ', refreshed' : '';
1501
- if (options.json) console.log(JSON.stringify(payload, null, 2));
1502
- else if (isImpact && options.yml) console.log(formatYmlImpactOutput(payload, impact));
1503
- else if (isImpact && options.compact) console.log(formatCompactImpactOutput(payload, impact));
1504
- else if (isImpact) console.log(`graph impact: ${payload.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${status.status}${refreshNote} index)`);
1505
- else console.log(`graph communities: ${payload.communities.communities.length}/${payload.communities.total} shown, ${payload.communities.omitted} omitted (${status.status}${refreshNote} index)`);
1506
- }
1507
-
1508
- export function runCli(argv = process.argv.slice(2)) {
23
+ export { isReadmeIndexPath, retrievalMetadataForPath, fileNodeMetadata } from './graph/metadata.mjs';
24
+ export { extractMarkdownGraph, extractPackageGraph, buildGraph } from './graph/extract.mjs';
25
+ export { addDeterministicSemanticEdges } from './graph/semantic.mjs';
26
+ export { buildIndex, writeIndex, readIndex, getStatus, runIndex, runStatus, readFreshIndex, graphSummary } from './index/cache.mjs';
27
+ export { runValidate } from './validate/run.mjs';
28
+ export { buildImpactReport, buildCompactImpactReport, neighborhood } from './impact/report.mjs';
29
+ export { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from './impact/format.mjs';
30
+ export { runConfig } from './commands/config.mjs';
31
+ export { runLoadSnapshot } from './commands/load-snapshot.mjs';
32
+ export { extractBracketReferences, extractFuzzyReferences, normalizeReferenceAlias } from './reference/extract.mjs';
33
+ export { resolveReferenceCandidates, runResolve, runExpand } from './reference/resolve.mjs';
34
+ export { parseTraceabilityOptions, runTraceability } from './commands/traceability.mjs';
35
+ export { parseGraphOptions, runGraph } from './commands/graph.mjs';
36
+ export { buildPlanValidationRepairPrompt, createPlanStageCheckpoint, formatPlanValidationText, PLAN_REQUIRED_HEADERS, PLAN_STAGE_DIRECTORIES, resolvePlanValidationStage, runPlan, validatePlanArtifact } from './commands/plan.mjs';
37
+ export { planTrelloDryRun, runTrelloSync } from './trello/sync.mjs';
38
+ export { parseTrelloMetadata, extractTrelloShortLink } from './trello/metadata.mjs';
39
+ export { normalizeGitHubRemote, normalizeGitHubRepositoryKey, resolveGitHubFileUrl, resolveGitHubRepositoryIdentity } from './trello/github-url.mjs';
40
+ export { buildLinkedDocsData, summarizeTraceability } from './trello/summary.mjs';
41
+ export { formatTrelloDryRunReport, formatTrelloWriteReport } from './trello/report.mjs';
42
+ export { createTrelloClient, resolveTrelloCredentials } from './trello/client.mjs';
43
+ export { buildLinkedDocsCustomFieldData, customFieldTextValue, DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME, dotdotgodCustomFieldName, mergeLinkedDocsCustomFieldValue, parseLinkedDocsCustomFieldPayload, serializeLinkedDocsCustomFieldPayload } from './trello/custom-fields.mjs';
44
+
45
+ export async function runCli(argv = process.argv.slice(2)) {
1509
46
  const [command = 'help', ...args] = argv;
1510
47
  if (isVersionToken(command)) printVersion();
1511
48
  if (command === 'help') usage('', helpCommandFromArgs(args));
@@ -1521,5 +58,14 @@ export function runCli(argv = process.argv.slice(2)) {
1521
58
  else if (command === 'expand') runExpand(args);
1522
59
  else if (command === 'traceability') runTraceability(args);
1523
60
  else if (command === 'graph') runGraph(args);
1524
- else usage(`Unknown command: ${command}`);
61
+ else if (command === 'plan') runPlan(args);
62
+ else if (command === 'trello') {
63
+ const result = await Promise.resolve(runTrelloSync(args));
64
+ if (result.stdout) process.stdout.write(result.stdout);
65
+ if (result.stderr) {
66
+ console.error(result.stderr);
67
+ console.error(commandUsage('trello'));
68
+ }
69
+ process.exit(result.exitCode);
70
+ } else usage(`Unknown command: ${command}`);
1525
71
  }