@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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ dotdotgod graph impact . --changed <path> --compact
38
38
  - `index` builds `.dotdotgod/manifest.json` and compact graph shards from maintained project files.
39
39
  - `query` locally embeds shared Markdown with `Xenova/multilingual-e5-small`, incrementally stores vectors under `.dotdotgod/vectors/`, and returns the best-ranked chunk from each relevant Markdown file.
40
40
  - `resolve` and `expand` map explicit or high-signal prompt references to project files.
41
- - `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five.
41
+ - `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five. Non-seed scores use fixed weighted PPR connection `80` plus memory policy `20`. A bounded request-local multilingual vector overlay participates in PPR when the query cache is available; vector preparation failures degrade to structural-only results.
42
42
  - `traceability links` checks or repairs generated Markdown traceability-link sections.
43
43
 
44
44
  ## Commands
@@ -85,17 +85,19 @@ $ dotdotgod graph impact . --changed packages/cli/src/core.mjs --compact
85
85
 
86
86
  ```text
87
87
  docs:
88
- - docs/spec/REFERENCE_EXPANSION.md (91; incoming:implemented_by, semantic_similarity)
89
- - docs/test/REFERENCE_EXPANSION.md (65.3; verified_by, semantic_similarity)
88
+ - docs/spec/CONFIG_COMMAND.md (56.8; incoming:links_to, incoming:implemented_by)
89
+ - docs/spec/cli/TRACEABILITY_LINKS.md (26.3; vector_similarity)
90
+
91
+ tests:
92
+ - packages/cli/test/core.test.mjs (6.5; verified_by)
90
93
 
91
94
  files:
92
95
  - packages/cli/src/core.mjs (100; changed-file)
93
- - packages/pi/extensions/plan-mode/index.ts (45; implemented_by, semantic_similarity)
94
96
  ```
95
97
 
96
98
  `graph impact` needs at least one `--changed <path>` and accepts repeated options. Multi-file output preserves input order, deduplicates repeated paths, returns a bounded combined ranking, and includes the top five non-seed results for each changed file. Use `--compact` for short text, `--yml` or `--yaml` for compact structured agent-facing output, and `--json` for machine-readable detail.
97
99
 
98
- The graph is built from maintained project files: Markdown links, README routes, headings, traceability blocks, package metadata, memory areas, imports, exports, commands, tests, and deterministic routing hints.
100
+ The indexed graph is built from maintained project files: Markdown links, README routes, headings, configured traceability relations, package metadata, scripts and packaged resources, dependencies, and memory-area membership. Impact analysis can add request-local vector edges for candidate discovery and PPR without mutating that indexed graph.
99
101
 
100
102
  ## Validation and Verification Boundaries
101
103
 
@@ -119,7 +121,7 @@ The default scaffold gives docs explicit roles:
119
121
  - `docs/archive/README.md`: local archive map included by default.
120
122
  - `docs/archive/**`: local archive bodies excluded by default unless targeted.
121
123
 
122
- Use `memory.areas` to customize memory classification and retrieval priority. Use `traceability.required` and `traceability.exclude` to customize which Markdown paths need `json dotdotgod` traceability blocks. Use `validation.markdown`, `impactRanking`, and `referenceExpansion` to tune size budgets, impact scoring, and fuzzy prompt matching.
124
+ Use `memory.areas` to customize memory classification and retrieval priority. Use `traceability.required` and `traceability.exclude` to select which Markdown paths need `json dotdotgod` blocks, and use ordered `traceability.keys` definitions to configure string-array labels, path or command targets, graph relations, and PPR weights. Use `validation.markdown` for size budgets and `referenceExpansion` for fuzzy prompt matching. The complete `impactRanking` namespace is non-blocking compatibility input: valid semantic candidate controls apply, malformed values fall back to defaults, and retired or unknown fields are ignored without changing fixed scoring. Legacy `load.pinnedPaths` and `load.pinnedBodies` values are also ignored.
123
125
 
124
126
  ## Indexing Scope
125
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.2.15",
3
+ "version": "0.2.18",
4
4
  "description": "CLI for dotdotgod validation, local documentation search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -36,9 +36,12 @@ function formatConfigOutput(payload) {
36
36
  lines.push(`- memory areas: ${payload.config.areas.length}`);
37
37
  lines.push(`- traceability required: ${(payload.config.traceability.required ?? []).join(', ') || 'none'}`);
38
38
  lines.push(`- traceability exclude: ${(payload.config.traceability.exclude ?? []).join(', ') || 'none'}`);
39
+ lines.push('- traceability keys:');
40
+ for (const definition of payload.config.traceability.keys ?? []) lines.push(` - ${definition.key}: ${definition.target} -> ${definition.relation} (weight=${definition.weight}; label=${definition.label})`);
39
41
  lines.push(`- validation markdown: maxLines=${payload.config.validation.markdown.maxLines}, maxChars=${payload.config.validation.markdown.maxChars}`);
40
42
  lines.push(`- validation markdown exclude: ${(payload.config.validation.markdown.exclude ?? []).join(', ') || 'none'}`);
41
- lines.push(`- impact ranking preset: ${payload.config.impactRanking.preset}`);
43
+ const fixedImpact = payload.config.impactRanking.fixed;
44
+ lines.push(`- impact ranking: fixed PPR=${fixedImpact.connectionCap}, memory=${fixedImpact.memoryCap}, reference=${fixedImpact.ppr.reference}`);
42
45
  lines.push(`- load pinned paths: ${(payload.config.load?.pinnedPaths ?? []).join(', ') || 'none'}`);
43
46
  lines.push(`- load pinned bodies: ${(payload.config.load?.pinnedBodies ?? []).join(', ') || 'none'}`);
44
47
  lines.push(`- load documentation summary exclude: ${(payload.config.load?.documentationSummary?.exclude ?? []).join(', ') || 'none'}`);
@@ -2,6 +2,8 @@ import { commandUsage, parseCommon, usage } from '../cli/usage.mjs';
2
2
  import { buildCommunities } from '../graph/communities.mjs';
