@dotdotgod/cli 0.2.15 → 0.2.18

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.
@@ -23,18 +23,11 @@ function roundScore(value) {
23
23
  return Math.round(value * 10) / 10;
24
24
  }
25
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);
26
+ function roundProbability(value) {
27
+ return Math.round(value * 1000000) / 1000000;
34
28
  }
35
29
 
36
30
  export function buildPersonalizedPageRank(graph, seeds, policy) {
37
- if (policy.ppr.enabled === false) return new Map();
38
31
  const seedList = Array.isArray(seeds) ? [...new Set(seeds)] : [seeds];
39
32
  const seedSet = new Set(seedList);
40
33
  const restart = seedList.length > 0 ? 1 / seedList.length : 0;
@@ -45,7 +38,7 @@ export function buildPersonalizedPageRank(graph, seeds, policy) {
45
38
  for (const seed of seedList) ids.add(seed);
46
39
  const adjacency = new Map([...ids].map((id) => [id, []]));
47
40
  for (const edge of graph.edges) {
48
- const weight = policy.relationWeights[edge.relation] ?? relationWeight(edge.relation);
41
+ const weight = edge.weight ?? policy.relationWeights[edge.relation] ?? relationWeight(edge.relation);
49
42
  if (weight <= 0) continue;
50
43
  if (!adjacency.has(edge.source)) adjacency.set(edge.source, []);
51
44
  if (!adjacency.has(edge.target)) adjacency.set(edge.target, []);
@@ -68,29 +61,33 @@ export function buildPersonalizedPageRank(graph, seeds, policy) {
68
61
  return ranks;
69
62
  }
70
63
 
71
- export function scoreImpactItem(item, seeds, changedPaths, policy, pprScores, maxPpr) {
64
+ export function scoreImpactItem(item, seeds, changedPaths, policy, pprScores) {
72
65
  const seedSet = seeds instanceof Set ? seeds : new Set(Array.isArray(seeds) ? seeds : [seeds]);
73
66
  const paths = Array.isArray(changedPaths) ? changedPaths : [changedPaths];
74
- if (seedSet.has(item.id)) return { impactScore: 100, scoreBreakdown: { seed: 100, ppr: 40, traceability: 0, memoryPolicy: 0, verification: 0, proximity: 0, semantic: 0, freshness: 0, archivePenalty: 0 } };
75
- const reasons = item.reasons ?? [];
67
+ const archiveSeeded = paths.some((path) => path.startsWith('docs/archive/'));
68
+ if (seedSet.has(item.id)) return { impactScore: 100, scoreBreakdown: { seed: 100, connection: { ppr: 80, reference: policy.ppr.reference }, memory: { priority: 20, policyAdjustments: 0 } } };
76
69
  const retrieval = item.retrieval ?? {};
77
- const pprNormalized = maxPpr > 0 ? (pprScores.get(item.id) ?? 0) / maxPpr : 0;
78
- const ppr = clamp(pprNormalized * (policy.weights.ppr ?? 40), 0, Math.abs(policy.weights.ppr ?? 40));
79
- const traceability = cappedTraceabilityScore(reasons, policy.traceabilityBoosts, Math.abs(policy.weights.traceability ?? 30));
80
- const memoryPolicy = clamp(((retrieval.priority ?? 30) / 100) * Math.abs(policy.weights.memoryPolicy ?? 10), 0, Math.abs(policy.weights.memoryPolicy ?? 10));
81
- 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));
82
- const proximity = clamp(sumReasonBoosts(reasons, policy.proximityBoosts), 0, Math.abs(policy.weights.proximity ?? 10));
83
- const semantic = clamp(sumReasonBoosts(reasons, policy.semanticBoosts), 0, Math.abs(policy.weights.semantic ?? 10));
84
- const freshness = retrieval.freshness === 'fresh' ? Math.abs(policy.weights.freshness ?? 5) : retrieval.freshness === 'stale' ? -Math.abs(policy.weights.freshness ?? 5) : 0;
85
- let archivePenalty = 0;
86
- if (!paths.some((path) => path.startsWith('docs/archive/'))) {
87
- if (retrieval.area === 'archive-body') archivePenalty -= Math.abs(policy.weights.archivePenalty ?? -25);
88
- if (retrieval.includeBodiesByDefault === false) archivePenalty -= 10;
89
- if (retrieval.freshness === 'stale' && retrieval.area !== 'archive-map') archivePenalty -= 5;
90
- }
91
- archivePenalty = clamp(archivePenalty, -Math.abs(policy.weights.archivePenalty ?? -25), 0);
92
- const impactScore = clamp(ppr + traceability + memoryPolicy + verification + proximity + semantic + freshness + archivePenalty, 0, 100);
93
- 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) } };
70
+ const probability = pprScores.get(item.id) ?? 0;
71
+ const connection = clamp((probability / policy.ppr.reference) * policy.connectionCap, 0, policy.connectionCap);
72
+ const priority = clamp(((retrieval.priority ?? 30) / 100) * 15, 0, 15);
73
+ let policyAdjustments = retrieval.freshness === 'fresh' ? 5 : retrieval.freshness === 'stale' ? -5 : 0;
74
+ if (retrieval.includeBodiesByDefault === false && !archiveSeeded) policyAdjustments -= 5;
75
+ const memory = clamp(priority + policyAdjustments, 0, policy.memoryCap);
76
+ const reasons = item.reasons ?? [];
77
+ const strongestReason = (values) => [...values].sort((a, b) => {
78
+ const weightDelta = (policy.relationWeights[baseImpactReason(b)] ?? relationWeight(baseImpactReason(b))) - (policy.relationWeights[baseImpactReason(a)] ?? relationWeight(baseImpactReason(a)));
79
+ return weightDelta || a.localeCompare(b);
80
+ })[0];
81
+ const strongestDirectRelation = strongestReason(reasons.filter((reason) => reason !== 'changed-file'));
82
+ const impactScore = clamp(connection + memory, 0, 100);
83
+ return {
84
+ impactScore: roundScore(impactScore),
85
+ scoreBreakdown: {
86
+ connection: { ppr: roundScore(connection), probability: roundProbability(probability), reference: policy.ppr.reference },
87
+ memory: { priority: roundScore(priority), policyAdjustments: roundScore(policyAdjustments) },
88
+ ...(strongestDirectRelation ? { strongestDirectRelation } : {}),
89
+ },
90
+ };
94
91
  }
