@dotdotgod/cli 0.1.41 → 0.1.43
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 +1 -1
- package/src/commands/plan-stage-contract.mjs +12 -2
- package/src/commands/plan.mjs +77 -0
- package/src/docs/traceability.mjs +87 -26
- package/src/graph/metadata.mjs +33 -6
- package/src/graph/store.mjs +1 -1
- package/src/impact/format.mjs +6 -3
- package/src/impact/report.mjs +5 -4
package/package.json
CHANGED
|
@@ -17,7 +17,14 @@ export const PLAN_REQUIRED_HEADERS = {
|
|
|
17
17
|
'02-context-load': ['Memory Reads', 'Impact Candidates', 'Related Files'],
|
|
18
18
|
'03-discovery': ['Findings', 'Risks', 'Open Questions'],
|
|
19
19
|
'04-plan': ['Milestones', 'Atomic Tasks', 'Validation Plan', 'Resume Point'],
|
|
20
|
-
'05-workstream-handoff': [
|
|
20
|
+
'05-workstream-handoff': [
|
|
21
|
+
'Workstream Handoff',
|
|
22
|
+
'Workstream Map',
|
|
23
|
+
'Shared Context',
|
|
24
|
+
'Workstreams',
|
|
25
|
+
'Integration Sequence',
|
|
26
|
+
'Todo Contract',
|
|
27
|
+
],
|
|
21
28
|
};
|
|
22
29
|
|
|
23
30
|
export const PLAN_CONSTRUCTION_CHECKLISTS = {
|
|
@@ -50,6 +57,9 @@ export const PLAN_BLOCKER_LABELS = new Map([
|
|
|
50
57
|
['malformed-checklist-item', 'Malformed construction checklist item'],
|
|
51
58
|
['open-checklist-item', 'Construction checklist item is open'],
|
|
52
59
|
['placeholder-checklist-item', 'Construction checklist item only contains placeholder content'],
|
|
60
|
+
['missing-workstream-split-decision', 'Workstream Handoff must include an explicit split decision'],
|
|
61
|
+
['incomplete-workstream-split', 'Split workstream handoff must include actionable workstream contracts'],
|
|
62
|
+
['missing-workstream-no-split-rationale', 'No-split workstream handoff must include rationale'],
|
|
53
63
|
]);
|
|
54
64
|
|
|
55
65
|
export function buildPlanValidationNextStagePrompt(planPath, stage, stagePath) {
|
|
@@ -57,7 +67,7 @@ export function buildPlanValidationNextStagePrompt(planPath, stage, stagePath) {
|
|
|
57
67
|
`Continue Plan Mode stage advancement for ${planPath}.`,
|
|
58
68
|
`Current stage: ${stage}.`,
|
|
59
69
|
`Make ${planPath} satisfy the current stage's durable README requirements first. Use ${stagePath} only as an optional checkpoint/workflow note for this stage; do not make numbered checkpoint creation the goal and do not create later checkpoint files yet.`,
|
|
60
|
-
stage === '05-workstream-handoff' ? 'For this stage,
|
|
70
|
+
stage === '05-workstream-handoff' ? 'For this stage, record `Split decision: yes` or `Split decision: no` in `## Workstream Handoff`. If splitting, make `## Workstream Map`, `## Workstreams`, and `## Integration Sequence` describe multiple actionable workstreams with required context, allowed/forbidden edits, tasks, validation, dependencies, and handoff output. If not splitting, record the no-split rationale and keep `## Todo Contract` actionable so Pi can extract execution todos.' : undefined,
|
|
61
71
|
`After completing this stage, stop and rerun \`dotdotgod plan validate ${planPath} --stage ${stage} --json\`.`,
|
|
62
72
|
].filter(Boolean).join('\n\n');
|
|
63
73
|
}
|
package/src/commands/plan.mjs
CHANGED
|
@@ -273,6 +273,82 @@ function hasUnresolvedAssumption(content) {
|
|
|
273
273
|
});
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
function workstreamSplitDecision(content) {
|
|
277
|
+
const match = /\bsplit\s+decision\s*:\s*(yes|no)\b/i.exec(content);
|
|
278
|
+
return match?.[1].toLowerCase();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function hasNoSplitRationale(content) {
|
|
282
|
+
return /\b(?:rationale|reason|because)\b/i.test(content);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function hasMultipleWorkstreams(content) {
|
|
286
|
+
const normalized = removeCodeBlocks(content).toLowerCase();
|
|
287
|
+
const explicitIds = new Set([...normalized.matchAll(/\b(?:workstream|ws)\s*[-:#]?\s*([a-z0-9]+)\b/g)].map((match) => match[1]));
|
|
288
|
+
if (explicitIds.size >= 2) return true;
|
|
289
|
+
const workstreamMentions = normalized.match(/\bworkstreams?\b/g) ?? [];
|
|
290
|
+
return workstreamMentions.length >= 2 && /\b(?:multiple|parallel|split)\b/.test(normalized);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function hasActionableWorkstreamFields(content) {
|
|
294
|
+
const normalized = content.toLowerCase();
|
|
295
|
+
const fieldGroups = [
|
|
296
|
+
['purpose', 'objective'],
|
|
297
|
+
['required context', 'read'],
|
|
298
|
+
['allowed edit'],
|
|
299
|
+
['forbidden edit', 'do not edit', 'do-not edit'],
|
|
300
|
+
['task'],
|
|
301
|
+
['validation', 'verify'],
|
|
302
|
+
['handoff output', 'output'],
|
|
303
|
+
['dependenc'],
|
|
304
|
+
];
|
|
305
|
+
return fieldGroups.every((group) => group.some((term) => normalized.includes(term)));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function hasActionableIntegrationFields(content) {
|
|
309
|
+
const normalized = content.toLowerCase();
|
|
310
|
+
return ['sequence', 'integrat'].every((term) => normalized.includes(term)) && ['validation', 'verify', 'handoff'].some((term) => normalized.includes(term));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function validateWorkstreamHandoffContract(root, files, stageName, blockers) {
|
|
314
|
+
if (stageName !== '05-workstream-handoff') return true;
|
|
315
|
+
let valid = true;
|
|
316
|
+
const handoff = findSection(files, 'Workstream Handoff');
|
|
317
|
+
const todo = findSection(files, 'Todo Contract');
|
|
318
|
+
const relHandoff = projectRelative(root, handoff?.file ?? files[0]);
|
|
319
|
+
const decision = workstreamSplitDecision(handoff?.content ?? '');
|
|
320
|
+
if (!decision) {
|
|
321
|
+
addBlocker(blockers, 'missing-workstream-split-decision', 'Workstream Handoff must include `Split decision: yes` or `Split decision: no`.', { path: relHandoff, stage: stageName, section: 'Workstream Handoff' });
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (decision === 'no') {
|
|
325
|
+
if (!hasNoSplitRationale(handoff.content)) {
|
|
326
|
+
valid = false;
|
|
327
|
+
addBlocker(blockers, 'missing-workstream-no-split-rationale', 'Workstream Handoff with `Split decision: no` must include a rationale.', { path: relHandoff, stage: stageName, section: 'Workstream Handoff' });
|
|
328
|
+
}
|
|
329
|
+
if (!todo || !contentHasValue(todo.content) || isPlaceholderContent(todo.content)) valid = false;
|
|
330
|
+
return valid;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const map = findSection(files, 'Workstream Map');
|
|
334
|
+
const workstreams = findSection(files, 'Workstreams');
|
|
335
|
+
const integration = findSection(files, 'Integration Sequence');
|
|
336
|
+
const splitContent = [map?.content, workstreams?.content, integration?.content].filter(Boolean).join('\n');
|
|
337
|
+
if (!hasMultipleWorkstreams(splitContent)) {
|
|
338
|
+
valid = false;
|
|
339
|
+
addBlocker(blockers, 'incomplete-workstream-split', 'Split Stage 05 handoff must indicate multiple workstreams in Workstream Map, Workstreams, or Integration Sequence.', { path: projectRelative(root, map?.file ?? workstreams?.file ?? integration?.file ?? handoff.file), stage: stageName, section: 'Workstream Map' });
|
|
340
|
+
}
|
|
341
|
+
if (!workstreams || !hasActionableWorkstreamFields(workstreams.content)) {
|
|
342
|
+
valid = false;
|
|
343
|
+
addBlocker(blockers, 'incomplete-workstream-split', 'Workstreams must mention purpose/objective, required context/read, allowed edits, forbidden edits/do-not-edit, tasks, validation, handoff output, and dependencies.', { path: projectRelative(root, workstreams?.file ?? handoff.file), stage: stageName, section: 'Workstreams' });
|
|
344
|
+
}
|
|
345
|
+
if (!integration || !hasActionableIntegrationFields(integration.content)) {
|
|
346
|
+
valid = false;
|
|
347
|
+
addBlocker(blockers, 'incomplete-workstream-split', 'Integration Sequence must describe actionable integration sequence and validation/handoff expectations.', { path: projectRelative(root, integration?.file ?? handoff.file), stage: stageName, section: 'Integration Sequence' });
|
|
348
|
+
}
|
|
349
|
+
return valid;
|
|
350
|
+
}
|
|
351
|
+
|
|
276
352
|
function constructionChecklistHeader(stageName) {
|
|
277
353
|
const match = /^(\d{2})-/.exec(stageName);
|
|
278
354
|
return `Stage ${match?.[1] ?? stageName} Construction Checklist`;
|
|
@@ -465,6 +541,7 @@ export function validatePlanArtifact(planPath, options = {}) {
|
|
|
465
541
|
addBlocker(blockers, 'unresolved-assumption', 'Assumptions contain unresolved items.', { path: relFound, stage: stageName, section: header });
|
|
466
542
|
}
|
|
467
543
|
}
|
|
544
|
+
if (!validateWorkstreamHandoffContract(root, files, stageName, blockers)) stageValid = false;
|
|
468
545
|
if (!validateConstructionChecklist(root, files, stageName, blockers)) stageValid = false;
|
|
469
546
|
if (stageValid) summary.stages.valid += 1;
|
|
470
547
|
}
|
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
package/src/graph/metadata.mjs
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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
|
|
package/src/graph/store.mjs
CHANGED
|
@@ -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
|
}
|
package/src/impact/format.mjs
CHANGED
|
@@ -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"');
|
package/src/impact/report.mjs
CHANGED
|
@@ -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 === '
|
|
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 } };
|