@dotdotgod/cli 0.1.30 → 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.
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { rel } from '../common/paths.mjs';
4
+ import { extractLinks } from '../docs/markdown.mjs';
5
+ import { defaultMemoryConfig } from '../memory/config.mjs';
6
+ import { addEdge, addNode } from './store.mjs';
7
+ import { addMemoryAreaMembership, addPackageResource, addTraceabilityGraph, fileNodeMetadata, headingId, isReadmeIndexPath } from './metadata.mjs';
8
+
9
+ export function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig()) {
10
+ const path = rel(root, file);
11
+ const content = readFileSync(file, 'utf8');
12
+ const fileId = `file:${path}`;
13
+ const headingRe = /^(#{1,6})\s+(.+)$/gm;
14
+ let match;
15
+ while ((match = headingRe.exec(content)) !== null) {
16
+ const title = match[2].trim();
17
+ const id = headingId(path, title);
18
+ addNode(graph, id, 'heading', { path, title, depth: match[1].length });
19
+ addEdge(graph, fileId, id, 'contains_heading', { confidence: 'EXTRACTED' });
20
+ }
21
+ for (const { href, line } of extractLinks(content)) {
22
+ const pathPart = href.split('#')[0];
23
+ if (!pathPart) continue;
24
+ const targetPath = rel(root, resolve(dirname(file), pathPart));
25
+ const targetId = `file:${targetPath}`;
26
+ addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
27
+ addEdge(graph, fileId, targetId, 'links_to', { line, confidence: 'EXTRACTED' });
28
+ if (isReadmeIndexPath(path)) addEdge(graph, fileId, targetId, 'routes_to', { line, confidence: 'CURATED_INDEX', sourceRole: 'readme-index' });
29
+ }
30
+ addTraceabilityGraph(root, fileId, content, graph, config);
31
+ }
32
+
33
+ export function extractPackageGraph(root, file, graph) {
34
+ const path = rel(root, file);
35
+ const fileId = `file:${path}`;
36
+ let pkg;
37
+ try { pkg = JSON.parse(readFileSync(file, 'utf8')); } catch { return; }
38
+ if (pkg.name) {
39
+ const pkgId = `package:${pkg.name}`;
40
+ addNode(graph, pkgId, 'package', { name: pkg.name, path });
41
+ addEdge(graph, fileId, pkgId, 'declares_package', { confidence: 'EXTRACTED' });
42
+ }
43
+ for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
44
+ const id = `script:${path}#${name}`;
45
+ addNode(graph, id, 'script', { name, command, path });
46
+ addEdge(graph, fileId, id, 'declares_script', { confidence: 'EXTRACTED' });
47
+ }
48
+ for (const [name, target] of Object.entries(typeof pkg.bin === 'string' ? { [pkg.name ?? 'bin']: pkg.bin } : pkg.bin ?? {})) {
49
+ const id = `bin:${name}`;
50
+ addNode(graph, id, 'binary', { name, target, path });
51
+ addEdge(graph, fileId, id, 'declares_bin', { confidence: 'EXTRACTED' });
52
+ addPackageResource(graph, fileId, path, name, target, 'bin');
53
+ }
54
+ for (const [index, target] of (Array.isArray(pkg.files) ? pkg.files : []).entries()) addPackageResource(graph, fileId, path, `files:${index}`, target, 'files');
55
+ for (const [kind, value] of Object.entries(pkg.pi ?? {})) {
56
+ for (const [index, target] of (Array.isArray(value) ? value : [value]).entries()) addPackageResource(graph, fileId, path, `pi:${kind}:${index}`, target, `pi:${kind}`);
57
+ }
58
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) {
59
+ for (const name of Object.keys(pkg[section] ?? {})) {
60
+ const id = `dependency:${name}`;
61
+ addNode(graph, id, 'dependency', { name });
62
+ addEdge(graph, fileId, id, 'depends_on', { section, confidence: 'EXTRACTED' });
63
+ }
64
+ }
65
+ }
66
+
67
+ export function buildGraph(root, files, config = defaultMemoryConfig()) {
68
+ const graph = { nodes: [], edges: [] };
69
+ for (const file of files) {
70
+ const path = rel(root, file);
71
+ const fileId = `file:${path}`;
72
+ addNode(graph, fileId, 'file', fileNodeMetadata(path, file, config));
73
+ addMemoryAreaMembership(graph, fileId, path, config);
74
+ if (path.endsWith('.md')) extractMarkdownGraph(root, file, graph, config);
75
+ else if (basename(file) === 'package.json') extractPackageGraph(root, file, graph);
76
+ }
77
+ return graph;
78
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, extname, resolve } from 'node:path';
3
+ import { rel } from '../common/paths.mjs';
4
+ import { headingToAnchor } from '../docs/markdown.mjs';
5
+ import { extractDotdotgodTraceabilityBlocks, isLocalRelativeTraceabilityPath } from '../docs/traceability.mjs';
6
+ import { defaultMemoryConfig, resolveMemoryArea } from '../memory/config.mjs';
7
+ import { addEdge, addNode } from './store.mjs';
8
+
9
+ export function isReadmeIndexPath(path = '') {
10
+ return basename(path) === 'README.md';
11
+ }
12
+
13
+ function retrievalSignalsForPath(path = '', config = defaultMemoryConfig()) {
14
+ const signals = [];
15
+ const definition = resolveMemoryArea(path, config);
16
+ if (definition) signals.push('memory-area', `scope:${definition.scope}`, `freshness:${definition.freshness}`);
17
+ if (path.startsWith('docs/')) signals.push('docs-path');
18
+ if (isReadmeIndexPath(path)) signals.push('readme-index');
19
+ return signals;
20
+ }
21
+
22
+ export function retrievalMetadataForPath(path = '', config = defaultMemoryConfig()) {
23
+ const definition = resolveMemoryArea(path, config);
24
+ return {
25
+ area: definition?.id,
26
+ role: definition?.role,
27
+ priority: definition?.priority ?? 30,
28
+ scope: definition?.scope,
29
+ freshness: definition?.freshness,
30
+ includeBodiesByDefault: definition?.includeBodiesByDefault,
31
+ signals: retrievalSignalsForPath(path, config),
32
+ };
33
+ }
34
+
35
+ export function fileNodeMetadata(path, file, config = defaultMemoryConfig()) {
36
+ const retrieval = retrievalMetadataForPath(path, config);
37
+ return {
38
+ path,
39
+ extension: file ? extname(file) : extname(path),
40
+ memoryArea: retrieval.area,
41
+ memoryRole: retrieval.role,
42
+ memoryScope: retrieval.scope,
43
+ memoryFreshness: retrieval.freshness,
44
+ retrievalPriority: retrieval.priority,
45
+ retrieval,
46
+ };
47
+ }
48
+
49
+ export function addMemoryAreaMembership(graph, fileId, path, config = defaultMemoryConfig()) {
50
+ const definition = resolveMemoryArea(path, config);
51
+ if (!definition) return;
52
+ const areaId = `memory_area:${definition.id}`;
53
+ 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 });
54
+ addEdge(graph, fileId, areaId, 'belongs_to_area', { confidence: 'DETERMINISTIC', role: definition.role, scope: definition.scope, freshness: definition.freshness });
55
+ }
56
+
57
+ export function addPackageResource(graph, fileId, packagePath, name, target, kind) {
58
+ if (!target || typeof target !== 'string') return;
59
+ const id = `package_resource:${packagePath}#${kind}:${name}`;
60
+ addNode(graph, id, 'package_resource', { name, target, kind, path: packagePath });
61
+ addEdge(graph, fileId, id, 'includes_resource', { kind, confidence: 'EXTRACTED' });
62
+ }
63
+
64
+ function addTraceabilityTarget(graph, sourceId, root, relation, targetPath, data = {}, config = defaultMemoryConfig()) {
65
+ if (!isLocalRelativeTraceabilityPath(targetPath) || !existsSync(resolve(root, targetPath))) return;
66
+ const targetId = `file:${targetPath}`;
67
+ addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
68
+ addEdge(graph, sourceId, targetId, relation, { confidence: 'CURATED_TRACEABILITY', ...data });
69
+ }
70
+
71
+ export function addTraceabilityGraph(root, fileId, content, graph, config = defaultMemoryConfig()) {
72
+ for (const block of extractDotdotgodTraceabilityBlocks(content)) {
73
+ if (block.error || !block.data || block.data.kind !== 'spec') continue;
74
+ for (const target of block.data.implementedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'implemented_by', target, {}, config);
75
+ for (const target of block.data.verifiedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'verified_by', target, {}, config);
76
+ for (const target of block.data.relatedDocs ?? []) addTraceabilityTarget(graph, fileId, root, 'related_doc', target, {}, config);
77
+ for (const [index, command] of (Array.isArray(block.data.verificationCommands) ? block.data.verificationCommands : []).entries()) {
78
+ if (typeof command !== 'string' || command.trim().length === 0) continue;
79
+ const id = `verification_command:${fileId}#${index}`;
80
+ addNode(graph, id, 'verification_command', { command, path: fileId.replace(/^file:/, '') });
81
+ addEdge(graph, fileId, id, 'verification_command', { confidence: 'CURATED_TRACEABILITY' });
82
+ }
83
+ }
84
+ }
85
+
86
+ export function headingId(path, title) {
87
+ return `heading:${path}#${headingToAnchor(title)}`;
88
+ }
@@ -0,0 +1,100 @@
1
+ import { basename } from 'node:path';
2
+ import { DEFAULT_IMPACT_RANKING_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, defaultMemoryConfig } from '../memory/config.mjs';
3
+ import { addEdge } from './store.mjs';
4
+
5
+ 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']);
6
+
7
+ function splitCamelCase(value = '') {
8
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
9
+ }
10
+
11
+ function conceptTokens(value = '') {
12
+ const tokens = new Set();
13
+ for (const raw of splitCamelCase(String(value)).toLowerCase().split(/[^a-z0-9]+/)) {
14
+ if (raw.length < 3 || TOKEN_STOPWORDS.has(raw)) continue;
15
+ tokens.add(raw);
16
+ }
17
+ return tokens;
18
+ }
19
+
20
+ function addTokens(target, value) {
21
+ for (const token of conceptTokens(value)) target.add(token);
22
+ }
23
+
24
+ function intersectTokens(a, b) {
25
+ return [...a].filter((token) => b.has(token));
26
+ }
27
+
28
+ function jaccard(a, b) {
29
+ if (a.size === 0 || b.size === 0) return 0;
30
+ const intersection = intersectTokens(a, b).length;
31
+ const denominator = Math.min(a.size, b.size);
32
+ return denominator === 0 ? 0 : intersection / denominator;
33
+ }
34
+
35
+ function semanticSignalScore(source, target) {
36
+ const path = jaccard(source.pathTokens, target.pathTokens);
37
+ const heading = Math.max(jaccard(source.headingTokens, target.headingTokens), jaccard(source.headingTokens, target.allTokens), jaccard(source.allTokens, target.headingTokens));
38
+ const pkg = Math.max(jaccard(source.packageTokens, target.packageTokens), jaccard(source.allTokens, target.packageTokens), jaccard(source.packageTokens, target.allTokens));
39
+ return { path, filename: path, heading, package: pkg };
40
+ }
41
+
42
+ function semanticProfileForFile(fileNode, graph) {
43
+ const profile = { id: fileNode.id, path: fileNode.path, retrieval: fileNode.retrieval, pathTokens: new Set(), headingTokens: new Set(), packageTokens: new Set(), allTokens: new Set() };
44
+ addTokens(profile.pathTokens, fileNode.path ?? fileNode.id);
45
+ addTokens(profile.pathTokens, basename(fileNode.path ?? ''));
46
+
47
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
48
+ for (const edge of graph.edges) {
49
+ if (edge.source !== fileNode.id) continue;
50
+ const target = nodeById.get(edge.target);
51
+ if (!target) continue;
52
+ if (edge.relation === 'contains_heading') addTokens(profile.headingTokens, target.title ?? target.id);
53
+ 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);
54
+ }
55
+ for (const set of [profile.pathTokens, profile.headingTokens, profile.packageTokens]) for (const token of set) profile.allTokens.add(token);
56
+ return profile;
57
+ }
58
+
59
+ function semanticRelationForProfiles(source, target, scores) {
60
+ if (scores.package >= 0.5) return 'mentions_package';
61
+ return 'semantic_similarity';
62
+ }
63
+
64
+ export function addDeterministicSemanticEdges(graph, config = defaultMemoryConfig()) {
65
+ const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
66
+ if (policy.semantic.enabled === false || policy.semantic.topKPerFile === 0) return graph;
67
+
68
+ const threshold = policy.semantic.threshold ?? 0.5;
69
+ const topK = policy.semantic.topKPerFile ?? 5;
70
+ const includeArchiveBodies = policy.semantic.includeArchiveBodies === true;
71
+ const enabledSignals = new Set(policy.semantic.signals ?? DEFAULT_IMPACT_RANKING_POLICY.semantic.signals);
72
+ const baseGraph = { nodes: graph.nodes.map((node) => ({ ...node })), edges: graph.edges.filter((edge) => !SEMANTIC_RELATIONS.has(edge.relation)).map((edge) => ({ ...edge })) };
73
+ const files = baseGraph.nodes.filter((node) => node.type === 'file' && node.path);
74
+ const profiles = files.map((node) => semanticProfileForFile(node, baseGraph));
75
+
76
+ for (const source of profiles) {
77
+ if (!includeArchiveBodies && source.retrieval?.area === 'archive-body') continue;
78
+ const candidates = [];
79
+ for (const target of profiles) {
80
+ if (source.id === target.id) continue;
81
+ if (!includeArchiveBodies && target.retrieval?.area === 'archive-body') continue;
82
+ const scores = semanticSignalScore(source, target);
83
+ const weighted = Object.entries(scores).filter(([signal]) => enabledSignals.has(signal));
84
+ const score = weighted.length === 0 ? 0 : Math.max(...weighted.map(([, value]) => value));
85
+ if (score < threshold) continue;
86
+ const matchedTerms = intersectTokens(source.allTokens, target.allTokens).slice(0, 12);
87
+ if (matchedTerms.length === 0) continue;
88
+ const relation = semanticRelationForProfiles(source, target, scores);
89
+ const signals = weighted.filter(([, value]) => value > 0).map(([signal]) => signal);
90
+ candidates.push({ target, relation, score, matchedTerms, signals });
91
+ }
92
+ candidates
93
+ .sort((a, b) => b.score - a.score || a.target.id.localeCompare(b.target.id))
94
+ .slice(0, topK)
95
+ .forEach((candidate) => {
96
+ addEdge(baseGraph, source.id, candidate.target.id, candidate.relation, { confidence: 'INFERRED_LEXICAL_SEMANTIC', score: Math.round(candidate.score * 1000) / 1000, matchedTerms: candidate.matchedTerms, signals: candidate.signals });
97
+ });
98
+ }
99
+ return baseGraph;
100
+ }
@@ -0,0 +1,92 @@
1
+ import { commandUsage } from '../cli/usage.mjs';
2
+
3
+ function formatCompactImpactGroup(name, group) {
4
+ const items = group?.items ?? [];
5
+ if (items.length === 0) return [];
6
+ return [
7
+ `${name}:`,
8
+ ...items.map((item) => {
9
+ const label = item.path ?? item.command ?? item.name ?? item.target ?? item.id;
10
+ const reasons = (item.reasons ?? []).slice(0, 3).join(', ');
11
+ return `- ${label} (${item.impactScore}; ${reasons})`;
12
+ }),
13
+ ];
14
+ }
15
+
16
+ export function formatCompactImpactOutput(payload, impact) {
17
+ const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
18
+ const lines = [`graph impact compact: ${impact.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${payload.status.status}${refreshNote} index)`];
19
+ for (const name of ['docs', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatCompactImpactGroup(name, impact.groups[name]));
20
+ return lines.join('\n');
21
+ }
22
+
23
+ function ymlScalar(value) {
24
+ if (value === null || value === undefined) return 'null';
25
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
26
+ return JSON.stringify(String(value));
27
+ }
28
+
29
+ function ymlList(values = []) {
30
+ return `[${values.map(ymlScalar).join(', ')}]`;
31
+ }
32
+
33
+ function formatYmlImpactItem(item) {
34
+ const label = item.path ?? item.command ?? item.name ?? item.target ?? item.id;
35
+ const lines = [` - path: ${ymlScalar(label)}`];
36
+ lines.push(` id: ${ymlScalar(item.id)}`);
37
+ lines.push(` type: ${ymlScalar(item.type)}`);
38
+ lines.push(` score: ${ymlScalar(item.impactScore)}`);
39
+ lines.push(` reasons: ${ymlList((item.reasons ?? []).slice(0, 6))}`);
40
+ if (item.retrieval?.area) lines.push(` area: ${ymlScalar(item.retrieval.area)}`);
41
+ if (item.retrieval?.role) lines.push(` role: ${ymlScalar(item.retrieval.role)}`);
42
+ return lines;
43
+ }
44
+
45
+ function formatYmlImpactGroup(name, group) {
46
+ const items = group?.items ?? [];
47
+ const lines = [` ${name}:`, ` omitted: ${group?.omitted ?? 0}`, ' items:'];
48
+ if (items.length === 0) lines.push(' []');
49
+ else for (const item of items) lines.push(...formatYmlImpactItem(item));
50
+ return lines;
51
+ }
52
+
53
+ export function formatYmlImpactOutput(payload, impact) {
54
+ const lines = [
55
+ 'impact:',
56
+ ` ok: ${payload.ok ? 'true' : 'false'}`,
57
+ ` changed: ${ymlScalar(payload.changed)}`,
58
+ ` root: ${ymlScalar(payload.root)}`,
59
+ ' output: "yml"',
60
+ ' status:',
61
+ ` index: ${ymlScalar(payload.status.status)}`,
62
+ ` cache_refreshed: ${payload.metadata.cacheRefreshed ? 'true' : 'false'}`,
63
+ ` related_count: ${impact.related.length}`,
64
+ ` omitted_related: ${impact.omittedRelated ?? 0}`,
65
+ ' ranking:',
66
+ ` method: ${ymlScalar(impact.ranking?.method)}`,
67
+ ` preset: ${ymlScalar(impact.ranking?.preset)}`,
68
+ ` config_source: ${ymlScalar(impact.ranking?.configSource)}`,
69
+ ' groups:',
70
+ ];
71
+ for (const name of ['docs', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatYmlImpactGroup(name, impact.groups[name]));
72
+ lines.push(' recommended_actions:');
73
+ lines.push(' - "review_related_docs"');
74
+ lines.push(' - "run_related_tests"');
75
+ lines.push(' - "run_dotdotgod_validate"');
76
+ return lines.join('\n');
77
+ }
78
+
79
+ export function formatYmlGraphImpactError(options, message, code = 'GRAPH_IMPACT_ERROR') {
80
+ return [
81
+ 'impact:',
82
+ ' ok: false',
83
+ ' command: "graph impact"',
84
+ ' output: "yml"',
85
+ ` compact: ${options.compact ? 'true' : 'false'}`,
86
+ ` root: ${ymlScalar(options.root)}`,
87
+ ' error:',
88
+ ` code: ${ymlScalar(code)}`,
89
+ ` message: ${ymlScalar(message)}`,
90
+ ` usage: ${ymlScalar(commandUsage('graph impact'))}`,
91
+ ].join('\n');
92
+ }
@@ -0,0 +1,126 @@
1
+ import { DEFAULT_IMPACT_RANKING_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, defaultMemoryConfig } from '../memory/config.mjs';
2
+ import { retrievalMetadataForPath } from '../graph/metadata.mjs';
3
+ import { buildPersonalizedPageRank, compareImpactItems, docsArea, hasCuratedImpactReason, isLowActionabilityImpactItem, isSemanticOnlyImpactItem, isTestPath, scoreImpactItem } from './scoring.mjs';
4
+
5
+ function addImpactItem(group, item, limit = 10) {
6
+ if (group.items.some((existing) => existing.id === item.id)) return;
7
+ if (group.items.length >= limit) {
8
+ group.omitted += 1;
9
+ return;
10
+ }
11
+ group.items.push(item);
12
+ }
13
+
14
+ function selectImpactItems(sortedItems, maxRelated, seed) {
15
+ const selected = [];
16
+ const selectedIds = new Set();
17
+ const deferred = [];
18
+ const firstPageLimit = Math.min(maxRelated, 10);
19
+ let semanticOnlyInFirstPage = 0;
20
+ let lowActionabilityInFirstPage = 0;
21
+ const firstPagePathCounts = new Map();
22
+ const add = (item, force = false) => {
23
+ if (selected.length >= maxRelated || selectedIds.has(item.id)) return;
24
+ const pathKey = item.path ?? item.id;
25
+ if (!force && item.id !== seed && selected.length < firstPageLimit) {
26
+ if (pathKey && (firstPagePathCounts.get(pathKey) ?? 0) >= 2) return deferred.push(item);
27
+ if (isLowActionabilityImpactItem(item) && lowActionabilityInFirstPage >= 2) return deferred.push(item);
28
+ if (isSemanticOnlyImpactItem(item) && semanticOnlyInFirstPage >= 3) return deferred.push(item);
29
+ }
30
+ selected.push(item);
31
+ selectedIds.add(item.id);
32
+ if (selected.length <= firstPageLimit && item.id !== seed) {
33
+ if (pathKey) firstPagePathCounts.set(pathKey, (firstPagePathCounts.get(pathKey) ?? 0) + 1);
34
+ if (isLowActionabilityImpactItem(item)) lowActionabilityInFirstPage += 1;
35
+ if (isSemanticOnlyImpactItem(item)) semanticOnlyInFirstPage += 1;
36
+ }
37
+ };
38
+ for (const item of sortedItems) if (item.id === seed) add(item, true);
39
+ for (const item of sortedItems) if (item.id !== seed) add(item);
40
+ for (const item of deferred) add(item, true);
41
+ return selected;
42
+ }
43
+
44
+ export function buildImpactReport(index, changedPath, limits = {}) {
45
+ const graph = index?.graph ?? { nodes: [], edges: [] };
46
+ const config = index?.memoryConfig ? { ...defaultMemoryConfig(), ...index.memoryConfig } : defaultMemoryConfig();
47
+ const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
48
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
49
+ const seed = `file:${changedPath}`;
50
+ const maxRelated = limits.related ?? 25;
51
+ const groups = { files: { items: [], omitted: 0 }, docs: { items: [], omitted: 0 }, tests: { items: [], omitted: 0 }, commands: { items: [], omitted: 0 }, events: { items: [], omitted: 0 }, packageResources: { items: [], omitted: 0 }, symbols: { items: [], omitted: 0 } };
52
+ const relatedIds = new Set([seed]);
53
+ const reasons = new Map([[seed, new Set(['changed-file'])]]);
54
+ const addReason = (id, reason) => {
55
+ relatedIds.add(id);
56
+ if (!reasons.has(id)) reasons.set(id, new Set());
57
+ reasons.get(id).add(reason);
58
+ };
59
+
60
+ for (const edge of graph.edges) {
61
+ if (edge.source === seed) addReason(edge.target, edge.relation);
62
+ if (edge.target === seed) addReason(edge.source, `incoming:${edge.relation}`);
63
+ }
64
+ const expansionRelations = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', ...SEMANTIC_RELATIONS]);
65
+ for (const edge of graph.edges) {
66
+ if (!expansionRelations.has(edge.relation)) continue;
67
+ if (relatedIds.has(edge.source) && edge.target !== seed) addReason(edge.target, edge.relation);
68
+ }
69
+
70
+ const pprScores = buildPersonalizedPageRank(graph, seed, policy);
71
+ const candidatePprMax = Math.max(0, ...[...relatedIds].filter((id) => id !== seed).map((id) => pprScores.get(id) ?? 0));
72
+ const relatedAll = [...relatedIds].map((id) => {
73
+ const node = nodeById.get(id) ?? { id };
74
+ const path = node.path ?? id.replace(/^file:/, '').replace(/^test:/, '');
75
+ const reasonList = [...(reasons.get(id) ?? [])];
76
+ const retrieval = node.retrieval ?? retrievalMetadataForPath(path);
77
+ const reasonSignals = reasonList.map((reason) => `reason:${reason}`);
78
+ const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seed, changedPath, policy, pprScores, candidatePprMax);
79
+ return { ...node, reasons: reasonList, retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
80
+ }).sort(compareImpactItems(seed));
81
+ const related = selectImpactItems(relatedAll, maxRelated, seed);
82
+ for (const item of related) {
83
+ if (item.type === 'file') {
84
+ const area = docsArea(item.path);
85
+ if (area) addImpactItem(groups.docs, { ...item, area }, limits.docs ?? 10);
86
+ else if (isTestPath(item.path)) addImpactItem(groups.tests, item, limits.tests ?? 10);
87
+ else addImpactItem(groups.files, item, limits.files ?? 10);
88
+ } else if (item.type === 'test') addImpactItem(groups.tests, item, limits.tests ?? 10);
89
+ else if (item.type === 'command') addImpactItem(groups.commands, item, limits.commands ?? 10);
90
+ else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
91
+ else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
92
+ }
93
+ return { changed: changedPath, 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 }, related, groups, omittedRelated: Math.max(0, relatedAll.length - related.length) };
94
+ }
95
+
96
+ function compactScoreBreakdown(scoreBreakdown = {}) {
97
+ const compact = {};
98
+ for (const [key, value] of Object.entries(scoreBreakdown)) if (value !== 0 && value !== undefined) compact[key] = value;
99
+ return compact;
100
+ }
101
+
102
+ export function compactImpactItem(item) {
103
+ const compact = { id: item.id, type: item.type, impactScore: item.impactScore, reasons: (item.reasons ?? []).slice(0, 6), scoreBreakdown: compactScoreBreakdown(item.scoreBreakdown) };
104
+ for (const key of ['path', 'area', 'name', 'command', 'target', 'kind', 'specifier', 'title']) if (item[key] !== undefined) compact[key] = item[key];
105
+ if (item.retrieval) compact.retrieval = { area: item.retrieval.area, role: item.retrieval.role, priority: item.retrieval.priority, freshness: item.retrieval.freshness };
106
+ return compact;
107
+ }
108
+
109
+ function compactImpactGroup(group = { items: [], omitted: 0 }, limit = 5) {
110
+ const items = (group.items ?? []).slice(0, limit).map(compactImpactItem);
111
+ return { items, omitted: (group.omitted ?? 0) + Math.max(0, (group.items?.length ?? 0) - items.length) };
112
+ }
113
+
114
+ export function buildCompactImpactReport(impact, limits = {}) {
115
+ const relatedLimit = limits.related ?? 10;
116
+ const groupLimit = limits.groupItems ?? 5;
117
+ const related = (impact.related ?? []).slice(0, relatedLimit).map(compactImpactItem);
118
+ const groupNames = ['files', 'docs', 'tests', 'commands', 'events', 'packageResources', 'symbols'];
119
+ const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
120
+ const top10 = (impact.related ?? []).filter((item) => item.id !== `file:${impact.changed}`).slice(0, 10);
121
+ return { changed: impact.changed, compact: true, ranking: { method: impact.ranking?.method, preset: impact.ranking?.preset, configSource: impact.ranking?.configSource }, related, groups, omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length), quality: { rawRelated: impact.related?.length ?? 0, compactRelated: related.length, semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length, curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length, lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length } };
122
+ }
123
+
124
+ export function neighborhood(index, changedPath) {
125
+ return buildImpactReport(index, changedPath).related;
126
+ }
@@ -0,0 +1,134 @@
1
+ import { SEMANTIC_RELATIONS } from '../memory/config.mjs';
2
+ import { relationWeight } from '../graph/communities.mjs';
3
+
4
+ export function isTestPath(path = '') {
5
+ return /(^|\/)(test|tests)\//.test(path) || /\.(test|spec)\.(mjs|cjs|js|jsx|ts|tsx)$/.test(path);
6
+ }
7
+
8
+ export function docsArea(path = '') {
9
+ if (path.startsWith('docs/spec/')) return 'spec';
10
+ if (path.startsWith('docs/arch/')) return 'arch';
11
+ if (path.startsWith('docs/test/')) return 'test-docs';
12
+ if (path.startsWith('docs/plan/')) return 'plan';
13
+ if (path.startsWith('docs/archive/')) return 'archive-index';
14
+ if (path.startsWith('docs/')) return 'docs';
15
+ return undefined;
16
+ }
17
+
18
+ function clamp(value, min, max) {
19
+ return Math.max(min, Math.min(max, value));
20
+ }
21
+
22
+ function roundScore(value) {
23
+ return Math.round(value * 10) / 10;
24
+ }
25
+
26
+ function sumReasonBoosts(reasons, boosts) {
27
+ return reasons.reduce((sum, reason) => sum + (boosts[reason] ?? 0), 0);
28
+ }
29
+
30
+ function cappedTraceabilityScore(reasons, boosts, cap) {
31
+ const values = reasons.map((reason) => boosts[reason] ?? 0).filter((value) => value > 0);
32
+ if (values.length === 0) return 0;
33
+ return clamp(Math.max(...values) + Math.min(5, Math.max(0, values.length - 1) * 2), 0, cap);
34
+ }
35
+
36
+ export function buildPersonalizedPageRank(graph, seed, policy) {
37
+ if (policy.ppr.enabled === false) return new Map();
38
+ const damping = policy.ppr.damping ?? 0.85;
39
+ const iterations = policy.ppr.iterations ?? 20;
40
+ const tolerance = policy.ppr.tolerance ?? 0.000001;
41
+ const ids = new Set(graph.nodes.map((node) => node.id));
42
+ ids.add(seed);
43
+ const adjacency = new Map([...ids].map((id) => [id, []]));
44
+ for (const edge of graph.edges) {
45
+ const weight = policy.relationWeights[edge.relation] ?? relationWeight(edge.relation);
46
+ if (weight <= 0) continue;
47
+ if (!adjacency.has(edge.source)) adjacency.set(edge.source, []);
48
+ if (!adjacency.has(edge.target)) adjacency.set(edge.target, []);
49
+ adjacency.get(edge.source).push([edge.target, weight]);
50
+ adjacency.get(edge.target).push([edge.source, weight]);
51
+ }
52
+ let ranks = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 : 0]));
53
+ for (let i = 0; i < iterations; i += 1) {
54
+ const next = new Map([...adjacency.keys()].map((id) => [id, id === seed ? 1 - damping : 0]));
55
+ for (const [id, edges] of adjacency.entries()) {
56
+ const rank = ranks.get(id) ?? 0;
57
+ const total = edges.reduce((sum, [, weight]) => sum + weight, 0);
58
+ if (total === 0) continue;
59
+ for (const [target, weight] of edges) next.set(target, (next.get(target) ?? 0) + damping * rank * (weight / total));
60
+ }
61
+ const delta = [...next.entries()].reduce((sum, [id, rank]) => sum + Math.abs(rank - (ranks.get(id) ?? 0)), 0);
62
+ ranks = next;
63
+ if (delta < tolerance) break;
64
+ }
65
+ return ranks;
66
+ }
67
+
68
+ export function scoreImpactItem(item, seed, changedPath, policy, pprScores, maxPpr) {
69
+ if (item.id === seed) return { impactScore: 100, scoreBreakdown: { seed: 100, ppr: 40, traceability: 0, memoryPolicy: 0, verification: 0, proximity: 0, semantic: 0, freshness: 0, archivePenalty: 0 } };
70
+ const reasons = item.reasons ?? [];
71
+ const retrieval = item.retrieval ?? {};
72
+ const pprNormalized = maxPpr > 0 ? (pprScores.get(item.id) ?? 0) / maxPpr : 0;
73
+ const ppr = clamp(pprNormalized * (policy.weights.ppr ?? 40), 0, Math.abs(policy.weights.ppr ?? 40));
74
+ const traceability = cappedTraceabilityScore(reasons, policy.traceabilityBoosts, Math.abs(policy.weights.traceability ?? 30));
75
+ const memoryPolicy = clamp(((retrieval.priority ?? 30) / 100) * Math.abs(policy.weights.memoryPolicy ?? 10), 0, Math.abs(policy.weights.memoryPolicy ?? 10));
76
+ 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));
77
+ const proximity = clamp(sumReasonBoosts(reasons, policy.proximityBoosts), 0, Math.abs(policy.weights.proximity ?? 10));
78
+ const semantic = clamp(sumReasonBoosts(reasons, policy.semanticBoosts), 0, Math.abs(policy.weights.semantic ?? 10));
79
+ const freshness = retrieval.freshness === 'fresh' ? Math.abs(policy.weights.freshness ?? 5) : retrieval.freshness === 'stale' ? -Math.abs(policy.weights.freshness ?? 5) : 0;
80
+ let archivePenalty = 0;
81
+ if (!changedPath.startsWith('docs/archive/')) {
82
+ if (retrieval.area === 'archive-body') archivePenalty -= Math.abs(policy.weights.archivePenalty ?? -25);
83
+ if (retrieval.includeBodiesByDefault === false) archivePenalty -= 10;
84
+ if (retrieval.freshness === 'stale' && retrieval.area !== 'archive-map') archivePenalty -= 5;
85
+ }
86
+ archivePenalty = clamp(archivePenalty, -Math.abs(policy.weights.archivePenalty ?? -25), 0);
87
+ const impactScore = clamp(ppr + traceability + memoryPolicy + verification + proximity + semantic + freshness + archivePenalty, 0, 100);
88
+ return { impactScore: roundScore(impactScore), scoreBreakdown: { ppr: roundScore(ppr), traceability: roundScore(traceability), memoryPolicy: roundScore(memoryPolicy), verification: roundScore(verification), proximity: roundScore(proximity), semantic: roundScore(semantic), freshness: roundScore(freshness), archivePenalty: roundScore(archivePenalty) } };
89
+ }
90
+
91
+ const CURATED_IMPACT_REASONS = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', 'links_to', 'routes_to', 'belongs_to_area']);
92
+ const LOW_ACTIONABILITY_IMPACT_TYPES = new Set(['dependency', 'package', 'script', 'binary', 'heading', 'memory_area']);
93
+
94
+ function baseImpactReason(reason = '') {
95
+ return reason.replace(/^incoming:/, '');
96
+ }
97
+
98
+ export function isSemanticImpactReason(reason = '') {
99
+ return SEMANTIC_RELATIONS.has(baseImpactReason(reason));
100
+ }
101
+
102
+ function isCuratedImpactReason(reason = '') {
103
+ return CURATED_IMPACT_REASONS.has(baseImpactReason(reason));
104
+ }
105
+
106
+ export function hasCuratedImpactReason(item) {
107
+ return (item.reasons ?? []).some((reason) => isCuratedImpactReason(reason));
108
+ }
109
+
110
+ export function isSemanticOnlyImpactItem(item) {
111
+ const reasons = item.reasons ?? [];
112
+ return reasons.length > 0 && reasons.every((reason) => isSemanticImpactReason(reason));
113
+ }
114
+
115
+ export function isLowActionabilityImpactItem(item) {
116
+ return LOW_ACTIONABILITY_IMPACT_TYPES.has(item.type);
117
+ }
118
+
119
+ function impactSelectionScore(item) {
120
+ let score = item.impactScore ?? 0;
121
+ if (hasCuratedImpactReason(item)) score += 4;
122
+ if (item.type === 'file' || item.type === 'test' || item.type === 'verification_command') score += 2;
123
+ if (isSemanticOnlyImpactItem(item)) score -= 12;
124
+ if (isLowActionabilityImpactItem(item)) score -= 15;
125
+ return score;
126
+ }
127
+
128
+ export function compareImpactItems(seed) {
129
+ return (a, b) => {
130
+ if (a.id === seed) return -1;
131
+ if (b.id === seed) return 1;
132
+ return impactSelectionScore(b) - impactSelectionScore(a) || (b.impactScore ?? 0) - (a.impactScore ?? 0) || a.id.localeCompare(b.id);
133
+ };
134
+ }