@dotdotgod/cli 0.1.35 → 0.1.36
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 +3 -0
- package/bin/dotdotgod.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/usage.mjs +38 -0
- package/src/commands/config.mjs +85 -0
- package/src/commands/graph.mjs +66 -0
- package/src/commands/load-snapshot.mjs +40 -0
- package/src/commands/plan-stage-contract.mjs +63 -0
- package/src/commands/plan.mjs +588 -0
- package/src/commands/traceability.mjs +74 -0
- package/src/core.mjs +46 -1500
- package/src/graph/extract.mjs +78 -0
- package/src/graph/metadata.mjs +88 -0
- package/src/graph/semantic.mjs +100 -0
- package/src/impact/format.mjs +92 -0
- package/src/impact/report.mjs +126 -0
- package/src/impact/scoring.mjs +134 -0
- package/src/index/cache.mjs +128 -0
- package/src/index/constants.mjs +3 -0
- package/src/memory/config.mjs +52 -10
- package/src/reference/extract.mjs +135 -0
- package/src/reference/resolve.mjs +131 -0
- package/src/trello/client.mjs +93 -0
- package/src/trello/custom-fields.mjs +106 -0
- package/src/trello/github-url.mjs +107 -0
- package/src/trello/metadata.mjs +52 -0
- package/src/trello/report.mjs +60 -0
- package/src/trello/summary.mjs +60 -0
- package/src/trello/sync.mjs +205 -0
- package/src/validate/run.mjs +168 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { usage } from '../cli/usage.mjs';
|
|
4
|
+
import { rel } from '../common/paths.mjs';
|
|
5
|
+
import { extractAnchors, extractLinks, isKebabCase, isUpperSnakeMarkdown } from '../docs/markdown.mjs';
|
|
6
|
+
import { extractDotdotgodTraceabilityBlocks, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from '../docs/traceability.mjs';
|
|
7
|
+
import { DEFAULT_VALIDATION_POLICY, cloneValidationPolicy, isMarkdownSizeExcluded, readMemoryConfig, requiresTraceability, resolveMemoryArea } from '../memory/config.mjs';
|
|
8
|
+
import { cacheFile, collectIndexFiles, fingerprint } from '../index/files.mjs';
|
|
9
|
+
import { CACHE_DIR, CACHE_VERSION } from '../index/constants.mjs';
|
|
10
|
+
import { readIndex } from '../index/cache.mjs';
|
|
11
|
+
|
|
12
|
+
export function runValidate(argv) {
|
|
13
|
+
const options = { root: '.', includeLocalMemory: false, checkIndex: false, maxLines: null, maxChars: null, linkCheck: true, json: false };
|
|
14
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
15
|
+
const arg = argv[i];
|
|
16
|
+
if (arg === '--include-local-memory') options.includeLocalMemory = true;
|
|
17
|
+
else if (arg === '--check-index') options.checkIndex = true;
|
|
18
|
+
else if (arg === '--max-lines') options.maxLines = Number(argv[++i]);
|
|
19
|
+
else if (arg === '--max-chars') options.maxChars = Number(argv[++i]);
|
|
20
|
+
else if (arg === '--no-link-check') options.linkCheck = false;
|
|
21
|
+
else if (arg === '--json') options.json = true;
|
|
22
|
+
else if (!arg.startsWith('-')) options.root = arg;
|
|
23
|
+
else usage(`Unknown option: ${arg}`, 'validate');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const root = resolve(options.root);
|
|
27
|
+
const docs = join(root, 'docs');
|
|
28
|
+
const errors = [];
|
|
29
|
+
const markdownFiles = [];
|
|
30
|
+
const fileCache = new Map();
|
|
31
|
+
const addError = (file, code, message, line = null, fix = null, prompt = null) => errors.push({ file: rel(root, file), line, code, message: fix ? `${message}\nFix: ${fix}` : message, ...(prompt ? { prompt } : {}) });
|
|
32
|
+
const shouldSkipDir = (dir) => {
|
|
33
|
+
const path = rel(root, dir);
|
|
34
|
+
if (!path || path === '.') return false;
|
|
35
|
+
if (path.includes('/node_modules') || path === 'node_modules') return true;
|
|
36
|
+
if (path.includes('/.git') || path === '.git') return true;
|
|
37
|
+
if (path === CACHE_DIR || path.startsWith(`${CACHE_DIR}/`)) return true;
|
|
38
|
+
if (!options.includeLocalMemory && (path === 'docs/plan' || path.startsWith('docs/plan/') || path === 'docs/archive' || path.startsWith('docs/archive/'))) return true;
|
|
39
|
+
return false;
|
|
40
|
+
};
|
|
41
|
+
const walk = (dir) => {
|
|
42
|
+
if (!existsSync(dir) || shouldSkipDir(dir)) return;
|
|
43
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
+
const path = join(dir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
const docsRel = rel(docs, path);
|
|
47
|
+
if (docsRel && !docsRel.startsWith('..')) {
|
|
48
|
+
const isPlanInternalWorkspaceDir = /^(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan(?:\/|$)/.test(docsRel);
|
|
49
|
+
if (!isPlanInternalWorkspaceDir) for (const part of docsRel.split('/')) if (part && !isKebabCase(part)) addError(path, 'DIR_NAMING', `Directory must be kebab-case: ${part}`, null, 'rename this docs directory to kebab-case and update any links that reference it.');
|
|
50
|
+
}
|
|
51
|
+
walk(path);
|
|
52
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) markdownFiles.push(path);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (!existsSync(docs)) usage(`docs directory not found: ${docs}`);
|
|
57
|
+
const memoryConfig = readMemoryConfig(root);
|
|
58
|
+
const validationPolicy = cloneValidationPolicy(memoryConfig.validation ?? DEFAULT_VALIDATION_POLICY);
|
|
59
|
+
const maxLines = options.maxLines ?? validationPolicy.markdown.maxLines;
|
|
60
|
+
const maxChars = options.maxChars ?? validationPolicy.markdown.maxChars;
|
|
61
|
+
const markdownSizePrompt = (path) => {
|
|
62
|
+
const area = resolveMemoryArea(path, memoryConfig);
|
|
63
|
+
const areaText = area ? `${area.label ?? area.id} (${area.id})` : 'the matching documentation area';
|
|
64
|
+
const roleText = area?.role ?? 'the document role';
|
|
65
|
+
return `Split ${path} into focused documents by documentation area and role. Keep the current ${areaText} area semantics and ${roleText} role intact, move role-specific detail into smaller UPPER_SNAKE_CASE markdown files, and update the nearest README.md index with each new file's purpose.`;
|
|
66
|
+
};
|
|
67
|
+
for (const error of memoryConfig.errors ?? []) errors.push(error);
|
|
68
|
+
walk(docs);
|
|
69
|
+
for (const file of markdownFiles) {
|
|
70
|
+
const name = basename(file);
|
|
71
|
+
const docsRel = rel(docs, file);
|
|
72
|
+
const rootRel = rel(root, file);
|
|
73
|
+
const isPlanInternalWorkspaceFile = /^docs\/(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan\/(?:[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*|[0-9]{2}_[A-Z0-9]+(?:_[A-Z0-9]+)*)\.md$/.test(rootRel);
|
|
74
|
+
if (docsRel && !docsRel.startsWith('..') && !isPlanInternalWorkspaceFile && !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.');
|
|
75
|
+
const content = readFileSync(file, 'utf8');
|
|
76
|
+
fileCache.set(file, content);
|
|
77
|
+
const budgetContent = stripTraceabilityLinksRegion(content);
|
|
78
|
+
const lines = budgetContent.split('\n').length;
|
|
79
|
+
const chars = budgetContent.length;
|
|
80
|
+
const path = rootRel;
|
|
81
|
+
const skipSizeChecks = isMarkdownSizeExcluded(path, memoryConfig);
|
|
82
|
+
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.', markdownSizePrompt(path));
|
|
83
|
+
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.', markdownSizePrompt(path));
|
|
84
|
+
if (requiresTraceability(rel(root, file), memoryConfig)) {
|
|
85
|
+
const blocks = extractDotdotgodTraceabilityBlocks(content);
|
|
86
|
+
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()}`);
|
|
87
|
+
else for (const error of validateTraceabilityPlacement(content, root, file)) errors.push(error);
|
|
88
|
+
for (const error of validateTraceabilityLinksRegion(content, root, file)) errors.push(error);
|
|
89
|
+
for (const block of blocks) {
|
|
90
|
+
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);
|
|
91
|
+
else {
|
|
92
|
+
for (const error of validateTraceabilityBlock(block.data, root, file, block.line, memoryConfig)) errors.push(error);
|
|
93
|
+
const synced = syncTraceabilityLinksInContent(content, block.data, root, file);
|
|
94
|
+
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.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const byDir = new Map();
|
|
100
|
+
for (const file of markdownFiles) byDir.set(dirname(file), [...(byDir.get(dirname(file)) ?? []), file]);
|
|
101
|
+
for (const [dir, files] of byDir) {
|
|
102
|
+
const dirRel = rel(root, dir);
|
|
103
|
+
if (/^docs\/(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan$/.test(dirRel)) continue;
|
|
104
|
+
if (files.length > 1 && !files.some((file) => basename(file) === 'README.md')) addError(dir, 'MISSING_README', 'Directory with multiple markdown files must include README.md', null, 'add a README.md in this directory that indexes the important markdown files and their purpose.');
|
|
105
|
+
}
|
|
106
|
+
if (options.linkCheck) {
|
|
107
|
+
for (const [file, content] of fileCache) {
|
|
108
|
+
const fileDir = dirname(file);
|
|
109
|
+
for (const { href, line } of extractLinks(content)) {
|
|
110
|
+
const hashIndex = href.indexOf('#');
|
|
111
|
+
const pathPart = hashIndex === -1 ? href : href.slice(0, hashIndex);
|
|
112
|
+
const anchor = hashIndex === -1 ? '' : href.slice(hashIndex + 1);
|
|
113
|
+
const target = pathPart ? resolve(fileDir, pathPart) : file;
|
|
114
|
+
if (pathPart && !existsSync(target)) {
|
|
115
|
+
addError(file, 'BROKEN_LINK', `Local link target does not exist: ${pathPart}`, line, 'update the link target to an existing local file, create the intended file, or remove the stale link.');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (anchor && extname(target) === '.md') {
|
|
119
|
+
const targetContent = fileCache.get(target) ?? (existsSync(target) ? readFileSync(target, 'utf8') : '');
|
|
120
|
+
if (targetContent && !extractAnchors(targetContent).has(decodeURIComponent(anchor))) addError(file, 'BROKEN_ANCHOR', `Local anchor target does not exist: ${href}`, line, 'update the fragment to a heading that exists in the target markdown file, or add the missing heading.');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (options.checkIndex) {
|
|
126
|
+
const index = readIndex(root);
|
|
127
|
+
if (!index) addError(cacheFile(root), 'INDEX_MISSING', 'Expected .dotdotgod index cache.', null, 'run `dotdotgod index <root>` or a lazy-refreshing command such as `dotdotgod load-snapshot <root> --json`.');
|
|
128
|
+
else {
|
|
129
|
+
const schemaVersion = index.schemaVersion ?? index.version ?? null;
|
|
130
|
+
if (schemaVersion !== CACHE_VERSION) addError(cacheFile(root), 'INDEX_SCHEMA_MISMATCH', `Index schema is ${String(schemaVersion)}; expected ${CACHE_VERSION}.`, null, 'run `dotdotgod index <root>` to rebuild the cache with the current schema.');
|
|
131
|
+
const indexed = new Map((index.files ?? []).map((file) => [file.path, file.sha256]));
|
|
132
|
+
const indexableMarkdownPaths = new Set(collectIndexFiles(root, memoryConfig).map((file) => rel(root, file)).filter((path) => path.endsWith('.md')));
|
|
133
|
+
for (const file of markdownFiles) {
|
|
134
|
+
const path = rel(root, file);
|
|
135
|
+
if (!indexableMarkdownPaths.has(path)) continue;
|
|
136
|
+
const indexedHash = indexed.get(path);
|
|
137
|
+
const currentHash = fingerprint(file);
|
|
138
|
+
if (!indexedHash) addError(file, 'INDEX_MISSING_FILE', 'Markdown file is not present in the current graph index.', null, 'run `dotdotgod index <root>` to refresh the graph index.');
|
|
139
|
+
else if (indexedHash !== currentHash) addError(file, 'INDEX_STALE', 'Markdown fingerprint differs from the current graph index.', null, 'run `dotdotgod index <root>` or a lazy-refreshing command such as `dotdotgod load-snapshot <root> --json`.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (options.includeLocalMemory) {
|
|
144
|
+
for (const area of ['plan', 'archive/plan', 'archive/report']) {
|
|
145
|
+
const areaRoot = join(docs, area);
|
|
146
|
+
if (!existsSync(areaRoot)) continue;
|
|
147
|
+
for (const entry of readdirSync(areaRoot, { withFileTypes: true })) {
|
|
148
|
+
if (!entry.isDirectory()) continue;
|
|
149
|
+
if (!isKebabCase(entry.name)) addError(join(areaRoot, entry.name), 'SLUG_NAMING', `Archive/plan/report slug must be kebab-case: ${entry.name}`, null, 'rename the task/report directory to kebab-case and update any README index links that reference it.');
|
|
150
|
+
const readme = join(areaRoot, entry.name, 'README.md');
|
|
151
|
+
if (!existsSync(readme)) addError(readme, 'MISSING_README', `Expected README.md in docs/${area}/${entry.name}/`, null, 'add a README.md that summarizes the task/report and links any supporting files.');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const gitignore = join(root, '.gitignore');
|
|
156
|
+
if (!existsSync(gitignore)) addError(gitignore, 'MISSING_GITIGNORE', 'Expected .gitignore', null, 'create .gitignore and include docs/plan, docs/archive, and .dotdotgod entries.');
|
|
157
|
+
else {
|
|
158
|
+
const content = readFileSync(gitignore, 'utf8').split('\n').map((line) => line.trim());
|
|
159
|
+
for (const required of ['docs/plan', 'docs/archive', CACHE_DIR]) if (!content.includes(required)) addError(gitignore, 'MISSING_GITIGNORE_ENTRY', `Expected .gitignore entry: ${required}`, null, `add ${required} to .gitignore so local plans, archives, and cache files stay untracked.`);
|
|
160
|
+
}
|
|
161
|
+
if (options.json) console.log(JSON.stringify({ ok: errors.length === 0, errors }, null, 2));
|
|
162
|
+
else if (errors.length === 0) console.log(`✅ docs validation passed (${markdownFiles.length} markdown files)`);
|
|
163
|
+
else {
|
|
164
|
+
for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}${error.prompt ? `\nPrompt: ${error.prompt}` : ''}`);
|
|
165
|
+
console.log(`\n❌ ${errors.length} docs validation error(s)`);
|
|
166
|
+
}
|
|
167
|
+
process.exit(errors.length === 0 ? 0 : 1);
|
|
168
|
+
}
|