@dotdotgod/cli 0.1.41 → 0.1.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "CLI for dotdotgod validation, project memory snapshots, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -9,11 +9,12 @@ function isSecretIndexPath(path) {
9
9
 
10
10
  const TRACEABILITY_PATH_FIELDS = ['implementedBy', 'verifiedBy', 'relatedDocs'];
11
11
  const TRACEABILITY_COMMAND_FIELDS = ['verificationCommands'];
12
+ const CONTRACT_ALLOWED_FIELDS = new Set(['id', 'title', 'sections', ...TRACEABILITY_PATH_FIELDS, ...TRACEABILITY_COMMAND_FIELDS]);
12
13
  export const TRACEABILITY_LINKS_START = '<!-- dotdotgod:traceability-links:start version=1 source=json-dotdotgod -->';
13
14
  export const TRACEABILITY_LINKS_END = '<!-- dotdotgod:traceability-links:end -->';
14
15
 
15
16
  export function traceabilityExample() {
16
- return 'Expected dotdotgod traceability block:\n\n```json dotdotgod\n{\n "kind": "spec",\n "implementedBy": ["packages/..."],\n "verifiedBy": ["packages/..."],\n "relatedDocs": ["docs/..."],\n "verificationCommands": ["pnpm ..."]\n}\n```\n\nProperty guidance:\n- kind: use "spec" for behavior specs.\n- implementedBy: source/config/script files that implement this spec\'s behavior.\n- verifiedBy: test files or verification docs that check this behavior.\n- relatedDocs: docs with relevant architecture, test strategy, or product context.\n- verificationCommands: commands an agent can run to verify this behavior.';
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.';
17
18
  }
18
19
 
