@dotdotgod/cli 0.1.29 → 0.1.35
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 +15 -1
- package/package.json +1 -1
- package/src/cli/usage.mjs +10 -0
- package/src/core.mjs +84 -7
- package/src/docs/traceability.mjs +122 -4
- package/src/load-snapshot/summary.mjs +14 -1
- package/src/memory/config.mjs +42 -10
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ Command-line tools for dotdotgod project memory. The CLI turns explicit, maintai
|
|
|
31
31
|
|
|
32
32
|
- Initialize `AGENTS.md`, thin agent entrypoints, docs indexes, active-plan space, archive map, and local cache ignores with `dotdotgod init`.
|
|
33
33
|
- Replace ad-hoc docs checks with `dotdotgod validate`.
|
|
34
|
+
- Keep generated Markdown traceability links synchronized with `dotdotgod traceability links`.
|
|
34
35
|
- Build `.dotdotgod/` as a local ignored cache of file fingerprints and compact graph shards derived from maintained project links.
|
|
35
36
|
- Use `load-snapshot` as a bounded first-pass map for high-quality agent loading.
|
|
36
37
|
- Resolve explicit `[[...]]` references or high-signal fuzzy references before broad text search.
|
|
@@ -53,13 +54,26 @@ dotdotgod load-snapshot .
|
|
|
53
54
|
dotdotgod resolve . PLAN_MODE
|
|
54
55
|
dotdotgod expand . "Update [[PLAN_MODE]] and [[HOOKS]]"
|
|
55
56
|
dotdotgod expand . "PLAN_MODE 수정하자" --fuzzy
|
|
57
|
+
dotdotgod traceability links . --check
|
|
58
|
+
dotdotgod traceability links . --write
|
|
56
59
|
dotdotgod graph impact . --changed <path>
|
|
57
60
|
dotdotgod graph impact . --changed <path> --compact
|
|
58
61
|
dotdotgod graph impact . --changed <path> --yml
|
|
59
62
|
dotdotgod graph communities .
|
|
60
63
|
```
|
|
61
64
|
|
|
62
|
-
`--help`, `-h`, and `help` print usage to stdout. Command-specific help is available with `dotdotgod <command> --help`, including nested commands such as `dotdotgod graph impact --help` and `dotdotgod
|
|
65
|
+
`--help`, `-h`, and `help` print usage to stdout. Command-specific help is available with `dotdotgod <command> --help`, including nested commands such as `dotdotgod graph impact --help`, `dotdotgod config init --help`, and `dotdotgod traceability links --help`.
|
|
66
|
+
|
|
67
|
+
## Validation and Verification Boundaries
|
|
68
|
+
|
|
69
|
+
| Command | Responsibility |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `dotdotgod validate <root>` | Checks dotdotgod docs/project-memory structure, local links, traceability blocks, generated traceability-link drift, config validity, and optional index freshness. |
|
|
72
|
+
| `dotdotgod traceability links <root> --check` | Runs only the generated traceability-link and compact JSON drift check. |
|
|
73
|
+
| `dotdotgod traceability links <root> --write` | Repairs generated traceability-link sections and rewrites canonical `json dotdotgod` blocks as compact JSON. |
|
|
74
|
+
| `pnpm run verify` | Runs the repository-level quality gate: generated-resource checks, package verify contracts, tests, typecheck, and docs validation where each package defines it. |
|
|
75
|
+
|
|
76
|
+
Most users start with `validate`. Use `traceability links --write` only when generated traceability output needs repair, and use `verify` before release-style handoff.
|
|
63
77
|
|
|
64
78
|
## Graph, Cache, and Impact
|
|
65
79
|
|
package/package.json
CHANGED
package/src/cli/usage.mjs
CHANGED
|
@@ -40,6 +40,14 @@ Create dotdotgod.config.json with the built-in default memory, traceability, val
|
|
|
40
40
|
case 'expand':
|
|
41
41
|
return `Usage:
|
|
42
42
|
dotdotgod expand <root> <prompt> [--max-results n] [--include-archive] [--with-impact] [--fuzzy] [--json]`;
|
|
43
|
+
case 'traceability':
|
|
44
|
+
return `Usage:
|
|
45
|
+
dotdotgod traceability links <root> [--check|--write] [--json]`;
|
|
46
|
+
case 'traceability links':
|
|
47
|
+
return `Usage:
|
|
48
|
+
dotdotgod traceability links <root> [--check|--write] [--json]
|
|
49
|
+
|
|
50
|
+
Checks or writes generated Markdown traceability link sections from canonical fenced json dotdotgod blocks.`;
|
|
43
51
|
case 'graph':
|
|
44
52
|
return `Usage:
|
|
45
53
|
dotdotgod graph impact <root> --changed <path> [--compact|--json|--yml|--yaml]
|
|
@@ -66,6 +74,7 @@ Ranks nodes related to a changed file. <root> is the project root; --changed is
|
|
|
66
74
|
dotdotgod load-snapshot <root> [--json]
|
|
67
75
|
dotdotgod resolve <root> <ref> [--max-results n] [--include-archive] [--json]
|
|
68
76
|
dotdotgod expand <root> <prompt> [--max-results n] [--include-archive] [--with-impact] [--fuzzy] [--json]
|
|
77
|
+
dotdotgod traceability links <root> [--check|--write] [--json]
|
|
69
78
|
dotdotgod graph impact <root> --changed <path> [--compact|--json|--yml|--yaml]
|
|
70
79
|
dotdotgod graph communities <root> [--json]`;
|
|
71
80
|
}
|
|
@@ -111,6 +120,7 @@ export function printVersion() {
|
|
|
111
120
|
export function helpCommandFromArgs(args) {
|
|
112
121
|
const nonHelp = args.filter((arg) => !isHelpToken(arg));
|
|
113
122
|
if (nonHelp[0] === 'graph' && nonHelp[1]) return `graph ${nonHelp[1]}`;
|
|
123
|
+
if (nonHelp[0] === 'traceability' && nonHelp[1]) return `traceability ${nonHelp[1]}`;
|
|
114
124
|
if (nonHelp[0] === 'config' && nonHelp[1] === 'init') return 'config init';
|
|
115
125
|
return nonHelp[0] ?? 'root';
|
|
116
126
|
}
|
package/src/core.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { runInit } from './init.mjs';
|
|
|
6
6
|
import { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, parseCommon, printVersion, usage } from './cli/usage.mjs';
|
|
7
7
|
import { rel } from './common/paths.mjs';
|
|
8
8
|
import { extractAnchors, extractLinks, headingToAnchor, isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from './docs/markdown.mjs';
|
|
9
|
-
import { extractDotdotgodTraceabilityBlocks, isLocalRelativeTraceabilityPath, traceabilityExample, validateTraceabilityBlock, validateTraceabilityPlacement } from './docs/traceability.mjs';
|
|
9
|
+
import { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
|
|
10
10
|
import { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, validateMemoryConfigData } from './memory/config.mjs';
|
|
11
11
|
import { buildCommunities, relationWeight } from './graph/communities.mjs';
|
|
12
12
|
import { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
|
|
@@ -16,13 +16,13 @@ import { buildMemoryAreas, detectCommandGuidance, detectPackageManager } from '.
|
|
|
16
16
|
export { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, parseCommon, printVersion, usage } from './cli/usage.mjs';
|
|
17
17
|
export { rel } from './common/paths.mjs';
|
|
18
18
|
export { extractAnchors, extractLinks, headingToAnchor, isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from './docs/markdown.mjs';
|
|
19
|
-
export { extractDotdotgodTraceabilityBlocks, isLocalRelativeTraceabilityPath, traceabilityExample, validateTraceabilityBlock, validateTraceabilityPlacement } from './docs/traceability.mjs';
|
|
19
|
+
export { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
|
|
20
20
|
export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, validateMemoryConfigData } from './memory/config.mjs';
|
|
21
21
|
export { buildCommunities, relationWeight } from './graph/communities.mjs';
|
|
22
22
|
export { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
|
|
23
23
|
export { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
|
|
24
24
|
export { buildMemoryAreas, detectCommandGuidance, detectPackageManager } from './load-snapshot/summary.mjs';
|
|
25
|
-
export const CACHE_VERSION =
|
|
25
|
+
export const CACHE_VERSION = 11;
|
|
26
26
|
const CACHE_DIR = '.dotdotgod';
|
|
27
27
|
const MANIFEST_FILE = 'manifest.json';
|
|
28
28
|
export function runValidate(argv) {
|
|
@@ -81,18 +81,25 @@ export function runValidate(argv) {
|
|
|
81
81
|
if (docsRel && !docsRel.startsWith('..') && !isUpperSnakeMarkdown(name)) addError(file, 'FILE_NAMING', `Markdown file must be UPPER_SNAKE_CASE.md or README.md: ${name}`, null, 'rename the markdown file to UPPER_SNAKE_CASE.md or README.md and update any links that reference it.');
|
|
82
82
|
const content = readFileSync(file, 'utf8');
|
|
83
83
|
fileCache.set(file, content);
|
|
84
|
-
const
|
|
84
|
+
const budgetContent = stripTraceabilityLinksRegion(content);
|
|
85
|
+
const lines = budgetContent.split('\n').length;
|
|
86
|
+
const chars = budgetContent.length;
|
|
85
87
|
const path = rel(root, file);
|
|
86
88
|
const skipSizeChecks = isMarkdownSizeExcluded(path, memoryConfig);
|
|
87
|
-
if (!skipSizeChecks && lines > maxLines) addError(file, 'FILE_TOO_LONG', `Markdown file has ${lines} lines; max is ${maxLines}`, null, 'split the document into focused markdown files and update the nearest README.md index, or add a narrow validation.markdown.exclude entry if this file is intentionally oversized.');
|
|
88
|
-
if (!skipSizeChecks &&
|
|
89
|
+
if (!skipSizeChecks && lines > maxLines) addError(file, 'FILE_TOO_LONG', `Markdown file has ${lines} lines excluding generated traceability links and traceability JSON blocks; max is ${maxLines}`, null, 'split the document into focused markdown files and update the nearest README.md index, or add a narrow validation.markdown.exclude entry if this file is intentionally oversized.');
|
|
90
|
+
if (!skipSizeChecks && chars > maxChars) addError(file, 'FILE_TOO_LARGE', `Markdown file has ${chars} characters excluding generated traceability links and traceability JSON blocks; max is ${maxChars}`, null, 'split the document into focused markdown files and update the nearest README.md index, or add a narrow validation.markdown.exclude entry if this file is intentionally oversized.');
|
|
89
91
|
if (requiresTraceability(rel(root, file), memoryConfig)) {
|
|
90
92
|
const blocks = extractDotdotgodTraceabilityBlocks(content);
|
|
91
93
|
if (blocks.length === 0) addError(file, 'TRACEABILITY_MISSING', `Behavior specs must include a fenced \`json dotdotgod\` traceability block as the final section.\nFix: add a final \`## Traceability\` section with the expected \`json dotdotgod\` block and point it at the relevant source, tests, related docs, and verification commands.\n\n${traceabilityExample()}`);
|
|
92
94
|
else for (const error of validateTraceabilityPlacement(content, root, file)) errors.push(error);
|
|
95
|
+
for (const error of validateTraceabilityLinksRegion(content, root, file)) errors.push(error);
|
|
93
96
|
for (const block of blocks) {
|
|
94
97
|
if (block.error) addError(file, 'TRACEABILITY_INVALID_JSON', `Invalid \`json dotdotgod\` block: ${block.error}\nFix: repair the fenced \`json dotdotgod\` block so it is valid JSON and still matches the expected schema.\n\n${traceabilityExample()}`, block.line);
|
|
95
|
-
else
|
|
98
|
+
else {
|
|
99
|
+
for (const error of validateTraceabilityBlock(block.data, root, file, block.line, memoryConfig)) errors.push(error);
|
|
100
|
+
const synced = syncTraceabilityLinksInContent(content, block.data, root, file);
|
|
101
|
+
if (synced.ok && synced.changed) addError(file, 'TRACEABILITY_LINKS_STALE', 'Generated traceability links or compact traceability JSON are missing or stale.', block.line, 'run `dotdotgod traceability links <root> --write` to synchronize the generated Markdown links and compact JSON block.');
|
|
102
|
+
}
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
}
|
|
@@ -1368,6 +1375,75 @@ export function runExpand(argv) {
|
|
|
1368
1375
|
else console.log(formatReferenceOutput(payload));
|
|
1369
1376
|
}
|
|
1370
1377
|
|
|
1378
|
+
function collectDocsMarkdownFiles(root) {
|
|
1379
|
+
const docs = join(root, 'docs');
|
|
1380
|
+
const files = [];
|
|
1381
|
+
const walk = (dir) => {
|
|
1382
|
+
if (!existsSync(dir)) return;
|
|
1383
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1384
|
+
const path = join(dir, entry.name);
|
|
1385
|
+
if (entry.isDirectory()) walk(path);
|
|
1386
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) files.push(path);
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
walk(docs);
|
|
1390
|
+
return files;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
export function parseTraceabilityOptions(argv) {
|
|
1394
|
+
const sub = argv[0];
|
|
1395
|
+
const action = argv[1];
|
|
1396
|
+
if (sub !== 'links') usage(sub ? `Unknown traceability command: ${sub}` : 'Missing traceability command.', 'traceability');
|
|
1397
|
+
const options = { root: '.', check: false, write: false, json: false };
|
|
1398
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
1399
|
+
const arg = argv[i];
|
|
1400
|
+
if (arg === '--check') options.check = true;
|
|
1401
|
+
else if (arg === '--write') options.write = true;
|
|
1402
|
+
else if (arg === '--json') options.json = true;
|
|
1403
|
+
else if (!arg.startsWith('-')) options.root = arg;
|
|
1404
|
+
else usage(`Unknown option: ${arg}`, 'traceability links');
|
|
1405
|
+
}
|
|
1406
|
+
if (action === '--check' || action === '--write' || action === '--json') {
|
|
1407
|
+
// no-op: action slot is an option, not a positional subcommand.
|
|
1408
|
+
}
|
|
1409
|
+
if (options.check && options.write) usage('Choose only one traceability links mode: --check or --write.', 'traceability links');
|
|
1410
|
+
options.check = options.check || !options.write;
|
|
1411
|
+
options.root = resolve(options.root);
|
|
1412
|
+
return options;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
export function runTraceability(argv) {
|
|
1416
|
+
const options = parseTraceabilityOptions(argv);
|
|
1417
|
+
const files = collectDocsMarkdownFiles(options.root);
|
|
1418
|
+
const results = [];
|
|
1419
|
+
const errors = [];
|
|
1420
|
+
for (const file of files) {
|
|
1421
|
+
const content = readFileSync(file, 'utf8');
|
|
1422
|
+
for (const error of validateTraceabilityLinksRegion(content, options.root, file)) errors.push(error);
|
|
1423
|
+
const blocks = extractDotdotgodTraceabilityBlocks(content).filter((block) => !block.error);
|
|
1424
|
+
if (blocks.length === 0) continue;
|
|
1425
|
+
const result = syncTraceabilityLinksInContent(content, blocks.at(-1).data, options.root, file);
|
|
1426
|
+
if (!result.ok) {
|
|
1427
|
+
for (const error of result.errors ?? []) errors.push(error);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (options.write && result.changed) writeFileSync(file, result.content);
|
|
1431
|
+
if (result.changed) results.push({ file: rel(options.root, file), changed: true });
|
|
1432
|
+
}
|
|
1433
|
+
const ok = errors.length === 0 && (!options.check || results.length === 0);
|
|
1434
|
+
const payload = { ok, command: 'traceability links', root: options.root, mode: options.write ? 'write' : 'check', changed: results.length, files: results, errors };
|
|
1435
|
+
if (options.json) console.log(JSON.stringify(payload, null, 2));
|
|
1436
|
+
else if (errors.length > 0) {
|
|
1437
|
+
for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}`);
|
|
1438
|
+
console.log(`\n❌ ${errors.length} traceability link error(s)`);
|
|
1439
|
+
} else if (options.check && results.length > 0) {
|
|
1440
|
+
for (const result of results) console.log(`${result.file} [TRACEABILITY_LINKS_STALE] generated traceability links are missing or stale.`);
|
|
1441
|
+
console.log(`\n❌ ${results.length} traceability link file(s) need sync`);
|
|
1442
|
+
} else if (options.write) console.log(`traceability links: synced ${results.length} file(s)`);
|
|
1443
|
+
else console.log('traceability links: all generated sections are up to date');
|
|
1444
|
+
process.exit(ok ? 0 : 1);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1371
1447
|
export function parseGraphOptions(argv) {
|
|
1372
1448
|
const filtered = [];
|
|
1373
1449
|
let changed;
|
|
@@ -1443,6 +1519,7 @@ export function runCli(argv = process.argv.slice(2)) {
|
|
|
1443
1519
|
else if (command === 'load-snapshot') runLoadSnapshot(args);
|
|
1444
1520
|
else if (command === 'resolve') runResolve(args);
|
|
1445
1521
|
else if (command === 'expand') runExpand(args);
|
|
1522
|
+
else if (command === 'traceability') runTraceability(args);
|
|
1446
1523
|
else if (command === 'graph') runGraph(args);
|
|
1447
1524
|
else usage(`Unknown command: ${command}`);
|
|
1448
1525
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { dirname, resolve } from 'node:path';
|
|
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
5
|
|
|
5
6
|
function isSecretIndexPath(path) {
|
|
6
7
|
return /(^|\/)(\.env|\.npmrc|\.pypirc|id_rsa|id_dsa|id_ed25519|credentials|secrets?)(\.|\/|$)/i.test(path);
|
|
@@ -8,6 +9,8 @@ function isSecretIndexPath(path) {
|
|
|
8
9
|
|
|
9
10
|
const TRACEABILITY_PATH_FIELDS = ['implementedBy', 'verifiedBy', 'relatedDocs'];
|
|
10
11
|
const TRACEABILITY_COMMAND_FIELDS = ['verificationCommands'];
|
|
12
|
+
export const TRACEABILITY_LINKS_START = '<!-- dotdotgod:traceability-links:start version=1 source=json-dotdotgod -->';
|
|
13
|
+
export const TRACEABILITY_LINKS_END = '<!-- dotdotgod:traceability-links:end -->';
|
|
11
14
|
|
|
12
15
|
export function traceabilityExample() {
|
|
13
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.';
|
|
@@ -27,9 +30,9 @@ export function extractDotdotgodTraceabilityBlocks(content) {
|
|
|
27
30
|
const raw = match[3].trim();
|
|
28
31
|
const line = lineForOffset(content, match.index);
|
|
29
32
|
try {
|
|
30
|
-
blocks.push({ data: JSON.parse(raw), raw, line });
|
|
33
|
+
blocks.push({ data: JSON.parse(raw), raw, line, index: match.index, end: re.lastIndex });
|
|
31
34
|
} catch (error) {
|
|
32
|
-
blocks.push({ error: error instanceof Error ? error.message : String(error), raw, line });
|
|
35
|
+
blocks.push({ error: error instanceof Error ? error.message : String(error), raw, line, index: match.index, end: re.lastIndex });
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
return blocks;
|
|
@@ -55,7 +58,117 @@ export function validateTraceabilityPlacement(content, root, file) {
|
|
|
55
58
|
return [];
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
function allMarkerIndexes(content, marker) {
|
|
62
|
+
const indexes = [];
|
|
63
|
+
let index = content.indexOf(marker);
|
|
64
|
+
while (index !== -1) {
|
|
65
|
+
indexes.push(index);
|
|
66
|
+
index = content.indexOf(marker, index + marker.length);
|
|
67
|
+
}
|
|
68
|
+
return indexes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function findTraceabilityLinksRegion(content) {
|
|
72
|
+
const starts = allMarkerIndexes(content, TRACEABILITY_LINKS_START);
|
|
73
|
+
const ends = allMarkerIndexes(content, TRACEABILITY_LINKS_END);
|
|
74
|
+
if (starts.length === 0 && ends.length === 0) return { status: 'missing' };
|
|
75
|
+
if (starts.length !== 1 || ends.length !== 1) return { status: 'invalid', code: 'TRACEABILITY_LINKS_MARKER_COUNT', message: 'Traceability links region must contain exactly one start marker and one end marker.' };
|
|
76
|
+
const start = starts[0];
|
|
77
|
+
const end = ends[0];
|
|
78
|
+
if (start > end) return { status: 'invalid', code: 'TRACEABILITY_LINKS_MARKER_ORDER', message: 'Traceability links start marker must appear before the end marker.' };
|
|
79
|
+
return { status: 'present', start, end: end + TRACEABILITY_LINKS_END.length };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateTraceabilityLinksRegion(content, root, file) {
|
|
83
|
+
const region = findTraceabilityLinksRegion(content);
|
|
84
|
+
if (region.status !== 'invalid') return [];
|
|
85
|
+
return [traceabilityFieldError(rel(root, file), region.code, null, region.message)];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function stripTraceabilityLinksRegion(content) {
|
|
89
|
+
const ranges = [];
|
|
90
|
+
const region = findTraceabilityLinksRegion(content);
|
|
91
|
+
if (region.status === 'present') ranges.push({ start: region.start, end: region.end });
|
|
92
|
+
for (const block of extractDotdotgodTraceabilityBlocks(content)) ranges.push({ start: block.index, end: block.end });
|
|
93
|
+
if (ranges.length === 0) return content;
|
|
94
|
+
return ranges
|
|
95
|
+
.sort((a, b) => b.start - a.start)
|
|
96
|
+
.reduce((next, range) => `${next.slice(0, range.start)}${next.slice(range.end)}`, content);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function markdownLinkPath(root, file, target) {
|
|
100
|
+
const fromDir = dirname(file);
|
|
101
|
+
const absoluteTarget = resolve(root, target);
|
|
102
|
+
let link = relative(fromDir, absoluteTarget).split('\\').join('/');
|
|
103
|
+
if (!link.startsWith('.') && !link.startsWith('/')) link = link || basename(target);
|
|
104
|
+
return link;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function escapeMarkdownText(value) {
|
|
108
|
+
return String(value).replace(/([\\[\\]])/g, '\\$1');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function escapeCodeSpan(value) {
|
|
112
|
+
return String(value).replace(/`/g, '\\`');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function labelForTraceabilityPath(path) {
|
|
116
|
+
return path;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderPathList(title, paths, root, file) {
|
|
120
|
+
if (!Array.isArray(paths) || paths.length === 0) return [];
|
|
121
|
+
const lines = [`- ${title}:`];
|
|
122
|
+
for (const target of paths) lines.push(` - [${escapeMarkdownText(labelForTraceabilityPath(target))}](${markdownLinkPath(root, file, target)})`);
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderCommandList(commands) {
|
|
127
|
+
if (!Array.isArray(commands) || commands.length === 0) return [];
|
|
128
|
+
return ['- Verification commands:', ...commands.map((command) => ` - \`${escapeCodeSpan(command)}\``)];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function renderTraceabilityLinks(data, root, file) {
|
|
132
|
+
const body = [
|
|
133
|
+
'### Traceability Links',
|
|
134
|
+
'',
|
|
135
|
+
...renderPathList('Implemented by', data?.implementedBy, root, file),
|
|
136
|
+
...renderPathList('Verified by', data?.verifiedBy, root, file),
|
|
137
|
+
...renderPathList('Related docs', data?.relatedDocs, root, file),
|
|
138
|
+
...renderCommandList(data?.verificationCommands),
|
|
139
|
+
];
|
|
140
|
+
return `${TRACEABILITY_LINKS_START}\n<!-- generated: do not edit manually -->\n\n${body.join('\n')}\n\n${TRACEABILITY_LINKS_END}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function renderCompactTraceabilityBlock(data) {
|
|
144
|
+
return `\`\`\`json dotdotgod\n${JSON.stringify(data)}\n\`\`\``;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function syncTraceabilityLinksInContent(content, data, root, file) {
|
|
148
|
+
const region = findTraceabilityLinksRegion(content);
|
|
149
|
+
if (region.status === 'invalid') return { ok: false, changed: false, errors: validateTraceabilityLinksRegion(content, root, file) };
|
|
150
|
+
const generated = renderTraceabilityLinks(data, root, file);
|
|
151
|
+
const blocks = extractDotdotgodTraceabilityBlocks(content).filter((block) => !block.error);
|
|
152
|
+
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.')] };
|
|
153
|
+
const block = blocks.at(-1);
|
|
154
|
+
const compactBlock = renderCompactTraceabilityBlock(data);
|
|
155
|
+
let next = `${content.slice(0, block.index)}${compactBlock}${content.slice(block.end)}`;
|
|
156
|
+
const adjustedRegion = region.status === 'present' && region.start > block.index
|
|
157
|
+
? { start: region.start - (block.end - block.index) + compactBlock.length, end: region.end - (block.end - block.index) + compactBlock.length }
|
|
158
|
+
: region;
|
|
159
|
+
if (adjustedRegion.status === 'present') {
|
|
160
|
+
next = `${next.slice(0, adjustedRegion.start)}${generated}${next.slice(adjustedRegion.end)}`;
|
|
161
|
+
return { ok: true, changed: next !== content, content: next };
|
|
162
|
+
}
|
|
163
|
+
const refreshedBlocks = extractDotdotgodTraceabilityBlocks(next).filter((item) => !item.error);
|
|
164
|
+
const refreshedBlock = refreshedBlocks.at(-1);
|
|
165
|
+
const prefix = next.slice(0, refreshedBlock.index).replace(/[ \t]*$/u, '');
|
|
166
|
+
const suffix = next.slice(refreshedBlock.index);
|
|
167
|
+
next = `${prefix}\n\n${generated}\n\n${suffix}`;
|
|
168
|
+
return { ok: true, changed: next !== content, content: next };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function validateTraceabilityBlock(data, root, file, line = null, config = defaultMemoryConfig()) {
|
|
59
172
|
const errors = [];
|
|
60
173
|
const add = (code, field, message) => errors.push(traceabilityFieldError(rel(root, file), code, field, message, line));
|
|
61
174
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
@@ -73,6 +186,11 @@ export function validateTraceabilityBlock(data, root, file, line = null) {
|
|
|
73
186
|
add('TRACEABILITY_INVALID_PATH', field, `invalid local relative path: ${JSON.stringify(value)}.`);
|
|
74
187
|
continue;
|
|
75
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
|
+
}
|
|
76
194
|
if (!existsSync(resolve(root, value))) add('TRACEABILITY_MISSING_TARGET', field, `target does not exist: ${value}.`);
|
|
77
195
|
}
|
|
78
196
|
}
|
|
@@ -54,7 +54,20 @@ export function buildMemoryAreas(index, limits = {}) {
|
|
|
54
54
|
const config = index?.memoryConfig?.areas ? index.memoryConfig : defaultMemoryConfig();
|
|
55
55
|
const areas = new Map();
|
|
56
56
|
for (const definition of config.areas ?? []) {
|
|
57
|
-
areas.set(definition.id, {
|
|
57
|
+
areas.set(definition.id, {
|
|
58
|
+
area: definition.id,
|
|
59
|
+
label: definition.label,
|
|
60
|
+
role: definition.role,
|
|
61
|
+
...(definition.description ? { description: definition.description } : {}),
|
|
62
|
+
...(definition.clarify ? { clarify: definition.clarify } : {}),
|
|
63
|
+
scope: definition.scope,
|
|
64
|
+
freshness: definition.freshness,
|
|
65
|
+
priority: definition.priority,
|
|
66
|
+
includeBodiesByDefault: definition.includeBodiesByDefault !== false,
|
|
67
|
+
files: [],
|
|
68
|
+
count: 0,
|
|
69
|
+
omitted: 0,
|
|
70
|
+
});
|
|
58
71
|
}
|
|
59
72
|
for (const node of graph.nodes) {
|
|
60
73
|
if (node.type !== 'file') continue;
|
package/src/memory/config.mjs
CHANGED
|
@@ -62,12 +62,32 @@ const DEFAULT_MEMORY_AREAS = [
|
|
|
62
62
|
{ id: 'archive-body', label: 'Archive Body', paths: ['docs/archive/**'], excludePaths: ['docs/archive/README.md'], scope: 'local', freshness: 'stale', role: 'historical-memory-body', priority: 20, includeBodiesByDefault: false },
|
|
63
63
|
];
|
|
64
64
|
|
|
65
|
-
function
|
|
65
|
+
function cloneClarifyGuidance(clarify) {
|
|
66
|
+
if (!clarify || typeof clarify !== 'object' || Array.isArray(clarify)) return undefined;
|
|
67
|
+
return {
|
|
68
|
+
...(Array.isArray(clarify.audience) ? { audience: [...clarify.audience] } : {}),
|
|
69
|
+
...(typeof clarify.documentType === 'string' ? { documentType: clarify.documentType } : {}),
|
|
70
|
+
...(typeof clarify.clarityGoal === 'string' ? { clarityGoal: clarify.clarityGoal } : {}),
|
|
71
|
+
...(Array.isArray(clarify.editRules) ? { editRules: [...clarify.editRules] } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function withOptionalAreaMetadata(target, area) {
|
|
76
|
+
const description = typeof area.description === 'string' ? area.description.trim() : '';
|
|
77
|
+
const clarify = cloneClarifyGuidance(area.clarify);
|
|
66
78
|
return {
|
|
79
|
+
...target,
|
|
80
|
+
...(description ? { description } : {}),
|
|
81
|
+
...(clarify && Object.keys(clarify).length > 0 ? { clarify } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cloneArea(area) {
|
|
86
|
+
return withOptionalAreaMetadata({
|
|
67
87
|
...area,
|
|
68
88
|
paths: [...(area.paths ?? [])],
|
|
69
89
|
excludePaths: [...(area.excludePaths ?? [])],
|
|
70
|
-
};
|
|
90
|
+
}, area);
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
export function cloneTraceabilityPolicy(policy = DEFAULT_TRACEABILITY_POLICY) {
|
|
@@ -216,7 +236,7 @@ export function requiresTraceability(path = '', config = defaultMemoryConfig())
|
|
|
216
236
|
}
|
|
217
237
|
|
|
218
238
|
function normalizeMemoryArea(raw) {
|
|
219
|
-
return {
|
|
239
|
+
return withOptionalAreaMetadata({
|
|
220
240
|
id: raw.id,
|
|
221
241
|
label: raw.label ?? raw.id,
|
|
222
242
|
paths: Array.isArray(raw.paths) ? raw.paths.map(normalizePathPattern) : [],
|
|
@@ -226,7 +246,7 @@ function normalizeMemoryArea(raw) {
|
|
|
226
246
|
role: raw.role ?? raw.id,
|
|
227
247
|
priority: typeof raw.priority === 'number' ? raw.priority : 30,
|
|
228
248
|
includeBodiesByDefault: raw.includeBodiesByDefault !== false,
|
|
229
|
-
};
|
|
249
|
+
}, raw);
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
function isFiniteNumberInRange(value, min, max) {
|
|
@@ -352,6 +372,18 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
|
|
|
352
372
|
if (!MEMORY_FRESHNESS.has(area.freshness)) add('MEMORY_CONFIG_INVALID_FRESHNESS', `${prefix}.freshness`, 'Expected "fresh" or "stale".');
|
|
353
373
|
if (area.priority !== undefined && (!Number.isInteger(area.priority) || area.priority < 0 || area.priority > 100)) add('MEMORY_CONFIG_INVALID_PRIORITY', `${prefix}.priority`, 'Expected an integer from 0 to 100.');
|
|
354
374
|
if (area.includeBodiesByDefault !== undefined && typeof area.includeBodiesByDefault !== 'boolean') add('MEMORY_CONFIG_INVALID_INCLUDE_POLICY', `${prefix}.includeBodiesByDefault`, 'Expected a boolean.');
|
|
375
|
+
if (area.description !== undefined && (typeof area.description !== 'string' || !area.description.trim())) add('MEMORY_CONFIG_INVALID_DESCRIPTION', `${prefix}.description`, 'Expected a non-empty string.');
|
|
376
|
+
if (area.clarify !== undefined) {
|
|
377
|
+
const clarify = area.clarify;
|
|
378
|
+
if (!clarify || typeof clarify !== 'object' || Array.isArray(clarify)) {
|
|
379
|
+
add('MEMORY_CONFIG_INVALID_CLARIFY', `${prefix}.clarify`, 'Expected an object.');
|
|
380
|
+
} else {
|
|
381
|
+
if (clarify.audience !== undefined && (!Array.isArray(clarify.audience) || clarify.audience.some((value) => typeof value !== 'string' || !value.trim()))) add('MEMORY_CONFIG_INVALID_CLARIFY_AUDIENCE', `${prefix}.clarify.audience`, 'Expected an array of non-empty strings.');
|
|
382
|
+
if (clarify.documentType !== undefined && (typeof clarify.documentType !== 'string' || !clarify.documentType.trim())) add('MEMORY_CONFIG_INVALID_CLARIFY_DOCUMENT_TYPE', `${prefix}.clarify.documentType`, 'Expected a non-empty string.');
|
|
383
|
+
if (clarify.clarityGoal !== undefined && (typeof clarify.clarityGoal !== 'string' || !clarify.clarityGoal.trim())) add('MEMORY_CONFIG_INVALID_CLARITY_GOAL', `${prefix}.clarify.clarityGoal`, 'Expected a non-empty string.');
|
|
384
|
+
if (clarify.editRules !== undefined && (!Array.isArray(clarify.editRules) || clarify.editRules.some((value) => typeof value !== 'string' || !value.trim()))) add('MEMORY_CONFIG_INVALID_CLARIFY_EDIT_RULES', `${prefix}.clarify.editRules`, 'Expected an array of non-empty strings.');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
355
387
|
for (const pattern of area.paths ?? []) {
|
|
356
388
|
if (exactIncluded.has(pattern) && !(area.excludePaths ?? []).includes(pattern)) add('MEMORY_CONFIG_OVERLAP', `${prefix}.paths`, `Path pattern also appears in ${exactIncluded.get(pattern)}: ${pattern}`);
|
|
357
389
|
else exactIncluded.set(pattern, `${prefix}.paths`);
|
|
@@ -382,7 +414,7 @@ export function readMemoryConfig(root = '.') {
|
|
|
382
414
|
}
|
|
383
415
|
|
|
384
416
|
function serializableMemoryArea(area) {
|
|
385
|
-
return {
|
|
417
|
+
return withOptionalAreaMetadata({
|
|
386
418
|
id: area.id,
|
|
387
419
|
label: area.label,
|
|
388
420
|
paths: [...(area.paths ?? [])],
|
|
@@ -392,7 +424,7 @@ function serializableMemoryArea(area) {
|
|
|
392
424
|
role: area.role,
|
|
393
425
|
priority: area.priority,
|
|
394
426
|
includeBodiesByDefault: area.includeBodiesByDefault !== false,
|
|
395
|
-
};
|
|
427
|
+
}, area);
|
|
396
428
|
}
|
|
397
429
|
|
|
398
430
|
export function defaultDotdotgodConfigData() {
|
|
@@ -415,17 +447,17 @@ export function defaultDotdotgodConfigText() {
|
|
|
415
447
|
export function memoryConfigSummary(config) {
|
|
416
448
|
return {
|
|
417
449
|
source: config.source ?? 'default',
|
|
418
|
-
areas: (config.areas ?? []).map((area) => ({
|
|
450
|
+
areas: (config.areas ?? []).map((area) => withOptionalAreaMetadata({
|
|
419
451
|
id: area.id,
|
|
420
452
|
label: area.label,
|
|
421
|
-
paths: area.paths,
|
|
422
|
-
excludePaths: area.excludePaths ?? [],
|
|
453
|
+
paths: [...(area.paths ?? [])],
|
|
454
|
+
excludePaths: [...(area.excludePaths ?? [])],
|
|
423
455
|
scope: area.scope,
|
|
424
456
|
freshness: area.freshness,
|
|
425
457
|
role: area.role,
|
|
426
458
|
priority: area.priority,
|
|
427
459
|
includeBodiesByDefault: area.includeBodiesByDefault !== false,
|
|
428
|
-
})),
|
|
460
|
+
}, area)),
|
|
429
461
|
traceability: cloneTraceabilityPolicy(config.traceability ?? DEFAULT_TRACEABILITY_POLICY),
|
|
430
462
|
validation: cloneValidationPolicy(config.validation ?? DEFAULT_VALIDATION_POLICY),
|
|
431
463
|
impactRanking: cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY),
|