3
3
  import { graphSummary, readFreshIndex } from '../index/cache.mjs';
4
4
  import { buildCompactImpactReport, buildImpactReport } from '../impact/report.mjs';
5
+ import { buildVectorImpactOverlay } from '../impact/vector-overlay.mjs';
6
+ import { canonicalizeChangedPath } from '../impact/vector-profile.mjs';
5
7
  import { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
6
8
 
7
9
  const MAX_CHANGED_FILES = 20;
@@ -65,8 +67,10 @@ export async function runGraph(argv) {
65
67
  }
66
68
  usage(message, 'graph impact');
67
69
  }
70
+ if (isImpact) options.changed = [...new Set(options.changed.map((path) => canonicalizeChangedPath(options.root, path)).filter(Boolean))];
68
71
  const { status, index, metadata } = readFreshIndex(options.root);
69
- const rawImpact = isImpact ? buildImpactReport(index, options.changed) : undefined;
72
+ const overlay = isImpact ? await buildVectorImpactOverlay(options.root, index, options.changed) : undefined;
73
+ const rawImpact = isImpact ? buildImpactReport(index, options.changed, { overlay, verboseSemantic: options.json }) : undefined;
70
74
  const impact = isImpact && (options.compact || options.yml) ? buildCompactImpactReport(rawImpact) : rawImpact;
71
75
  const payload = isImpact
72
76
  ? { ok: status.ok, command: 'graph impact', compact: options.compact || undefined, root: options.root, status, metadata, changed: options.changed[0], changedFiles: options.changed, related: impact.related, impact }
@@ -3,7 +3,7 @@ import { usage } from '../cli/usage.mjs';
3
3
  import { readMemoryConfig } from '../memory/config.mjs';
4
4
  import { collectDocumentationChunks } from '../query/chunks.mjs';
5
5
  import { embedTexts } from '../query/embedder.mjs';
6
- import { readVectorCache, VECTOR_DIMENSIONS, VECTOR_MODEL, writeVectorCache } from '../query/store.mjs';
6
+ import { isValidNormalizedVectorValues, readVectorCache, VECTOR_DIMENSIONS, VECTOR_MODEL, writeVectorCache } from '../query/store.mjs';
7
7
 
8
8
  const DEFAULT_LIMIT = 30;
9
9
  const MAX_LIMIT = 100;
@@ -31,10 +31,33 @@ export function parseQueryOptions(argv) {
31
31
  return options;
32
32
  }
33
33
 