19
20
  function lineForOffset(content, offset) {
@@ -128,6 +129,23 @@ function renderCommandList(commands) {
128
129
  return ['- Verification commands:', ...commands.map((command) => ` - \`${escapeCodeSpan(command)}\``)];
129
130
  }
130
131
 
132
+ function renderContractList(contracts) {
133
+ if (!Array.isArray(contracts) || contracts.length === 0) return [];
134
+ const lines = ['- Contracts:'];
135
+ for (const contract of contracts) {
136
+ if (!contract || typeof contract !== 'object' || Array.isArray(contract)) continue;
137
+ const id = typeof contract.id === 'string' ? contract.id : '<missing-id>';
138
+ const title = typeof contract.title === 'string' ? contract.title : '<missing-title>';
139
+ const details = [];
140
+ 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
+ }
144
+ lines.push(` - \`${escapeCodeSpan(id)}\` — ${escapeMarkdownText(title)}${details.length > 0 ? ` (${details.join(', ')})` : ''}`);
145
+ }
146
+ return lines;
147
+ }
148
+
131
149
  export function renderTraceabilityLinks(data, root, file) {
132
150
  const body = [
133
151
  '### Traceability Links',
@@ -136,6 +154,7 @@ export function renderTraceabilityLinks(data, root, file) {
136
154
  ...renderPathList('Verified by', data?.verifiedBy, root, file),
137
155
  ...renderPathList('Related docs', data?.relatedDocs, root, file),
138
156
  ...renderCommandList(data?.verificationCommands),
157
+ ...renderContractList(data?.contracts),
139
158
  ];
140
159
  return `${TRACEABILITY_LINKS_START}\n<!-- generated: do not edit manually -->\n\n${body.join('\n')}\n\n${TRACEABILITY_LINKS_END}`;
141
160
  }
@@ -168,39 +187,81 @@ export function syncTraceabilityLinksInContent(content, data, root, file) {
168
187
  return { ok: true, changed: next !== content, content: next };
169
188
  }
170
189
 
171
- export function validateTraceabilityBlock(data, root, file, line = null, config = defaultMemoryConfig()) {
172
- const errors = [];
173
- const add = (code, field, message) => errors.push(traceabilityFieldError(rel(root, file), code, field, message, line));
174
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
175
- add('TRACEABILITY_INVALID_JSON', null, 'Traceability block must be a JSON object.');
176
- return errors;
190
+ function validateTraceabilityPathArray(data, field, add, root, config, { required = true, displayField = field } = {}) {
191
+ if (data[field] === undefined && !required) return;
192
+ if (!Array.isArray(data[field])) {
193
+ add('TRACEABILITY_INVALID_FIELD', displayField, 'must be an array of local relative paths.');
194
+ return;
177
195
  }
178
- if (data.kind !== 'spec') add('TRACEABILITY_INVALID_KIND', 'kind', 'must be "spec" for behavior specs.');
179
- for (const field of TRACEABILITY_PATH_FIELDS) {
180
- if (!Array.isArray(data[field])) {
181
- add('TRACEABILITY_INVALID_FIELD', field, 'must be an array of local relative paths.');
196
+ for (const value of data[field]) {
197
+ if (!isLocalRelativeTraceabilityPath(value)) {
198
+ add('TRACEABILITY_INVALID_PATH', displayField, `invalid local relative path: ${JSON.stringify(value)}.`);
182
199
  continue;
183
200
  }
184
- for (const value of data[field]) {
185
- if (!isLocalRelativeTraceabilityPath(value)) {
186
- add('TRACEABILITY_INVALID_PATH', field, `invalid local relative path: ${JSON.stringify(value)}.`);
187
- continue;
188
- }
189
- const area = resolveMemoryArea(value, config);
190
- if (area?.scope === 'local') {
191
- add('TRACEABILITY_LOCAL_MEMORY_TARGET', field, `target points to local memory area "${area.id}": ${value}. Traceability must point to shared durable files, not active plans or archive memory.`);
192
- continue;
193
- }
194
- if (!existsSync(resolve(root, value))) add('TRACEABILITY_MISSING_TARGET', field, `target does not exist: ${value}.`);
201
+ const area = resolveMemoryArea(value, config);
202
+ if (area?.scope === 'local') {
203
+ add('TRACEABILITY_LOCAL_MEMORY_TARGET', displayField, `target points to local memory area "${area.id}": ${value}. Traceability must point to shared durable files, not active plans or archive memory.`);
204
+ continue;
195
205
  }
206
+ if (!existsSync(resolve(root, value))) add('TRACEABILITY_MISSING_TARGET', displayField, `target does not exist: ${value}.`);
207
+ }
208
+ }
209
+
210
+ function validateTraceabilityCommandArray(data, field, add, { required = true, displayField = field } = {}) {
211
+ if (data[field] === undefined && !required) return;
212
+ if (!Array.isArray(data[field])) {
213
+ add('TRACEABILITY_INVALID_FIELD', displayField, 'must be an array of executable project-local command strings.');
214
+ return;
196
215
  }
197
- for (const field of TRACEABILITY_COMMAND_FIELDS) {
198
- if (!Array.isArray(data[field])) {
199
- add('TRACEABILITY_INVALID_FIELD', field, 'must be an array of executable project-local command strings.');
216
+ 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
+ }
218
+
219
+ function validateTraceabilityContracts(data, root, add, config) {
220
+ if (data.contracts === undefined) return;
221
+ if (!Array.isArray(data.contracts)) {
222
+ add('TRACEABILITY_INVALID_FIELD', 'contracts', 'must be an array of focused contract objects.');
223
+ return;
224
+ }
225
+ const ids = new Set();
226
+ for (const [index, contract] of data.contracts.entries()) {
227
+ const base = `contracts[${index}]`;
228
+ if (!contract || typeof contract !== 'object' || Array.isArray(contract)) {
229
+ add('TRACEABILITY_INVALID_FIELD', base, 'must be an object with non-empty id and title fields.');
200
230
  continue;
201
231
  }
202
- for (const value of data[field]) if (typeof value !== 'string' || value.trim().length === 0) add('TRACEABILITY_INVALID_COMMAND', field, `invalid command: ${JSON.stringify(value)}.`);
232
+ 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.');
234
+ }
235
+ if (typeof contract.id !== 'string' || contract.id.trim().length === 0) {
236
+ add('TRACEABILITY_INVALID_FIELD', `${base}.id`, 'must be a non-empty stable contract ID string.');
237
+ } else if (ids.has(contract.id)) {
238
+ add('TRACEABILITY_INVALID_FIELD', `${base}.id`, `duplicates contract id ${JSON.stringify(contract.id)} in this traceability block.`);
239
+ } else {
240
+ ids.add(contract.id);
241
+ }
242
+ if (typeof contract.title !== 'string' || contract.title.trim().length === 0) add('TRACEABILITY_INVALID_FIELD', `${base}.title`, 'must be a non-empty human-readable contract title.');
243
+ if (contract.sections !== undefined) {
244
+ if (!Array.isArray(contract.sections)) add('TRACEABILITY_INVALID_FIELD', `${base}.sections`, 'must be an array of same-file heading titles.');
245
+ else for (const [sectionIndex, section] of contract.sections.entries()) {
246
+ if (typeof section !== 'string' || section.trim().length === 0) add('TRACEABILITY_INVALID_FIELD', `${base}.sections[${sectionIndex}]`, 'must be a non-empty heading title string.');
247
+ }
248
+ }
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}` });
203
251
  }
252
+ }
253
+
254
+ export function validateTraceabilityBlock(data, root, file, line = null, config = defaultMemoryConfig()) {
255
+ const errors = [];
256
+ const add = (code, field, message) => errors.push(traceabilityFieldError(rel(root, file), code, field, message, line));
257
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
258
+ add('TRACEABILITY_INVALID_JSON', null, 'Traceability block must be a JSON object.');
259
+ return errors;
260
+ }
261
+ 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);
264
+ validateTraceabilityContracts(data, root, add, config);
204
265
  return errors;
205
266
  }
206
267
 
@@ -68,18 +68,45 @@ function addTraceabilityTarget(graph, sourceId, root, relation, targetPath, data
68
68
  addEdge(graph, sourceId, targetId, relation, { confidence: 'CURATED_TRACEABILITY', ...data });
69
69
  }
70
70
 
71
+ function contractNodeId(filePath, contractId) {
72
+ return `contract:${filePath}#${contractId}`;
73
+ }
74
+
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' });
81
+ }
82
+ }
83
+
84
+ function addTraceabilityContracts(graph, root, fileId, filePath, contracts, config = defaultMemoryConfig()) {
85
+ if (!Array.isArray(contracts)) return;
86
+ for (const contract of contracts) {
87
+ if (!contract || typeof contract !== 'object' || Array.isArray(contract)) continue;
88
+ if (typeof contract.id !== 'string' || contract.id.trim().length === 0) continue;
89
+ if (typeof contract.title !== 'string' || contract.title.trim().length === 0) continue;
90
+ const id = contractNodeId(filePath, contract.id);
91
+ const sections = Array.isArray(contract.sections) ? contract.sections.filter((section) => typeof section === 'string' && section.trim().length > 0) : undefined;
92
+ addNode(graph, id, 'contract', { path: filePath, contractId: contract.id, title: contract.title, sections });
93
+ 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);
98
+ }
99
+ }
100
+
71
101
  export function addTraceabilityGraph(root, fileId, content, graph, config = defaultMemoryConfig()) {
102
+ const filePath = fileId.replace(/^file:/, '');
72
103
  for (const block of extractDotdotgodTraceabilityBlocks(content)) {
73
104
  if (block.error || !block.data || block.data.kind !== 'spec') continue;
74
105
  for (const target of block.data.implementedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'implemented_by', target, {}, config);
75
106
  for (const target of block.data.verifiedBy ?? []) addTraceabilityTarget(graph, fileId, root, 'verified_by', target, {}, config);
76
107
  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
- }
108
+ addTraceabilityCommands(graph, fileId, filePath, block.data.verificationCommands, fileId);
109
+ addTraceabilityContracts(graph, root, fileId, filePath, block.data.contracts, config);
83
110
  }
84
111
  }
85
112
 
@@ -14,7 +14,7 @@ function graphNodeShard(node) {
14
14
  }
15
15
 
16
16
  function graphEdgeShard(edge) {
17
- if (edge.relation === 'links_to' || edge.relation === 'routes_to' || edge.relation === 'contains_heading' || edge.relation === 'implemented_by' || edge.relation === 'verified_by' || edge.relation === 'related_doc' || edge.relation === 'verification_command' || SEMANTIC_RELATIONS.has(edge.relation)) return 'docs-links';
17
+ if (edge.relation === 'links_to' || edge.relation === 'routes_to' || edge.relation === 'contains_heading' || edge.relation === 'implemented_by' || edge.relation === 'verified_by' || edge.relation === 'related_doc' || edge.relation === 'verification_command' || edge.relation === 'defines_contract' || SEMANTIC_RELATIONS.has(edge.relation)) return 'docs-links';
18
18
  if (edge.relation === 'declares_package' || edge.relation === 'declares_script' || edge.relation === 'declares_bin' || edge.relation === 'depends_on' || edge.relation === 'includes_resource') return 'packages';
19
19
  return 'other';
20
20
  }
@@ -6,7 +6,7 @@ function formatCompactImpactGroup(name, group) {
6
6
  return [
7
7
  `${name}:`,
8
8
  ...items.map((item) => {
9
- const label = item.path ?? item.command ?? item.name ?? item.target ?? item.id;
9
+ const label = item.type === 'contract' && item.path && item.contractId ? `${item.path}#${item.contractId}${item.title ? ` — ${item.title}` : ''}` : item.path ?? item.command ?? item.name ?? item.target ?? item.id;
10
10
  const reasons = (item.reasons ?? []).slice(0, 3).join(', ');
11
11
  return `- ${label} (${item.impactScore}; ${reasons})`;
12
12
  }),
@@ -16,7 +16,7 @@ function formatCompactImpactGroup(name, group) {
16
16
  export function formatCompactImpactOutput(payload, impact) {
17
17
  const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
18
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]));
19
+ for (const name of ['docs', 'contracts', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatCompactImpactGroup(name, impact.groups[name]));
20
20
  return lines.join('\n');
21
21
  }
22
22
 
@@ -37,6 +37,9 @@ function formatYmlImpactItem(item) {
37
37
  lines.push(` type: ${ymlScalar(item.type)}`);
38
38
  lines.push(` score: ${ymlScalar(item.impactScore)}`);
39
39
  lines.push(` reasons: ${ymlList((item.reasons ?? []).slice(0, 6))}`);
40
+ if (item.contractId) lines.push(` contract_id: ${ymlScalar(item.contractId)}`);
41
+ if (item.title) lines.push(` title: ${ymlScalar(item.title)}`);
42
+ if (Array.isArray(item.sections) && item.sections.length > 0) lines.push(` sections: ${ymlList(item.sections)}`);
40
43
  if (item.retrieval?.area) lines.push(` area: ${ymlScalar(item.retrieval.area)}`);
41
44
  if (item.retrieval?.role) lines.push(` role: ${ymlScalar(item.retrieval.role)}`);
42
45
  return lines;
@@ -68,7 +71,7 @@ export function formatYmlImpactOutput(payload, impact) {
68
71
  ` config_source: ${ymlScalar(impact.ranking?.configSource)}`,
69
72
  ' groups:',
70
73
  ];
71
- for (const name of ['docs', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatYmlImpactGroup(name, impact.groups[name]));
74
+ for (const name of ['docs', 'contracts', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatYmlImpactGroup(name, impact.groups[name]));
72
75
  lines.push(' recommended_actions:');
73
76
  lines.push(' - "review_related_docs"');
74
77
  lines.push(' - "run_related_tests"');
@@ -48,7 +48,7 @@ export function buildImpactReport(index, changedPath, limits = {}) {
48
48
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
49
49
  const seed = `file:${changedPath}`;
50
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 } };
51
+ 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 } };
52
52
  const relatedIds = new Set([seed]);
53
53
  const reasons = new Map([[seed, new Set(['changed-file'])]]);
54
54
  const addReason = (id, reason) => {
@@ -85,7 +85,8 @@ export function buildImpactReport(index, changedPath, limits = {}) {
85
85
  if (area) addImpactItem(groups.docs, { ...item, area }, limits.docs ?? 10);
86
86
  else if (isTestPath(item.path)) addImpactItem(groups.tests, item, limits.tests ?? 10);
87
87
  else addImpactItem(groups.files, item, limits.files ?? 10);
88
- } else if (item.type === 'test') addImpactItem(groups.tests, item, limits.tests ?? 10);
88
+ } else if (item.type === 'contract') addImpactItem(groups.contracts, item, limits.contracts ?? 10);
89
+ else if (item.type === 'test') addImpactItem(groups.tests, item, limits.tests ?? 10);
89
90
  else if (item.type === 'command') addImpactItem(groups.commands, item, limits.commands ?? 10);
90
91
  else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
91
92
  else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
@@ -101,7 +102,7 @@ function compactScoreBreakdown(scoreBreakdown = {}) {
101
102
 
102
103
  export function compactImpactItem(item) {
103
104
  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
+ for (const key of ['path', 'area', 'name', 'command', 'target', 'kind', 'specifier', 'title', 'contractId', 'sections']) if (item[key] !== undefined) compact[key] = item[key];
105
106
  if (item.retrieval) compact.retrieval = { area: item.retrieval.area, role: item.retrieval.role, priority: item.retrieval.priority, freshness: item.retrieval.freshness };
106
107
  return compact;
107
108
  }
@@ -115,7 +116,7 @@ export function buildCompactImpactReport(impact, limits = {}) {
115
116
  const relatedLimit = limits.related ?? 10;
116
117
  const groupLimit = limits.groupItems ?? 5;
117
118
  const related = (impact.related ?? []).slice(0, relatedLimit).map(compactImpactItem);
118
- const groupNames = ['files', 'docs', 'tests', 'commands', 'events', 'packageResources', 'symbols'];
119
+ const groupNames = ['files', 'docs', 'contracts', 'tests', 'commands', 'events', 'packageResources', 'symbols'];
119
120
  const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
120
121
  const top10 = (impact.related ?? []).filter((item) => item.id !== `file:${impact.changed}`).slice(0, 10);
121
122
  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 } };