95
92
 
96
93
  const CURATED_IMPACT_REASONS = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', 'links_to', 'routes_to', 'belongs_to_area']);
@@ -109,7 +106,7 @@ function isCuratedImpactReason(reason = '') {
109
106
  }
110
107
 
111
108
  export function hasCuratedImpactReason(item) {
112
- return (item.reasons ?? []).some((reason) => isCuratedImpactReason(reason));
109
+ return item.hasCuratedEvidence === true || (item.reasons ?? []).some((reason) => isCuratedImpactReason(reason));
113
110
  }
114
111
 
115
112
  export function isSemanticOnlyImpactItem(item) {
@@ -121,15 +118,6 @@ export function isLowActionabilityImpactItem(item) {
121
118
  return LOW_ACTIONABILITY_IMPACT_TYPES.has(item.type);
122
119
  }
123
120
 
124
- function impactSelectionScore(item) {
125
- let score = item.impactScore ?? 0;
126
- if (hasCuratedImpactReason(item)) score += 4;
127
- if (item.type === 'file' || item.type === 'test' || item.type === 'verification_command') score += 2;
128
- if (isSemanticOnlyImpactItem(item)) score -= 12;
129
- if (isLowActionabilityImpactItem(item)) score -= 15;
130
- return score;
131
- }
132
-
133
121
  export function compareImpactItems(seeds) {
134
122
  const seedList = Array.isArray(seeds) ? seeds : [seeds];
135
123
  const seedOrder = new Map(seedList.map((seed, index) => [seed, index]));
@@ -141,6 +129,6 @@ export function compareImpactItems(seeds) {
141
129
  if (bSeed === undefined) return -1;
142
130
  return aSeed - bSeed;
143
131
  }
144
- return impactSelectionScore(b) - impactSelectionScore(a) || (b.impactScore ?? 0) - (a.impactScore ?? 0) || a.id.localeCompare(b.id);
132
+ return (b.impactScore ?? 0) - (a.impactScore ?? 0) || a.id.localeCompare(b.id);
145
133
  };
146
134
  }