34
- function cosine(left, vectors, offset) {
34
+ function assertNormalizedVector(vector, label) {
35
+ if (!vector || vector.length !== VECTOR_DIMENSIONS || !isValidNormalizedVectorValues(vector)) throw new Error(`${label} must be a finite normalized ${VECTOR_DIMENSIONS}-dimensional vector.`);
36
+ }
37
+
38
+ export function cosineScore(left, vectors, offset = 0) {
39
+ assertNormalizedVector(left, 'Query vector');
40
+ const right = vectors?.subarray ? vectors.subarray(offset, offset + VECTOR_DIMENSIONS) : vectors?.slice(offset, offset + VECTOR_DIMENSIONS);
41
+ assertNormalizedVector(right, 'Passage vector');
35
42
  let score = 0;
36
- for (let index = 0; index < VECTOR_DIMENSIONS; index += 1) score += left[index] * vectors[offset + index];
37
- return score;
43
+ for (let index = 0; index < VECTOR_DIMENSIONS; index += 1) score += left[index] * right[index];
44
+ if (!Number.isFinite(score) || score < -1.000001 || score > 1.000001) throw new Error('Cosine similarity was outside the finite normalized range.');
45
+ return Math.max(-1, Math.min(1, score));
46
+ }
47
+
48
+ export function rankVectorFiles(queryVector, index) {
49
+ assertNormalizedVector(queryVector, 'Query vector');
50
+ if (!index || !Array.isArray(index.chunks) || index.vectors?.length !== index.chunks.length * VECTOR_DIMENSIONS || !isValidNormalizedVectorValues(index.vectors)) throw new Error('Vector index contains invalid passage vectors.');
51
+ const ranked = index.chunks.map((chunk, position) => ({ ...chunk, vectorScore: cosineScore(queryVector, index.vectors, position * VECTOR_DIMENSIONS) }))
52
+ .sort((left, right) => right.vectorScore - left.vectorScore || left.path.localeCompare(right.path) || left.id.localeCompare(right.id));
53
+ const results = [];
54
+ const seen = new Set();
55
+ for (const chunk of ranked) {
56
+ if (seen.has(chunk.path)) continue;
57
+ seen.add(chunk.path);
58
+ results.push(chunk);
59
+ }
60
+ return results;
38
61
  }
39
62
 
40
63
  function lexicalBoost(query, chunk) {
@@ -66,7 +89,11 @@ export async function buildVectorIndex(root, embed = embedTexts) {
66
89
  for (let offset = 0; offset < missing.length; offset += EMBED_BATCH_SIZE) {
67
90
  const batch = missing.slice(offset, offset + EMBED_BATCH_SIZE);
68
91
  const embedded = await embed(batch.map(({ chunk }) => `passage: ${chunk.passage}`));
69
- embedded.forEach((vector, index) => vectors.set(vector, batch[index].index * VECTOR_DIMENSIONS));
92
+ if (!Array.isArray(embedded) || embedded.length !== batch.length) throw new Error('Embedder returned an unexpected passage-vector count.');
93
+ embedded.forEach((vector, index) => {
94
+ assertNormalizedVector(vector, 'Passage vector');
95
+ vectors.set(vector, batch[index].index * VECTOR_DIMENSIONS);
96
+ });
70
97
  }
71
98
  const storedChunks = chunks.map(({ passage, ...chunk }) => chunk);
72
99
  const manifest = writeVectorCache(root, storedChunks, vectors, {
@@ -81,9 +108,12 @@ export async function queryDocumentation(root, query, options = {}) {
81
108
  const embed = options.embed ?? embedTexts;
82
109
  const limit = options.limit ?? DEFAULT_LIMIT;
83
110
  const index = await buildVectorIndex(root, embed);
84
- const [queryVector] = await embed([`query: ${query}`]);
111
+ const embeddedQuery = await embed([`query: ${query}`]);
112
+ if (!Array.isArray(embeddedQuery) || embeddedQuery.length !== 1) throw new Error('Embedder returned an unexpected query-vector count.');
113
+ const [queryVector] = embeddedQuery;
114
+ assertNormalizedVector(queryVector, 'Query vector');
85
115
  const rankedChunks = index.chunks.map((chunk, position) => {
86
- const vectorScore = cosine(queryVector, index.vectors, position * VECTOR_DIMENSIONS);
116
+ const vectorScore = cosineScore(queryVector, index.vectors, position * VECTOR_DIMENSIONS);
87
117
  return { ...chunk, score: Number((vectorScore + lexicalBoost(query, chunk)).toFixed(6)), vectorScore: Number(vectorScore.toFixed(6)) };
88
118
  }).sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
89
119
  const results = [];
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path';
3
3
  import { usage } from '../cli/usage.mjs';
4
4
  import { rel } from '../common/paths.mjs';
5
5
  import { extractDotdotgodTraceabilityBlocks, syncTraceabilityLinksInContent, validateTraceabilityLinksRegion } from '../docs/traceability.mjs';
6
+ import { readMemoryConfig } from '../memory/config.mjs';
6
7
 
7
8
  function collectDocsMarkdownFiles(root) {
8
9
  const docs = join(root, 'docs');
@@ -43,15 +44,17 @@ export function parseTraceabilityOptions(argv) {
43
44
 
44
45
  export function runTraceability(argv) {
45
46
  const options = parseTraceabilityOptions(argv);
47
+ const config = readMemoryConfig(options.root);
46
48
  const files = collectDocsMarkdownFiles(options.root);
47
49
  const results = [];
48
- const errors = [];
50
+ const errors = [...(config.errors ?? [])];
49
51
  for (const file of files) {
52
+ if (options.write && errors.length > 0) break;
50
53
  const content = readFileSync(file, 'utf8');
51
54
  for (const error of validateTraceabilityLinksRegion(content, options.root, file)) errors.push(error);
52
55
  const blocks = extractDotdotgodTraceabilityBlocks(content).filter((block) => !block.error);
53
56
  if (blocks.length === 0) continue;
54
- const result = syncTraceabilityLinksInContent(content, blocks.at(-1).data, options.root, file);
57
+ const result = syncTraceabilityLinksInContent(content, blocks.at(-1).data, options.root, file, config);
55
58
  if (!result.ok) {
56
59
  for (const error of result.errors ?? []) errors.push(error);
57
60
  continue;
package/src/core.mjs CHANGED
@@ -17,16 +17,18 @@ export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_
17
17
  export { buildCommunities, relationWeight } from './graph/communities.mjs';
18
18
  export { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
19
19
  export { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
20
- export { buildMemoryAreas, buildPinnedFiles, detectCommandGuidance, detectPackageManager } from './memory/summary.mjs';
20
+ export { buildMemoryAreas, detectCommandGuidance, detectPackageManager } from './memory/summary.mjs';
21
21
  export { isReadmeIndexPath, retrievalMetadataForPath } from './graph/metadata.mjs';
22
22
  export { buildGraph } from './graph/extract.mjs';
23
23
  export { addDeterministicSemanticEdges } from './graph/semantic.mjs';
24
24
  export { buildIndex, writeIndex, readIndex, getStatus, runIndex, runStatus, readFreshIndex, graphSummary } from './index/cache.mjs';
25
25
  export { runValidate } from './validate/run.mjs';
26
26
  export { buildImpactReport, buildCompactImpactReport } from './impact/report.mjs';
27
+ export { buildChangedFileProfile, MAX_VECTOR_PROFILE_CHARS, MAX_VECTOR_PROFILE_FILE_BYTES, MAX_VECTOR_PROFILE_METADATA_ITEMS } from './impact/vector-profile.mjs';
28
+ export { buildVectorImpactOverlay, VECTOR_RELATION_WEIGHT } from './impact/vector-overlay.mjs';
27
29
  export { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from './impact/format.mjs';
28
30
  export { runConfig } from './commands/config.mjs';
29
- export { buildVectorIndex, parseQueryOptions, queryDocumentation, runQuery } from './commands/query.mjs';
31
+ export { buildVectorIndex, cosineScore, parseQueryOptions, queryDocumentation, rankVectorFiles, runQuery } from './commands/query.mjs';
30
32
  export { chunkMarkdown, collectDocumentationChunks, collectDocumentationMarkdown, textFingerprint } from './query/chunks.mjs';
31
33
  export { readVectorCache, VECTOR_DIMENSIONS, VECTOR_MODEL, VECTOR_SCHEMA_VERSION, vectorCachePaths, writeVectorCache } from './query/store.mjs';
32
34
  export { extractBracketReferences, extractFuzzyReferences, normalizeReferenceAlias } from './reference/extract.mjs';
@@ -1,20 +1,26 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { basename, dirname, extname, relative, resolve } from 'node:path';
3
3
  import { rel } from '../common/paths.mjs';
4
- import { defaultMemoryConfig, resolveMemoryArea } from '../memory/config.mjs';
4
+ import { DEFAULT_TRACEABILITY_KEYS, defaultMemoryConfig, resolveMemoryArea } from '../memory/config.mjs';
5
5
 
6
6
  function isSecretIndexPath(path) {
7
7
  return /(^|\/)(\.env|\.npmrc|\.pypirc|id_rsa|id_dsa|id_ed25519|credentials|secrets?)(\.|\/|$)/i.test(path);
8
8
  }
9
9
 
10
- const TRACEABILITY_PATH_FIELDS = ['implementedBy', 'verifiedBy', 'relatedDocs'];
11
- const TRACEABILITY_COMMAND_FIELDS = ['verificationCommands'];
12
- const CONTRACT_ALLOWED_FIELDS = new Set(['id', 'title', 'sections', ...TRACEABILITY_PATH_FIELDS, ...TRACEABILITY_COMMAND_FIELDS]);
10
+ function traceabilityKeys(config = defaultMemoryConfig()) {
11
+ return config.traceability?.keys ?? DEFAULT_TRACEABILITY_KEYS;
12
+ }
13
+
14
+ function traceabilityDefinitionMap(config = defaultMemoryConfig()) {
15
+ return new Map(traceabilityKeys(config).map((definition) => [definition.key, definition]));
16
+ }
13
17
  export const TRACEABILITY_LINKS_START = '<!-- dotdotgod:traceability-links:start version=1 source=json-dotdotgod -->';
14
18
  export const TRACEABILITY_LINKS_END = '<!-- dotdotgod:traceability-links:end -->';
15
19
 
16
- export function traceabilityExample() {
17
- return 'Expected dotdotgod traceability block:\n\n```json dotdotgod\n{\n "kind": "spec",\n "implementedBy": ["packages/..."],\n "verifiedBy": ["packages/..."],\n "relatedDocs": ["docs/..."],\n "verificationCommands": ["pnpm ..."],\n "contracts": [{\n "id": "FEATURE-BEHAVIOR-001",\n "title": "Focused behavior contract",\n "sections": ["Focused Behavior"],\n "implementedBy": ["packages/..."],\n "verifiedBy": ["packages/..."],\n "relatedDocs": ["docs/..."],\n "verificationCommands": ["pnpm ..."]\n }]\n}\n```\n\nProperty guidance:\n- kind: use "spec" for behavior specs.\n- implementedBy: source/config/script files that implement this spec\'s behavior.\n- verifiedBy: test files or verification docs that check this behavior.\n- relatedDocs: docs with relevant architecture, test strategy, or product context.\n- verificationCommands: commands an agent can run to verify this behavior.\n- contracts: optional focused behavior contracts; each contract requires non-empty id and title, may include sections as navigation hints, and may include the same path and command fields. Unknown contract fields are validation errors.';
20
+ export function traceabilityExample(config = defaultMemoryConfig()) {
21
+ const fields = traceabilityKeys(config).map((definition) => ` "${definition.key}": [${definition.target === 'command' ? '"pnpm ..."' : '"packages/..."'}]`).join(',\n');
22
+ const guidance = traceabilityKeys(config).map((definition) => `- ${definition.key}: ${definition.description}`).join('\n');
23
+ return `Expected dotdotgod traceability block:\n\n\`\`\`json dotdotgod\n{\n "kind": "spec"${fields ? `,\n${fields}` : ''}\n}\n\`\`\`\n\nProperty guidance:\n- kind: use "spec" for behavior specs.\n${guidance}${guidance ? '\n' : ''}- contracts: optional focused behavior contracts; each contract requires non-empty id and title, may include sections, and may include configured traceability fields. Unknown contract fields are validation errors.`;
18
24
  }
19
25
 
20
26
  function lineForOffset(content, offset) {
@@ -46,8 +52,8 @@ export function isLocalRelativeTraceabilityPath(value) {
46
52
  return !isSecretIndexPath(value);
47
53
  }
48
54
 
49
- function traceabilityFieldError(file, code, field, message, line = null) {
50
- return { file, line, code, message: `${field ? `Field "${field}": ` : ''}${message}\nFix: update the traceability block so it matches the expected schema and points to existing project files or commands.\n\n${traceabilityExample()}` };
55
+ function traceabilityFieldError(file, code, field, message, line = null, config = defaultMemoryConfig()) {
56
+ return { file, line, code, message: `${field ? `Field "${field}": ` : ''}${message}\nFix: update the traceability block so it matches the expected schema and points to existing project files or commands.\n\n${traceabilityExample(config)}` };
51
57
  }
52
58
 
53
59
  export function validateTraceabilityPlacement(content, root, file) {
@@ -124,12 +130,13 @@ function renderPathList(title, paths, root, file) {
124
130
  return lines;
125
131
  }
126
132
 
127
- function renderCommandList(commands) {
128
- if (!Array.isArray(commands) || commands.length === 0) return [];
129
- return ['- Verification commands:', ...commands.map((command) => ` - \`${escapeCodeSpan(command)}\``)];
133
+ function renderDefinitionList(definition, values, root, file) {
134
+ if (!Array.isArray(values) || values.length === 0) return [];
135
+ if (definition.target === 'command') return [`- ${definition.label}:`, ...values.map((command) => ` - \`${escapeCodeSpan(command)}\``)];
136
+ return renderPathList(definition.label, values, root, file);
130
137
  }
131
138
 
132
- function renderContractList(contracts) {
139
+ function renderContractList(contracts, definitions) {
133
140
  if (!Array.isArray(contracts) || contracts.length === 0) return [];
134
141
  const lines = ['- Contracts:'];
135
142
  for (const contract of contracts) {
@@ -138,23 +145,19 @@ function renderContractList(contracts) {
138
145
  const title = typeof contract.title === 'string' ? contract.title : '<missing-title>';
139
146
  const details = [];
140
147
  if (Array.isArray(contract.sections) && contract.sections.length > 0) details.push(`sections: ${contract.sections.length}`);
141
- for (const [field, label] of [['implementedBy', 'impl'], ['verifiedBy', 'verify'], ['relatedDocs', 'docs'], ['verificationCommands', 'commands']]) {
142
- if (Array.isArray(contract[field]) && contract[field].length > 0) details.push(`${label}: ${contract[field].length}`);
143
- }
148
+ for (const definition of definitions) if (Array.isArray(contract[definition.key]) && contract[definition.key].length > 0) details.push(`${definition.key}: ${contract[definition.key].length}`);
144
149
  lines.push(` - \`${escapeCodeSpan(id)}\` — ${escapeMarkdownText(title)}${details.length > 0 ? ` (${details.join(', ')})` : ''}`);
145
150
  }
146
151
  return lines;
147
152
  }
148
153
 
149
- export function renderTraceabilityLinks(data, root, file) {
154
+ export function renderTraceabilityLinks(data, root, file, config = defaultMemoryConfig()) {
155
+ const definitions = traceabilityKeys(config);
150
156
  const body = [
151
157
  '### Traceability Links',
152
158
  '',
153
- ...renderPathList('Implemented by', data?.implementedBy, root, file),
154
- ...renderPathList('Verified by', data?.verifiedBy, root, file),
155
- ...renderPathList('Related docs', data?.relatedDocs, root, file),
156
- ...renderCommandList(data?.verificationCommands),
157
- ...renderContractList(data?.contracts),
159
+ ...definitions.flatMap((definition) => renderDefinitionList(definition, data?.[definition.key], root, file)),
160
+ ...renderContractList(data?.contracts, definitions),
158
161
  ];
159
162
  return `${TRACEABILITY_LINKS_START}\n<!-- generated: do not edit manually -->\n\n${body.join('\n')}\n\n${TRACEABILITY_LINKS_END}`;
160
163
  }
@@ -163,10 +166,10 @@ export function renderCompactTraceabilityBlock(data) {
163
166
  return `\`\`\`json dotdotgod\n${JSON.stringify(data)}\n\`\`\``;
164
167
  }
165
168
 
166
- export function syncTraceabilityLinksInContent(content, data, root, file) {
169
+ export function syncTraceabilityLinksInContent(content, data, root, file, config = defaultMemoryConfig()) {
167
170
  const region = findTraceabilityLinksRegion(content);
168
171
  if (region.status === 'invalid') return { ok: false, changed: false, errors: validateTraceabilityLinksRegion(content, root, file) };
169
- const generated = renderTraceabilityLinks(data, root, file);
172
+ const generated = renderTraceabilityLinks(data, root, file, config);
170
173
  const blocks = extractDotdotgodTraceabilityBlocks(content).filter((block) => !block.error);
171
174
  if (blocks.length === 0) return { ok: false, changed: false, errors: [traceabilityFieldError(rel(root, file), 'TRACEABILITY_LINKS_MISSING_BLOCK', null, 'Cannot generate traceability links without a valid `json dotdotgod` block.')] };
172
175
  const block = blocks.at(-1);
@@ -216,12 +219,19 @@ function validateTraceabilityCommandArray(data, field, add, { required = true, d
216
219
  for (const value of data[field]) if (typeof value !== 'string' || value.trim().length === 0) add('TRACEABILITY_INVALID_COMMAND', displayField, `invalid command: ${JSON.stringify(value)}.`);
217
220
  }
218
221
 
222
+ function validateConfiguredArray(data, definition, add, root, config, options) {
223
+ if (definition.target === 'command') validateTraceabilityCommandArray(data, definition.key, add, options);
224
+ else validateTraceabilityPathArray(data, definition.key, add, root, config, options);
225
+ }
226
+
219
227
  function validateTraceabilityContracts(data, root, add, config) {
220
228
  if (data.contracts === undefined) return;
221
229
  if (!Array.isArray(data.contracts)) {
222
230
  add('TRACEABILITY_INVALID_FIELD', 'contracts', 'must be an array of focused contract objects.');
223
231
  return;
224
232
  }
233
+ const definitions = traceabilityKeys(config);
234
+ const allowedFields = new Set(['id', 'title', 'sections', ...definitions.map((definition) => definition.key)]);
225
235
  const ids = new Set();
226
236
  for (const [index, contract] of data.contracts.entries()) {
227
237
  const base = `contracts[${index}]`;
@@ -230,7 +240,7 @@ function validateTraceabilityContracts(data, root, add, config) {
230
240
  continue;
231
241
  }
232
242
  for (const field of Object.keys(contract)) {
233
- if (!CONTRACT_ALLOWED_FIELDS.has(field)) add('TRACEABILITY_INVALID_FIELD', `${base}.${field}`, 'is not a supported contract field.');
243
+ if (!allowedFields.has(field)) add('TRACEABILITY_INVALID_FIELD', `${base}.${field}`, 'is not a supported contract field.');
234
244
  }
235
245
  if (typeof contract.id !== 'string' || contract.id.trim().length === 0) {
236
246
  add('TRACEABILITY_INVALID_FIELD', `${base}.id`, 'must be a non-empty stable contract ID string.');
@@ -246,21 +256,22 @@ function validateTraceabilityContracts(data, root, add, config) {
246
256
  if (typeof section !== 'string' || section.trim().length === 0) add('TRACEABILITY_INVALID_FIELD', `${base}.sections[${sectionIndex}]`, 'must be a non-empty heading title string.');
247
257
  }
248
258
  }
249
- for (const field of TRACEABILITY_PATH_FIELDS) validateTraceabilityPathArray(contract, field, add, root, config, { required: false, displayField: `${base}.${field}` });
250
- for (const field of TRACEABILITY_COMMAND_FIELDS) validateTraceabilityCommandArray(contract, field, add, { required: false, displayField: `${base}.${field}` });
259
+ for (const definition of definitions) validateConfiguredArray(contract, definition, add, root, config, { required: false, displayField: `${base}.${definition.key}` });
251
260
  }
252
261
  }
253
262
 
254
263
  export function validateTraceabilityBlock(data, root, file, line = null, config = defaultMemoryConfig()) {
255
264
  const errors = [];
256
- const add = (code, field, message) => errors.push(traceabilityFieldError(rel(root, file), code, field, message, line));
265
+ const add = (code, field, message) => errors.push(traceabilityFieldError(rel(root, file), code, field, message, line, config));
257
266
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
258
267
  add('TRACEABILITY_INVALID_JSON', null, 'Traceability block must be a JSON object.');
259
268
  return errors;
260
269
  }
261
270
  if (data.kind !== 'spec') add('TRACEABILITY_INVALID_KIND', 'kind', 'must be "spec" for behavior specs.');
262
- for (const field of TRACEABILITY_PATH_FIELDS) validateTraceabilityPathArray(data, field, add, root, config);
263
- for (const field of TRACEABILITY_COMMAND_FIELDS) validateTraceabilityCommandArray(data, field, add);
271
+ const definitions = traceabilityKeys(config);
272
+ const allowedFields = new Set(['kind', 'contracts', ...definitions.map((definition) => definition.key)]);
273
+ for (const field of Object.keys(data)) if (!allowedFields.has(field)) add('TRACEABILITY_INVALID_FIELD', field, 'is not configured as a traceability field.');
274
+ for (const definition of definitions) validateConfiguredArray(data, definition, add, root, config, { required: true, displayField: definition.key });
264
275
  validateTraceabilityContracts(data, root, add, config);
265
276
  return errors;
266
277
  }
@@ -43,8 +43,7 @@ function addBounded(list, value, limit) {
43
43
  export function relationWeight(relation) {
44
44
  if (relation === 'implemented_by' || relation === 'verified_by') return 4;
45
45
  if (relation === 'includes_resource' || relation === 'routes_to' || relation === 'related_doc' || relation === 'verification_command') return 3;
46
- if (relation === 'links_to' || relation === 'belongs_to_area' || relation === 'declares_package' || relation === 'declares_bin' || relation === 'semantic_similarity') return 2;
47
- if (relation === 'mentions_package') return 1;
46
+ if (relation === 'links_to' || relation === 'belongs_to_area' || relation === 'declares_package' || relation === 'declares_bin' || relation === 'vector_similarity') return 2;
48
47
  return 1;
49
48
  }
50
49
 
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { basename, extname, resolve } from 'node:path';
3
4
  import { rel } from '../common/paths.mjs';
@@ -72,12 +73,21 @@ function contractNodeId(filePath, contractId) {
72
73
  return `contract:${filePath}#${contractId}`;
73
74
  }
74
75
 
75
- function addTraceabilityCommands(graph, sourceId, filePath, commands, idPrefix) {
76
- for (const [index, command] of (Array.isArray(commands) ? commands : []).entries()) {
77
- if (typeof command !== 'string' || command.trim().length === 0) continue;
78
- const id = `verification_command:${idPrefix}#${index}`;
79
- addNode(graph, id, 'verification_command', { command, path: filePath });
80
- addEdge(graph, sourceId, id, 'verification_command', { confidence: 'CURATED_TRACEABILITY' });
76
+ function traceabilityCommandId(sourceId, key, command) {
77
+ const digest = createHash('sha256').update(`${sourceId}\0${key}\0${command}`).digest('hex').slice(0, 16);
78
+ return `command:${key}:${digest}`;
79
+ }
80
+
81
+ function addTraceabilityValues(graph, sourceId, root, filePath, definition, values, config) {
82
+ for (const value of Array.isArray(values) ? values : []) {
83
+ if (definition.target === 'path') {
84
+ addTraceabilityTarget(graph, sourceId, root, definition.relation, value, { traceabilityKey: definition.key, relationWeight: definition.weight }, config);
85
+ continue;
86
+ }
87
+ if (typeof value !== 'string' || value.trim().length === 0) continue;
88
+ const id = traceabilityCommandId(sourceId, definition.key, value);
89
+ addNode(graph, id, 'command', { command: value, path: filePath, traceabilityKey: definition.key, relation: definition.relation });
90
+ addEdge(graph, sourceId, id, definition.relation, { confidence: 'CURATED_TRACEABILITY', traceabilityKey: definition.key, relationWeight: definition.weight });
81
91
  }
82
92
  }
83
93
 
@@ -91,10 +101,7 @@ function addTraceabilityContracts(graph, root, fileId, filePath, contracts, conf
91
101
  const sections = Array.isArray(contract.sections) ? contract.sections.filter((section) => typeof section === 'string' && section.trim().length > 0) : undefined;
92
102
  addNode(graph, id, 'contract', { path: filePath, contractId: contract.id, title: contract.title, sections });
93
103
  addEdge(graph, fileId, id, 'defines_contract', { confidence: 'CURATED_TRACEABILITY' });
94
- for (const target of Array.isArray(contract.implementedBy) ? contract.implementedBy : []) addTraceabilityTarget(graph, id, root, 'implemented_by', target, {}, config);
95
- for (const target of Array.isArray(contract.verifiedBy) ? contract.verifiedBy : []) addTraceabilityTarget(graph, id, root, 'verified_by', target, {}, config);
96
- for (const target of Array.isArray(contract.relatedDocs) ? contract.relatedDocs : []) addTraceabilityTarget(graph, id, root, 'related_doc', target, {}, config);
97
- addTraceabilityCommands(graph, id, filePath, Array.isArray(contract.verificationCommands) ? contract.verificationCommands : [], id);
104
+ for (const definition of config.traceability?.keys ?? []) addTraceabilityValues(graph, id, root, filePath, definition, contract[definition.key], config);
98
105
  }
99
106
  }
100
107
 
@@ -102,10 +109,7 @@ export function addTraceabilityGraph(root, fileId, content, graph, config = defa
102
109
  const filePath = fileId.replace(/^file:/, '');
103
110
  for (const block of extractDotdotgodTraceabilityBlocks(content)) {
104
111
  if (block.error || !block.data || block.data.kind !== 'spec') continue;
105
- for (const target of block.data.implementedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'implemented_by', target, {}, config);
106
- for (const target of block.data.verifiedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'verified_by', target, {}, config);
107
- for (const target of block.data.relatedDocs ?? []) addTraceabilityTarget(graph, fileId, root, 'related_doc', target, {}, config);
108
- addTraceabilityCommands(graph, fileId, filePath, block.data.verificationCommands, fileId);
112
+ for (const definition of config.traceability?.keys ?? []) addTraceabilityValues(graph, fileId, root, filePath, definition, block.data[definition.key], config);
109
113
  addTraceabilityContracts(graph, root, fileId, filePath, block.data.contracts, config);
110
114
  }
111
115
  }
@@ -68,7 +68,7 @@ export function addDeterministicSemanticEdges(graph, config = defaultMemoryConfi
68
68
  const threshold = policy.semantic.threshold ?? 0.5;
69
69
  const topK = policy.semantic.topKPerFile ?? 5;
70
70
  const includeArchiveBodies = policy.semantic.includeArchiveBodies === true;
71
- const enabledSignals = new Set(policy.semantic.signals ?? DEFAULT_IMPACT_RANKING_POLICY.semantic.signals);
71
+ const enabledSignals = new Set(policy.semantic.signals ?? ['path', 'filename', 'heading', 'package']);
72
72
  const baseGraph = { nodes: graph.nodes.map((node) => ({ ...node })), edges: graph.edges.filter((edge) => !SEMANTIC_RELATIONS.has(edge.relation)).map((edge) => ({ ...edge })) };
73
73
  const files = baseGraph.nodes.filter((node) => node.type === 'file' && node.path);
74
74
  const profiles = files.map((node) => semanticProfileForFile(node, baseGraph));
@@ -69,8 +69,10 @@ export function formatYmlImpactOutput(payload, impact) {
69
69
  ` omitted_related: ${impact.omittedRelated ?? 0}`,
70
70
  ' ranking:',
71
71
  ` method: ${ymlScalar(impact.ranking?.method)}`,
72
- ` preset: ${ymlScalar(impact.ranking?.preset)}`,
73
72
  ` config_source: ${ymlScalar(impact.ranking?.configSource)}`,
73
+ ` connection_cap: ${impact.ranking?.connectionCap ?? 80}`,
74
+ ` memory_cap: ${impact.ranking?.memoryCap ?? 20}`,
75
+ ` ppr_reference: ${impact.ranking?.pprReference ?? 0.4}`,
74
76
  ' per_seed:',
75
77
  ];
76
78
  for (const entry of impact.perSeed ?? []) {
@@ -1,6 +1,8 @@
1
- import { DEFAULT_IMPACT_RANKING_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, defaultMemoryConfig } from '../memory/config.mjs';
1
+ import { DEFAULT_IMPACT_RANKING_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, defaultMemoryConfig, traceabilityRelationWeights } from '../memory/config.mjs';
2
2
  import { retrievalMetadataForPath } from '../graph/metadata.mjs';
3
3
  import { buildPersonalizedPageRank, compareImpactItems, docsArea, hasCuratedImpactReason, isLowActionabilityImpactItem, isSemanticOnlyImpactItem, isTestPath, scoreImpactItem } from './scoring.mjs';
4
+ import { normalizeChangedPath } from './vector-profile.mjs';
5
+ import { MAX_VECTOR_EVIDENCE_CHUNK_ID_CHARS, MAX_VECTOR_EVIDENCE_HEADING_CHARS } from './vector-overlay.mjs';
4
6
 
5
7
  function addImpactItem(group, item, limit = 10) {
6
8
  if (group.items.some((existing) => existing.id === item.id)) return;
@@ -13,7 +15,7 @@ function addImpactItem(group, item, limit = 10) {
13
15
 
14
16
  function normalizeChangedPaths(changedPaths) {
15
17
  const values = Array.isArray(changedPaths) ? changedPaths : [changedPaths];
16
- return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
18
+ return [...new Set(values.map(normalizeChangedPath).filter(Boolean))];
17
19
  }
18
20
 
19
21
  function selectImpactItems(sortedItems, maxRelated, seeds) {
@@ -48,9 +50,14 @@ function selectImpactItems(sortedItems, maxRelated, seeds) {
48
50
  }
49
51
 
50
52
  function buildCombinedImpactReport(index, changedPaths, limits = {}) {
51
- const graph = index?.graph ?? { nodes: [], edges: [] };
53
+ const baseGraph = index?.graph ?? { nodes: [], edges: [] };
54
+ const overlay = limits.overlay ?? { status: 'disabled', edges: [] };
55
+ const graph = { nodes: baseGraph.nodes, edges: [...baseGraph.edges, ...(overlay.edges ?? [])] };
52
56
  const config = index?.memoryConfig ? { ...defaultMemoryConfig(), ...index.memoryConfig } : defaultMemoryConfig();
53
57
  const policy = cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY);
58
+ const traceabilityWeights = traceabilityRelationWeights(config.traceability);
59
+ policy.relationWeights = { ...policy.relationWeights, ...traceabilityWeights };
60
+ const curatedRelations = new Set(Object.keys(traceabilityWeights));
54
61
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
55
62
  const seeds = changedPaths.map((path) => `file:${path}`);
56
63
  const seedSet = new Set(seeds);
@@ -58,6 +65,7 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
58
65
  const groups = { files: { items: [], omitted: 0 }, docs: { items: [], omitted: 0 }, contracts: { items: [], omitted: 0 }, tests: { items: [], omitted: 0 }, commands: { items: [], omitted: 0 }, events: { items: [], omitted: 0 }, packageResources: { items: [], omitted: 0 }, symbols: { items: [], omitted: 0 } };
59
66
  const relatedIds = new Set(seeds);
60
67
  const reasons = new Map(seeds.map((seed) => [seed, new Set(['changed-file'])]));
68
+ const vectorEvidence = new Map();
61
69
  const addReason = (id, reason) => {
62
70
  relatedIds.add(id);
63
71
  if (!reasons.has(id)) reasons.set(id, new Set());
@@ -65,25 +73,31 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
65
73
  };
66
74
 
67
75
  for (const edge of graph.edges) {
76
+ if (edge.relation === 'vector_similarity' && seedSet.has(edge.source)) vectorEvidence.set(edge.target, {
77
+ score: edge.score,
78
+ heading: typeof edge.heading === 'string' ? edge.heading.slice(0, MAX_VECTOR_EVIDENCE_HEADING_CHARS) : undefined,
79
+ chunkId: typeof edge.chunkId === 'string' ? edge.chunkId.slice(0, MAX_VECTOR_EVIDENCE_CHUNK_ID_CHARS) : undefined,
80
+ confidence: edge.confidence,
81
+ });
68
82
  if (seedSet.has(edge.source)) addReason(edge.target, edge.relation);
69
83
  if (seedSet.has(edge.target)) addReason(edge.source, `incoming:${edge.relation}`);
70
84
  }
71
- const expansionRelations = new Set(['implemented_by', 'verified_by', 'related_doc', 'verification_command', ...SEMANTIC_RELATIONS]);
85
+ const expansionRelations = new Set([...curatedRelations, ...SEMANTIC_RELATIONS]);
72
86
  for (const edge of graph.edges) {
73
87
  if (!expansionRelations.has(edge.relation)) continue;
74
88
  if (relatedIds.has(edge.source) && !seedSet.has(edge.target)) addReason(edge.target, edge.relation);
75
89
  }
76
90
 
77
91
  const pprScores = buildPersonalizedPageRank(graph, seeds, policy);
78
- const candidatePprMax = Math.max(0, ...[...relatedIds].filter((id) => !seedSet.has(id)).map((id) => pprScores.get(id) ?? 0));
79
92
  const relatedAll = [...relatedIds].map((id) => {
80
93
  const node = nodeById.get(id) ?? { id };
81
94
  const path = node.path ?? id.replace(/^file:/, '').replace(/^test:/, '');
82
95
  const reasonList = [...(reasons.get(id) ?? [])];
83
96
  const retrieval = node.retrieval ?? retrievalMetadataForPath(path);
84
97
  const reasonSignals = reasonList.map((reason) => `reason:${reason}`);
85
- const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores, candidatePprMax);
86
- return { ...node, reasons: reasonList, retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
98
+ const hasCuratedEvidence = reasonList.some((reason) => curatedRelations.has(reason.replace(/^incoming:/, '')));
99
+ const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores);
100
+ return { ...node, reasons: reasonList, hasCuratedEvidence, ...(vectorEvidence.has(id) ? { vectorEvidence: vectorEvidence.get(id) } : {}), retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
87
101
  }).sort(compareImpactItems(seeds));
88
102
  const related = selectImpactItems(relatedAll, maxRelated, seeds);
89
103
  for (const item of related) {
@@ -98,7 +112,7 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
98
112
  else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
99
113
  else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
100
114
  }
101
- return { changed: changedPaths[0], changedFiles: changedPaths, 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) };
115
+ return { changed: changedPaths[0], changedFiles: changedPaths, semantic: { status: overlay.status ?? 'disabled', ...(limits.verboseSemantic ? { diagnostics: overlay.diagnostics } : {}) }, ranking: { method: 'weighted-personalized-pagerank+memory', configSource: index?.memoryConfig?.source ?? 'default', connectionCap: policy.connectionCap, memoryCap: policy.memoryCap, pprReference: policy.ppr.reference }, related, groups, omittedRelated: Math.max(0, relatedAll.length - related.length) };
102
116
  }
103
117
 
104
118
  export function buildImpactReport(index, changedPaths, limits = {}) {
@@ -106,7 +120,9 @@ export function buildImpactReport(index, changedPaths, limits = {}) {
106
120
  const aggregate = buildCombinedImpactReport(index, normalized, limits);
107
121
  const perSeedLimit = limits.perSeed ?? 5;
108
122
  aggregate.perSeed = normalized.map((changed) => {
109
- const report = buildCombinedImpactReport(index, [changed], { ...limits, related: Math.max(limits.related ?? 25, perSeedLimit + 1) });
123
+ const seedId = `file:${changed}`;
124
+ const seedOverlay = limits.overlay ? { ...limits.overlay, edges: (limits.overlay.edges ?? []).filter((edge) => edge.source === seedId) } : undefined;
125
+ const report = buildCombinedImpactReport(index, [changed], { ...limits, overlay: seedOverlay, related: Math.max(limits.related ?? 25, perSeedLimit + 1) });
110
126
  const related = report.related.filter((item) => item.id !== `file:${changed}`).slice(0, perSeedLimit);
111
127
  return { changed, related, omittedRelated: Math.max(0, report.related.length - 1 - related.length) + report.omittedRelated };
112
128
  });
@@ -121,7 +137,7 @@ function compactScoreBreakdown(scoreBreakdown = {}) {
121
137
 
122
138
  export function compactImpactItem(item) {
123
139
  const compact = { id: item.id, type: item.type, impactScore: item.impactScore, reasons: (item.reasons ?? []).slice(0, 6), scoreBreakdown: compactScoreBreakdown(item.scoreBreakdown) };
124
- for (const key of ['path', 'area', 'name', 'command', 'target', 'kind', 'specifier', 'title', 'contractId', 'sections']) if (item[key] !== undefined) compact[key] = item[key];
140
+ for (const key of ['path', 'area', 'name', 'command', 'target', 'kind', 'specifier', 'title', 'contractId', 'sections', 'vectorEvidence']) if (item[key] !== undefined) compact[key] = item[key];
125
141
  if (item.retrieval) compact.retrieval = { area: item.retrieval.area, role: item.retrieval.role, priority: item.retrieval.priority, freshness: item.retrieval.freshness };
126
142
  return compact;
127
143
  }
@@ -141,5 +157,5 @@ export function buildCompactImpactReport(impact, limits = {}) {
141
157
  const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
142
158
  const perSeed = (impact.perSeed ?? []).map((entry) => ({ changed: entry.changed, related: (entry.related ?? []).slice(0, limits.perSeed ?? 5).map(compactImpactItem), omittedRelated: entry.omittedRelated ?? 0 }));
143
159
  const top10 = (impact.related ?? []).filter((item) => !seedIds.has(item.id)).slice(0, 10);
144
- return { changed: impact.changed, changedFiles, perSeed, 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 } };
160
+ return { changed: impact.changed, changedFiles, perSeed, semantic: impact.semantic, compact: true, ranking: { method: impact.ranking?.method, configSource: impact.ranking?.configSource, connectionCap: impact.ranking?.connectionCap, memoryCap: impact.ranking?.memoryCap, pprReference: impact.ranking?.pprReference }, 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 } };
145
161
  }