@@ -0,0 +1,49 @@
1
+ import { buildVectorIndex, rankVectorFiles } from '../commands/query.mjs';
2
+ import { embedTexts } from '../query/embedder.mjs';
3
+ import { VECTOR_MODEL } from '../query/store.mjs';
4
+ import { buildChangedFileProfile, canonicalizeChangedPath } from './vector-profile.mjs';
5
+
6
+ export const VECTOR_RELATION_WEIGHT = 2;
7
+ export const MAX_VECTOR_EVIDENCE_HEADING_CHARS = 160;
8
+ export const MAX_VECTOR_EVIDENCE_CHUNK_ID_CHARS = 200;
9
+
10
+ function boundedEvidence(value, limit) {
11
+ return typeof value === 'string' ? value.slice(0, limit) : undefined;
12
+ }
13
+
14
+ export async function buildVectorImpactOverlay(root, index, changedPaths, options = {}) {
15
+ const semantic = index?.memoryConfig?.impactRanking?.semantic ?? {};
16
+ if (semantic.enabled === false || semantic.topKPerFile === 0) return { status: 'disabled', edges: [], diagnostics: { enabled: false } };
17
+ const embed = options.embed ?? embedTexts;
18
+ const threshold = semantic.threshold ?? 0.5;
19
+ const topK = semantic.topKPerFile ?? 5;
20
+ try {
21
+ const vectorIndex = await (options.buildIndex ?? buildVectorIndex)(root, embed);
22
+ const edges = [];
23
+ const normalizedChangedPaths = [...new Set(changedPaths.map((path) => canonicalizeChangedPath(root, path)).filter(Boolean))];
24
+ for (const changed of normalizedChangedPaths) {
25
+ const profile = buildChangedFileProfile(root, changed, index?.graph);
26
+ if (!profile) continue;
27
+ const [queryVector] = await embed([`query: ${profile.text}`]);
28
+ if (!queryVector) throw new Error('Changed-file embedding was empty.');
29
+ const matches = rankVectorFiles(queryVector, vectorIndex)
30
+ .filter((match) => match.path !== changed && match.vectorScore >= threshold)
31
+ .slice(0, topK);
32
+ for (const match of matches) {
33
+ edges.push({
34
+ source: `file:${changed}`,
35
+ target: `file:${match.path}`,
36
+ relation: 'vector_similarity',
37
+ weight: VECTOR_RELATION_WEIGHT * match.vectorScore,
38
+ score: Number(match.vectorScore.toFixed(6)),
39
+ confidence: 'INFERRED_VECTOR_SEMANTIC',
40
+ heading: boundedEvidence(match.heading, MAX_VECTOR_EVIDENCE_HEADING_CHARS),
41
+ chunkId: boundedEvidence(match.id, MAX_VECTOR_EVIDENCE_CHUNK_ID_CHARS),
42
+ });
43
+ }
44
+ }
45
+ return { status: 'available', edges, diagnostics: { enabled: true, model: VECTOR_MODEL, corpusChunks: vectorIndex.chunks.length, threshold, topK, embedded: vectorIndex.manifest?.embedded, reused: vectorIndex.manifest?.reused } };
46
+ } catch (error) {
47
+ return { status: 'unavailable', edges: [], diagnostics: { enabled: true, reason: error instanceof Error ? error.message : String(error) } };
48
+ }
49
+ }
@@ -0,0 +1,87 @@
1
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { extname, relative, resolve, sep } from 'node:path';
3
+ import { isSecretLikePathPattern } from '../memory/config.mjs';
4
+
5
+ export const MAX_VECTOR_PROFILE_FILE_BYTES = 64 * 1024;
6
+ export const MAX_VECTOR_PROFILE_CHARS = 4000;
7
+ export const MAX_VECTOR_PROFILE_METADATA_ITEMS = 20;
8
+
9
+ const TEXT_EXTENSIONS = new Set(['.c', '.cc', '.cpp', '.css', '.go', '.h', '.hpp', '.html', '.java', '.js', '.json', '.jsx', '.md', '.mjs', '.py', '.rb', '.rs', '.sh', '.ts', '.tsx', '.txt', '.yaml', '.yml']);
10
+ const GENERATED_SEGMENTS = new Set(['.dotdotgod', 'build', 'coverage', 'dist', 'node_modules']);
11
+
12
+ function insideRoot(root, absolute) {
13
+ const normalizedRoot = resolve(root);
14
+ return absolute === normalizedRoot || absolute.startsWith(`${normalizedRoot}${sep}`);
15
+ }
16
+
17
+ export function normalizeChangedPath(path) {
18
+ const normalized = String(path ?? '').replaceAll('\\', '/').replace(/^\.\/+/, '');
19
+ if (!normalized || normalized === '.') return null;
20
+ return normalized;
21
+ }
22
+
23
+ export function canonicalizeChangedPath(root, path) {
24
+ const normalized = normalizeChangedPath(path);
25
+ if (!normalized) return null;
26
+ const rootAbsolute = resolve(root);
27
+ const absolute = resolve(rootAbsolute, normalized);
28
+ if (!insideRoot(rootAbsolute, absolute)) return normalized;
29
+ try {
30
+ const canonicalRoot = realpathSync(rootAbsolute);
31
+ const canonicalAbsolute = realpathSync(absolute);
32
+ if (!insideRoot(canonicalRoot, canonicalAbsolute)) return normalized;
33
+ const canonical = relative(canonicalRoot, canonicalAbsolute).replaceAll('\\', '/');
34
+ return canonical || normalized;
35
+ } catch {
36
+ return relative(rootAbsolute, absolute).replaceAll('\\', '/') || normalized;
37
+ }
38
+ }
39
+
40
+ function unsafeProfilePath(path) {
41
+ return isSecretLikePathPattern(path) || path.split('/').some((segment) => GENERATED_SEGMENTS.has(segment));
42
+ }
43
+
44
+ function graphMetadata(graph, seedId) {
45
+ const nodeById = new Map((graph.nodes ?? []).map((node) => [node.id, node]));
46
+ const values = [];
47
+ for (const edge of graph.edges ?? []) {
48
+ if (edge.source !== seedId) continue;
49
+ if (!['contains_heading', 'declares_package', 'declares_bin', 'includes_resource', 'depends_on'].includes(edge.relation)) continue;
50
+ const target = nodeById.get(edge.target);
51
+ const value = target?.title ?? target?.name ?? target?.target ?? target?.id;
52
+ if (value) values.push(`${edge.relation}: ${value}`);
53
+ if (values.length === MAX_VECTOR_PROFILE_METADATA_ITEMS) break;
54
+ }
55
+ return values;
56
+ }
57
+
58
+ function safeTextPrefix(root, path) {
59
+ if (unsafeProfilePath(path) || !TEXT_EXTENSIONS.has(extname(path).toLowerCase())) return '';
60
+ try {
61
+ const absolute = resolve(root, path);
62
+ if (!insideRoot(root, absolute)) return '';
63
+ const canonicalAbsolute = realpathSync(absolute);
64
+ if (!insideRoot(realpathSync(root), canonicalAbsolute)) return '';
65
+ const canonicalPath = relative(realpathSync(root), canonicalAbsolute).replaceAll('\\', '/');
66
+ if (canonicalPath !== path || unsafeProfilePath(canonicalPath) || !TEXT_EXTENSIONS.has(extname(canonicalPath).toLowerCase())) return '';
67
+ const stats = lstatSync(canonicalAbsolute);
68
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.size > MAX_VECTOR_PROFILE_FILE_BYTES) return '';
69
+ const buffer = readFileSync(canonicalAbsolute);
70
+ if (buffer.includes(0)) return '';
71
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
72
+ return text.slice(0, MAX_VECTOR_PROFILE_CHARS);
73
+ } catch {
74
+ return '';
75
+ }
76
+ }
77
+
78
+ export function buildChangedFileProfile(root, path, graph = { nodes: [], edges: [] }) {
79
+ const normalized = canonicalizeChangedPath(root, path);
80
+ if (!normalized || normalized === '..' || normalized.startsWith('../') || normalized.includes('/../') || unsafeProfilePath(normalized)) return null;
81
+ const metadata = graphMetadata(graph, `file:${normalized}`);
82
+ const text = safeTextPrefix(root, normalized);
83
+ const sections = [`Path: ${normalized}`];
84
+ if (metadata.length) sections.push(`Graph metadata:\n${metadata.join('\n')}`);
85
+ if (text) sections.push(`Text:\n${text}`);
86
+ return { path: normalized, text: sections.join('\n\n').slice(0, MAX_VECTOR_PROFILE_CHARS), metadataItems: metadata.length, sourceTextIncluded: Boolean(text) };
87
+ }
@@ -7,7 +7,6 @@ import { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shar
7
7
  import { cacheFile, collectIndexFiles, fingerprint } from './files.mjs';
8
8
  import { CACHE_VERSION } from './constants.mjs';
9
9
  import { buildGraph } from '../graph/extract.mjs';
10
- import { addDeterministicSemanticEdges } from '../graph/semantic.mjs';
11
10
 
12
11
  function collectFingerprints(root, config = readMemoryConfig(root)) {
13
12
  return collectIndexFiles(root, config).map((file) => {
@@ -45,7 +44,7 @@ export function buildIndex(root, previous = readIndex(root)) {
45
44
  const fullRebuild = !previous?.graph || previous.version !== CACHE_VERSION;
46
45
  const refreshReason = !previous ? 'missing' : previous.version !== CACHE_VERSION ? 'schema-mismatch' : removedPaths.length > 0 ? 'content-removed' : changedPaths.length > 0 ? 'content-changed' : 'fresh';
47
46
  const rawGraph = fullRebuild ? buildGraph(root, files.map((file) => join(root, file.path)), memoryConfig) : mergeIncrementalGraph(previous.graph, buildGraph(root, changedFiles, memoryConfig), changedPaths);
48
- const graph = addDeterministicSemanticEdges(rawGraph, memoryConfig);
47
+ const graph = rawGraph;
49
48
  const archiveBodiesIncluded = (memoryConfig.areas ?? []).some((area) => area.id === 'archive-body' && area.includeBodiesByDefault !== false);
50
49
  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 } };
51
50
  }
@@ -1,3 +1,3 @@
1
- export const CACHE_VERSION = 11;
1
+ export const CACHE_VERSION = 13;
2
2
  export const CACHE_DIR = '.dotdotgod';
3
3
  export const MANIFEST_FILE = 'manifest.json';
@@ -6,9 +6,16 @@ import { isKebabCase } from '../docs/markdown.mjs';
6
6
  const MEMORY_CONFIG_FILE = 'dotdotgod.config.json';
7
7
  const MEMORY_SCOPES = new Set(['shared', 'local']);
8
8
  const MEMORY_FRESHNESS = new Set(['fresh', 'stale']);
9
+ export const DEFAULT_TRACEABILITY_KEYS = [
10
+ { key: 'implementedBy', label: 'Implemented by', description: 'Files that implement the behavior.', target: 'path', relation: 'implemented_by', weight: 4 },
11
+ { key: 'verifiedBy', label: 'Verified by', description: 'Tests or maintained verification documents.', target: 'path', relation: 'verified_by', weight: 4 },
12
+ { key: 'relatedDocs', label: 'Related docs', description: 'Documents needed to interpret the behavior.', target: 'path', relation: 'related_doc', weight: 3 },
13
+ { key: 'verificationCommands', label: 'Verification commands', description: 'Project-local verification commands.', target: 'command', relation: 'verification_command', weight: 3 },
14
+ ];
9
15
  export const DEFAULT_TRACEABILITY_POLICY = {
10
16
  required: ['docs/spec/**'],
11
17
  exclude: ['**/README.md'],
18
+ keys: DEFAULT_TRACEABILITY_KEYS,
12
19
  };
13
20
  export const DEFAULT_VALIDATION_POLICY = {
14
21
  markdown: {
@@ -32,37 +39,27 @@ export const DEFAULT_PLAN_MODE_POLICY = {
32
39
  writablePaths: ['docs/plan/**', 'docs/archive/**'],
33
40
  };
34
41
  export const DEFAULT_IMPACT_RANKING_POLICY = {
35
- preset: 'balanced',
36
- weights: { ppr: 40, traceability: 30, memoryPolicy: 10, verification: 15, proximity: 10, semantic: 10, freshness: 5, archivePenalty: -25 },
37
- ppr: { enabled: true, damping: 0.85, iterations: 20, tolerance: 0.000001 },
42
+ connectionCap: 80,
43
+ memoryCap: 20,
44
+ ppr: { damping: 0.85, iterations: 20, tolerance: 0.000001, reference: 0.4 },
38
45
  relationWeights: {
39
- implemented_by: 4,
40
- verified_by: 4,
41
- related_doc: 3,
42
- verification_command: 3,
43
46
  links_to: 2,
47
+ routes_to: 2,
44
48
  belongs_to_area: 2,
45
- semantic_similarity: 2,
46
- mentions_package: 1,
49
+ vector_similarity: 2,
50
+ includes_resource: 1,
51
+ defines_contract: 2,
47
52
  },
48
- traceabilityBoosts: { implemented_by: 30, 'incoming:implemented_by': 30, verified_by: 25, 'incoming:verified_by': 25, verification_command: 15, 'incoming:verification_command': 15, related_doc: 12, 'incoming:related_doc': 12 },
49
- verificationBoosts: { verified_by: 15, 'incoming:verified_by': 15, verification_command: 12, 'incoming:verification_command': 12 },
50
- semanticBoosts: { semantic_similarity: 8, 'incoming:semantic_similarity': 8, mentions_package: 4, 'incoming:mentions_package': 4 },
51
- proximityBoosts: { links_to: 6, 'incoming:links_to': 6, routes_to: 5, 'incoming:routes_to': 5 },
52
- semantic: { enabled: true, threshold: 0.5, topKPerFile: 5, includeArchiveBodies: false, signals: ['path', 'filename', 'heading', 'package'] },
53
- };
54
- const IMPACT_RANKING_PRESETS = {
55
- balanced: {},
56
- 'docs-first': { weights: { ppr: 35, traceability: 35, memoryPolicy: 15, verification: 15, proximity: 5, semantic: 8, freshness: 5, archivePenalty: -30 } },
57
- 'code-proximity': { weights: { ppr: 45, traceability: 20, memoryPolicy: 8, verification: 12, proximity: 20, semantic: 8, freshness: 3, archivePenalty: -25 } },
58
- 'test-focused': { weights: { ppr: 35, traceability: 25, memoryPolicy: 8, verification: 25, proximity: 10, semantic: 7, freshness: 5, archivePenalty: -25 } },
59
- 'archive-aware': { weights: { ppr: 35, traceability: 25, memoryPolicy: 10, verification: 15, proximity: 10, semantic: 8, freshness: 3, archivePenalty: -10 } },
53
+ semantic: { enabled: true, threshold: 0.5, topKPerFile: 5 },
60
54
  };
61
- export const SEMANTIC_RELATIONS = new Set(['semantic_similarity', 'mentions_package']);
62
- const IMPACT_RANKING_WEIGHT_KEYS = new Set(['ppr', 'traceability', 'memoryPolicy', 'verification', 'proximity', 'semantic', 'freshness', 'archivePenalty']);
63
- const IMPACT_RANKING_RELATION_KEYS = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', 'links_to', 'belongs_to_area', 'semantic_similarity', 'mentions_package']);
64
- const IMPACT_RANKING_REASON_KEYS = new Set(['implemented_by', 'incoming:implemented_by', 'verified_by', 'incoming:verified_by', 'verification_command', 'incoming:verification_command', 'related_doc', 'incoming:related_doc', 'semantic_similarity', 'incoming:semantic_similarity', 'mentions_package', 'incoming:mentions_package', 'links_to', 'incoming:links_to', 'routes_to', 'incoming:routes_to']);
65
- const SEMANTIC_SIGNAL_KEYS = new Set(['path', 'filename', 'heading', 'package']);
55
+ export const SEMANTIC_RELATIONS = new Set(['vector_similarity']);
56
+ const TRACEABILITY_TARGETS = new Set(['path', 'command']);
57
+ const TRACEABILITY_RESERVED_KEYS = new Set(['kind', 'contracts', 'id', 'title', 'sections']);
58
+ export const BUILT_IN_GRAPH_RELATIONS = new Set([
59
+ 'belongs_to_area', 'includes_resource', 'defines_contract', 'contains_heading', 'links_to', 'routes_to',
60
+ 'declares_package', 'declares_script', 'declares_bin', 'depends_on', 'vector_similarity',
61
+ ]);
62
+ const TRACEABILITY_RESERVED_RELATIONS = BUILT_IN_GRAPH_RELATIONS;
66
63
  const DEFAULT_FUZZY_LOW_SIGNAL_TERMS = [
67
64
  'a', 'an', 'and', 'are', 'as', 'by', 'docs', 'document', 'for', 'from', 'it', 'of', 'on', 'plan', 'test', 'the', 'to', 'update', 'version', 'with',
68
65
  '계획', '문서', '수정', '업데이트', '버전', '정보', '확인', '테스트',
@@ -109,13 +106,22 @@ function cloneArea(area) {
109
106
  }, area);
110
107
  }
111
108
 
109
+ function cloneTraceabilityKey(definition) {
110
+ return { key: definition.key, label: definition.label, description: definition.description, target: definition.target, relation: definition.relation, weight: definition.weight };
111
+ }
112
+
112
113
  export function cloneTraceabilityPolicy(policy = DEFAULT_TRACEABILITY_POLICY) {
113
114
  return {
114
115
  required: [...(policy.required ?? [])],
115
116
  exclude: [...(policy.exclude ?? [])],
117
+ keys: (policy.keys ?? DEFAULT_TRACEABILITY_KEYS).map(cloneTraceabilityKey),
116
118
  };
117
119
  }
118
120
 
121
+ export function traceabilityRelationWeights(policy = DEFAULT_TRACEABILITY_POLICY) {
122
+ return Object.fromEntries((policy.keys ?? DEFAULT_TRACEABILITY_KEYS).map((definition) => [definition.relation, definition.weight]));
123
+ }
124
+
119
125
  export function cloneValidationPolicy(policy = DEFAULT_VALIDATION_POLICY) {
120
126
  const filename = policy.markdown?.filename;
121
127
  return {
@@ -132,15 +138,11 @@ export function cloneValidationPolicy(policy = DEFAULT_VALIDATION_POLICY) {
132
138
 
133
139
  export function cloneImpactRankingPolicy(policy = DEFAULT_IMPACT_RANKING_POLICY) {
134
140
  return {
135
- preset: policy.preset ?? 'balanced',
136
- weights: { ...DEFAULT_IMPACT_RANKING_POLICY.weights, ...(policy.weights ?? {}) },
137
- ppr: { ...DEFAULT_IMPACT_RANKING_POLICY.ppr, ...(policy.ppr ?? {}) },
138
- relationWeights: { ...DEFAULT_IMPACT_RANKING_POLICY.relationWeights, ...(policy.relationWeights ?? {}) },
139
- traceabilityBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.traceabilityBoosts, ...(policy.traceabilityBoosts ?? {}) },
140
- verificationBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.verificationBoosts, ...(policy.verificationBoosts ?? {}) },
141
- semanticBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.semanticBoosts, ...(policy.semanticBoosts ?? {}) },
142
- proximityBoosts: { ...DEFAULT_IMPACT_RANKING_POLICY.proximityBoosts, ...(policy.proximityBoosts ?? {}) },
143
- semantic: { ...DEFAULT_IMPACT_RANKING_POLICY.semantic, ...(policy.semantic ?? {}), signals: [...(policy.semantic?.signals ?? DEFAULT_IMPACT_RANKING_POLICY.semantic.signals)] },
141
+ connectionCap: DEFAULT_IMPACT_RANKING_POLICY.connectionCap,
142
+ memoryCap: DEFAULT_IMPACT_RANKING_POLICY.memoryCap,
143
+ ppr: { ...DEFAULT_IMPACT_RANKING_POLICY.ppr },
144
+ relationWeights: { ...DEFAULT_IMPACT_RANKING_POLICY.relationWeights },
145
+ semantic: { ...DEFAULT_IMPACT_RANKING_POLICY.semantic, ...(policy.semantic ?? {}) },
144
146
  };
145
147
  }
146
148
 
@@ -169,9 +171,16 @@ function normalizeReferenceExpansionPolicy(raw) {
169
171
  }
170
172
 
171
173
  function normalizeImpactRankingPolicy(raw) {
172
- const presetName = typeof raw?.preset === 'string' ? raw.preset : 'balanced';
173
- const preset = IMPACT_RANKING_PRESETS[presetName] ?? IMPACT_RANKING_PRESETS.balanced;
174
- return cloneImpactRankingPolicy({ ...preset, ...raw, preset: presetName, weights: { ...(preset.weights ?? {}), ...(raw?.weights ?? {}) }, ppr: { ...(preset.ppr ?? {}), ...(raw?.ppr ?? {}) }, semantic: { ...(preset.semantic ?? {}), ...(raw?.semantic ?? {}) } });
174
+ const candidate = raw && typeof raw === 'object' && !Array.isArray(raw)
175
+ && raw.semantic && typeof raw.semantic === 'object' && !Array.isArray(raw.semantic)
176
+ ? raw.semantic
177
+ : {};
178
+ const semantic = {
179
+ ...(typeof candidate.enabled === 'boolean' ? { enabled: candidate.enabled } : {}),
180
+ ...(isFiniteNumberInRange(candidate.threshold, 0, 1) ? { threshold: candidate.threshold } : {}),
181
+ ...(Number.isInteger(candidate.topKPerFile) && candidate.topKPerFile >= 0 && candidate.topKPerFile <= 20 ? { topKPerFile: candidate.topKPerFile } : {}),
182
+ };
183
+ return cloneImpactRankingPolicy({ semantic });
175
184
  }
176
185
 
177
186
  export function cloneLoadPolicy(policy = DEFAULT_LOAD_POLICY) {
@@ -237,6 +246,7 @@ function normalizeTraceabilityPolicy(raw) {
237
246
  return {
238
247
  required: Array.isArray(raw.required) ? raw.required.map(normalizePathPattern) : [],
239
248
  exclude: Array.isArray(raw.exclude) ? raw.exclude.map(normalizePathPattern) : [],
249
+ keys: (raw.keys === undefined ? DEFAULT_TRACEABILITY_KEYS : raw.keys).map(cloneTraceabilityKey),
240
250
  };
241
251
  }
242
252
 
@@ -274,8 +284,8 @@ function normalizeLoadPolicy(raw) {
274
284
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneLoadPolicy();
275
285
  const normalize = (values) => (Array.isArray(values) ? [...new Set(values.map(normalizePathPattern))] : []);
276
286
  return cloneLoadPolicy({
277
- pinnedPaths: normalize(raw.pinnedPaths),
278
- pinnedBodies: normalize(raw.pinnedBodies),
287
+ pinnedPaths: [],
288
+ pinnedBodies: [],
279
289
  documentationSummary: {
280
290
  exclude: raw.documentationSummary?.exclude === undefined
281
291
  ? DEFAULT_LOAD_POLICY.documentationSummary.exclude
@@ -314,10 +324,6 @@ function isFiniteNumberInRange(value, min, max) {
314
324
  return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
315
325
  }
316
326
 
317
- function validateNumberMap(map, keys, min, max) {
318
- return map && typeof map === 'object' && !Array.isArray(map) && Object.entries(map).every(([key, value]) => keys.has(key) && isFiniteNumberInRange(value, min, max));
319
- }
320
-
321
327
  export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.config.json') {
322
328
  const errors = [];
323
329
  const add = (code, field, message, fix = null) => errors.push({
@@ -338,6 +344,26 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
338
344
  else if (traceability.required.some((value) => !isValidPathPattern(value))) add('TRACEABILITY_CONFIG_INVALID_REQUIRED', 'traceability.required', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
339
345
  if (traceability.exclude !== undefined && !Array.isArray(traceability.exclude)) add('TRACEABILITY_CONFIG_INVALID_EXCLUDE', 'traceability.exclude', 'Expected an array of path strings.');
340
346
  else if (Array.isArray(traceability.exclude) && traceability.exclude.some((value) => !isValidPathPattern(value))) add('TRACEABILITY_CONFIG_INVALID_EXCLUDE', 'traceability.exclude', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
347
+ if (traceability.keys !== undefined && !Array.isArray(traceability.keys)) add('TRACEABILITY_CONFIG_INVALID_KEYS', 'traceability.keys', 'Expected an array of traceability key definitions.');
348
+ else if (Array.isArray(traceability.keys)) {
349
+ const keys = new Set();
350
+ const labels = new Set();
351
+ const relations = new Set();
352
+ for (const [index, definition] of traceability.keys.entries()) {
353
+ const prefix = `traceability.keys[${index}]`;
354
+ if (!definition || typeof definition !== 'object' || Array.isArray(definition)) { add('TRACEABILITY_CONFIG_INVALID_KEY', prefix, 'Expected an object.'); continue; }
355
+ const allowed = new Set(['key', 'label', 'description', 'target', 'relation', 'weight']);
356
+ if (Object.keys(definition).some((key) => !allowed.has(key))) add('TRACEABILITY_CONFIG_INVALID_KEY', prefix, 'Contains unsupported fields.');
357
+ if (typeof definition.key !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(definition.key) || TRACEABILITY_RESERVED_KEYS.has(definition.key) || keys.has(definition.key)) add('TRACEABILITY_CONFIG_INVALID_KEY', `${prefix}.key`, 'Expected a unique non-reserved identifier.'); else keys.add(definition.key);
358
+ const trimmedLabel = typeof definition.label === 'string' ? definition.label.trim() : '';
359
+ const normalizedLabel = trimmedLabel.toLocaleLowerCase();
360
+ if (!normalizedLabel || /[\u0000-\u001f\u007f]/.test(trimmedLabel) || labels.has(normalizedLabel)) add('TRACEABILITY_CONFIG_INVALID_LABEL', `${prefix}.label`, 'Expected a unique single-line non-empty label after trimming and case normalization.'); else labels.add(normalizedLabel);
361
+ if (typeof definition.description !== 'string' || !definition.description.trim()) add('TRACEABILITY_CONFIG_INVALID_DESCRIPTION', `${prefix}.description`, 'Expected a non-empty description.');
362
+ if (!TRACEABILITY_TARGETS.has(definition.target)) add('TRACEABILITY_CONFIG_INVALID_TARGET', `${prefix}.target`, 'Expected "path" or "command".');
363
+ if (typeof definition.relation !== 'string' || !/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(definition.relation) || TRACEABILITY_RESERVED_RELATIONS.has(definition.relation) || relations.has(definition.relation)) add('TRACEABILITY_CONFIG_INVALID_RELATION', `${prefix}.relation`, 'Expected a unique canonical snake_case relation not reserved by the graph.'); else relations.add(definition.relation);
364
+ if (!isFiniteNumberInRange(definition.weight, 0, 20)) add('TRACEABILITY_CONFIG_INVALID_WEIGHT', `${prefix}.weight`, 'Expected a finite number from 0 to 20.');
365
+ }
366
+ }
341
367
  }
342
368
  }
343
369
  const validation = data.validation;
@@ -371,15 +397,6 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
371
397
  if (!load || typeof load !== 'object' || Array.isArray(load)) {
372
398
  add('LOAD_CONFIG_INVALID', 'load', 'Expected an object.');
373
399
  } else {
374
- for (const [key, code] of [['pinnedPaths', 'LOAD_CONFIG_INVALID_PINNED_PATHS'], ['pinnedBodies', 'LOAD_CONFIG_INVALID_PINNED_BODIES']]) {
375
- const values = load[key];
376
- if (values === undefined) continue;
377
- if (!Array.isArray(values)) add(code, `load.${key}`, 'Expected an array of path strings.');
378
- else {
379
- if (values.some((value) => !isValidPathPattern(value) || (typeof value === 'string' && value.trim().startsWith('/')))) add(code, `load.${key}`, 'Expected repository-relative path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
380
- if (values.some((value) => typeof value === 'string' && isSecretLikePathPattern(value))) add('LOAD_CONFIG_SECRET_PINNED_PATH', `load.${key}`, 'Pinned load paths must not target secrets, credentials, environment files, or private keys.');
381
- }
382
- }
383
400
  if (load.documentationSummary !== undefined) {
384
401
  const summary = load.documentationSummary;
385
402
  if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
@@ -421,40 +438,6 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
421
438
  }
422
439
  }
423
440
  }
424
- const impactRanking = data.impactRanking;
425
- if (impactRanking !== undefined) {
426
- if (!impactRanking || typeof impactRanking !== 'object' || Array.isArray(impactRanking)) {
427
- add('IMPACT_RANKING_CONFIG_INVALID', 'impactRanking', 'Expected an object.');
428
- } else {
429
- if (impactRanking.preset !== undefined && !Object.hasOwn(IMPACT_RANKING_PRESETS, impactRanking.preset)) add('IMPACT_RANKING_CONFIG_INVALID_PRESET', 'impactRanking.preset', 'Expected one of balanced, docs-first, code-proximity, test-focused, or archive-aware.');
430
- if (impactRanking.weights !== undefined && !validateNumberMap(impactRanking.weights, IMPACT_RANKING_WEIGHT_KEYS, -100, 100)) add('IMPACT_RANKING_CONFIG_INVALID_WEIGHTS', 'impactRanking.weights', 'Expected known numeric weight keys with finite values from -100 to 100.');
431
- if (impactRanking.relationWeights !== undefined && !validateNumberMap(impactRanking.relationWeights, IMPACT_RANKING_RELATION_KEYS, 0, 20)) add('IMPACT_RANKING_CONFIG_INVALID_RELATION_WEIGHTS', 'impactRanking.relationWeights', 'Expected known relation keys with finite values from 0 to 20.');
432
- for (const key of ['traceabilityBoosts', 'verificationBoosts', 'semanticBoosts', 'proximityBoosts']) {
433
- if (impactRanking[key] !== undefined && !validateNumberMap(impactRanking[key], IMPACT_RANKING_REASON_KEYS, 0, 100)) add('IMPACT_RANKING_CONFIG_INVALID_BOOSTS', `impactRanking.${key}`, 'Expected known reason keys with finite values from 0 to 100.');
434
- }
435
- if (impactRanking.ppr !== undefined) {
436
- const ppr = impactRanking.ppr;
437
- if (!ppr || typeof ppr !== 'object' || Array.isArray(ppr)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr', 'Expected an object.');
438
- else {
439
- if (ppr.enabled !== undefined && typeof ppr.enabled !== 'boolean') add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.enabled', 'Expected a boolean.');
440
- if (ppr.damping !== undefined && !isFiniteNumberInRange(ppr.damping, 0.01, 0.99)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.damping', 'Expected a number greater than 0 and less than 1.');
441
- if (ppr.iterations !== undefined && (!Number.isInteger(ppr.iterations) || ppr.iterations < 1 || ppr.iterations > 100)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.iterations', 'Expected an integer from 1 to 100.');
442
- if (ppr.tolerance !== undefined && !isFiniteNumberInRange(ppr.tolerance, 0, 1)) add('IMPACT_RANKING_CONFIG_INVALID_PPR', 'impactRanking.ppr.tolerance', 'Expected a number from 0 to 1.');
443
- }
444
- }
445
- if (impactRanking.semantic !== undefined) {
446
- const semantic = impactRanking.semantic;
447
- if (!semantic || typeof semantic !== 'object' || Array.isArray(semantic)) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic', 'Expected an object.');
448
- else {
449
- if (semantic.enabled !== undefined && typeof semantic.enabled !== 'boolean') add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.enabled', 'Expected a boolean.');
450
- if (semantic.threshold !== undefined && !isFiniteNumberInRange(semantic.threshold, 0, 1)) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.threshold', 'Expected a number from 0 to 1.');
451
- if (semantic.topKPerFile !== undefined && (!Number.isInteger(semantic.topKPerFile) || semantic.topKPerFile < 0 || semantic.topKPerFile > 20)) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.topKPerFile', 'Expected an integer from 0 to 20.');
452
- if (semantic.includeArchiveBodies !== undefined && typeof semantic.includeArchiveBodies !== 'boolean') add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.includeArchiveBodies', 'Expected a boolean.');
453
- if (semantic.signals !== undefined && (!Array.isArray(semantic.signals) || semantic.signals.some((value) => !SEMANTIC_SIGNAL_KEYS.has(value)))) add('IMPACT_RANKING_CONFIG_INVALID_SEMANTIC', 'impactRanking.semantic.signals', 'Expected an array of known deterministic signal names.');
454
- }
455
- }
456
- }
457
- }
458
441
  const areas = data.memory?.areas;
459
442
  if (areas === undefined) return errors;
460
443
  if (!Array.isArray(areas)) {
@@ -540,7 +523,7 @@ export function defaultDotdotgodConfigData() {
540
523
  },
541
524
  traceability: cloneTraceabilityPolicy(config.traceability),
542
525
  validation: cloneValidationPolicy(config.validation),
543
- impactRanking: cloneImpactRankingPolicy(config.impactRanking),
526
+ impactRanking: { semantic: { ...config.impactRanking.semantic } },
544
527
  referenceExpansion: { fuzzy: { lowSignal: { add: [], remove: [] } } },
545
528
  load: cloneLoadPolicy(config.load),
546
529
  planMode: clonePlanModePolicy(config.planMode),
@@ -567,7 +550,15 @@ export function memoryConfigSummary(config) {
567
550
  }, area)),
568
551
  traceability: cloneTraceabilityPolicy(config.traceability ?? DEFAULT_TRACEABILITY_POLICY),
569
552
  validation: cloneValidationPolicy(config.validation ?? DEFAULT_VALIDATION_POLICY),
570
- impactRanking: cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY),
553
+ impactRanking: {
554
+ fixed: {
555
+ connectionCap: DEFAULT_IMPACT_RANKING_POLICY.connectionCap,
556
+ memoryCap: DEFAULT_IMPACT_RANKING_POLICY.memoryCap,
557
+ ppr: { ...DEFAULT_IMPACT_RANKING_POLICY.ppr },
558
+ relationWeights: { ...DEFAULT_IMPACT_RANKING_POLICY.relationWeights },
559
+ },
560
+ semantic: { ...cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY).semantic },
561
+ },
571
562
  referenceExpansion: cloneReferenceExpansionPolicy(config.referenceExpansion),
572
563
  load: cloneLoadPolicy(config.load),
573
564
  planMode: clonePlanModePolicy(config.planMode),