@dotdotgod/cli 0.3.9 → 0.3.10
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/docs/traceability.mjs +3 -1
- package/src/validate/references.mjs +132 -0
- package/src/validate/run.mjs +6 -0
package/package.json
CHANGED
|
@@ -98,9 +98,11 @@ export function stripTraceabilityLinksRegion(content) {
|
|
|
98
98
|
if (region.status === 'present') ranges.push({ start: region.start, end: region.end });
|
|
99
99
|
for (const block of extractDotdotgodTraceabilityBlocks(content)) ranges.push({ start: block.index, end: block.end });
|
|
100
100
|
if (ranges.length === 0) return content;
|
|
101
|
-
|
|
101
|
+
const stripped = ranges
|
|
102
102
|
.sort((a, b) => b.start - a.start)
|
|
103
103
|
.reduce((next, range) => `${next.slice(0, range.start)}${next.slice(range.end)}`, content);
|
|
104
|
+
// Keep surrounding prose and one line separator, but not metadata headings or padding.
|
|
105
|
+
return stripped.replace(/(?:^[ \t]*\r?\n)*^##[ \t]+Traceability[ \t]*\r?(?:\n(?:[ \t]*\r?\n)*|$)/gm, '');
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
function markdownLinkPath(root, file, target) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
|
+
import { findTraceabilityLinksRegion } from '../docs/traceability.mjs';
|
|
4
|
+
import { resolveMemoryArea } from '../memory/config.mjs';
|
|
5
|
+
|
|
6
|
+
const blank = (text) => text.replace(/[^\n\r]/g, ' ');
|
|
7
|
+
const unescape = (text) => text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/g, '$1');
|
|
8
|
+
const labelKey = (text) => unescape(text).trim().replace(/\s+/g, ' ').toLowerCase();
|
|
9
|
+
const escaped = (text, index) => {
|
|
10
|
+
let count = 0;
|
|
11
|
+
while (index > 0 && text[--index] === '\\') count++;
|
|
12
|
+
return count % 2 === 1;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function visibleContent(content) {
|
|
16
|
+
const region = findTraceabilityLinksRegion(content);
|
|
17
|
+
let text = region.status === 'present'
|
|
18
|
+
? content.slice(0, region.start) + blank(content.slice(region.start, region.end)) + content.slice(region.end)
|
|
19
|
+
: content;
|
|
20
|
+
const tokens = /<!--[\s\S]*?(?:-->|(?![\s\S]))|^ {0,3}(`{3,}|~{3,})[^\n]*/gm;
|
|
21
|
+
let token;
|
|
22
|
+
while ((token = tokens.exec(text))) {
|
|
23
|
+
let end = tokens.lastIndex;
|
|
24
|
+
if (token[1]) {
|
|
25
|
+
const closing = new RegExp(`^ {0,3}${token[1][0]}{${token[1].length},}[ \\t]*\\r?$`, 'gm');
|
|
26
|
+
closing.lastIndex = end + 1;
|
|
27
|
+
end = closing.exec(text) ? closing.lastIndex : text.length;
|
|
28
|
+
}
|
|
29
|
+
text = text.slice(0, token.index) + blank(text.slice(token.index, end)) + text.slice(end);
|
|
30
|
+
tokens.lastIndex = end;
|
|
31
|
+
}
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read Markdown destinations with balanced parentheses and optional angle brackets.
|
|
36
|
+
function destination(text, start) {
|
|
37
|
+
let i = start;
|
|
38
|
+
while (/\s/.test(text[i] ?? '') && i < text.length) i++;
|
|
39
|
+
const begin = i;
|
|
40
|
+
if (text[i] === '<') {
|
|
41
|
+
for (++i; i < text.length; i++) {
|
|
42
|
+
if (text[i] === '>' && !escaped(text, i)) return { href: text.slice(begin + 1, i), end: i + 1 };
|
|
43
|
+
if (text[i] === '\n') return null;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
let depth = 0;
|
|
48
|
+
for (; i < text.length; i++) {
|
|
49
|
+
if (escaped(text, i)) continue;
|
|
50
|
+
if (/\s/.test(text[i]) || (text[i] === ')' && depth === 0)) break;
|
|
51
|
+
if (text[i] === '(') depth++;
|
|
52
|
+
if (text[i] === ')') depth--;
|
|
53
|
+
}
|
|
54
|
+
return depth === 0 && i > begin ? { href: text.slice(begin, i), end: i } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function extractMemoryReferences(content) {
|
|
58
|
+
let text = visibleContent(content);
|
|
59
|
+
const references = [];
|
|
60
|
+
const code = [];
|
|
61
|
+
const lineAt = (offset) => content.slice(0, offset).split('\n').length;
|
|
62
|
+
// Mask code before link parsing so literal Markdown inside code is not a link.
|
|
63
|
+
text = text.replace(/(`+)([\s\S]*?)\1(?!`)/g, (match, ticks, value, offset) => {
|
|
64
|
+
if (escaped(text, offset)) return match;
|
|
65
|
+
code.push({ href: value.trim(), offset, end: offset + match.length, kind: 'inline-code', line: lineAt(offset) });
|
|
66
|
+
return blank(match);
|
|
67
|
+
});
|
|
68
|
+
const definitions = new Map();
|
|
69
|
+
text = text.replace(/^ {0,3}\[([^\]\n]+)\]:[ \t]*(.*)$/gm, (match, label, value) => {
|
|
70
|
+
const parsed = destination(value, 0);
|
|
71
|
+
if (parsed && !definitions.has(labelKey(label))) definitions.set(labelKey(label), parsed.href);
|
|
72
|
+
return blank(match);
|
|
73
|
+
});
|
|
74
|
+
const linkRanges = [];
|
|
75
|
+
const labels = /!?\[((?:\\.|[^\]\\\n])*)\]/g;
|
|
76
|
+
let match;
|
|
77
|
+
while ((match = labels.exec(text))) {
|
|
78
|
+
if (escaped(text, match.index) || (match[0][0] === '!' && escaped(text, match.index + 1))) continue;
|
|
79
|
+
const after = labels.lastIndex;
|
|
80
|
+
let end = after;
|
|
81
|
+
let href;
|
|
82
|
+
if (text[after] === '(') {
|
|
83
|
+
const parsed = destination(text, after + 1);
|
|
84
|
+
if (parsed) {
|
|
85
|
+
const tail = text.slice(parsed.end).match(/^\s*(?:(?:"[^"\n]*"|'[^'\n]*'|\([^\n)]*\))\s*)?\)/);
|
|
86
|
+
if (tail) { href = parsed.href; end = parsed.end + tail[0].length; }
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const suffix = text.slice(after).match(/^\[([^\]\n]*)\]/);
|
|
90
|
+
const label = suffix?.[1] || match[1];
|
|
91
|
+
href = definitions.get(labelKey(label));
|
|
92
|
+
if (suffix) end += suffix[0].length;
|
|
93
|
+
}
|
|
94
|
+
if (href !== undefined) {
|
|
95
|
+
references.push({ href: unescape(href), kind: 'markdown-link', line: lineAt(match.index) });
|
|
96
|
+
linkRanges.push([match.index, end]);
|
|
97
|
+
labels.lastIndex = end;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const item of code) {
|
|
101
|
+
if (linkRanges.some(([start, end]) => item.offset >= start && item.end <= end)) continue;
|
|
102
|
+
const path = item.href.split('#')[0];
|
|
103
|
+
// Policy A: syntax-only examples are not concrete file references.
|
|
104
|
+
if ((!path.includes('/') && !/\.[\w-]+$/.test(path)) || /[\s*?<>\[\]{}]/.test(path) || path.endsWith('/')) continue;
|
|
105
|
+
references.push({ href: item.href, kind: item.kind, line: item.line });
|
|
106
|
+
}
|
|
107
|
+
return references;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function sharedLocalReferences(content, root, file, config) {
|
|
111
|
+
const source = resolveMemoryArea(relative(root, file).split('\\').join('/'), config);
|
|
112
|
+
if (source?.scope !== 'shared') return [];
|
|
113
|
+
const errors = [];
|
|
114
|
+
for (const item of extractMemoryReferences(content)) {
|
|
115
|
+
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(item.href)) continue;
|
|
116
|
+
let path;
|
|
117
|
+
try { path = decodeURIComponent(item.href.split(/[?#]/)[0]); } catch { continue; }
|
|
118
|
+
if (!path) continue;
|
|
119
|
+
const base = item.kind === 'markdown-link' || /^\.\.?\//.test(path) ? dirname(file) : root;
|
|
120
|
+
const target = relative(root, resolve(base, path)).split('\\').join('/');
|
|
121
|
+
if (target === '..' || target.startsWith('../')) continue;
|
|
122
|
+
if (item.kind === 'inline-code') {
|
|
123
|
+
const configuredDirectory = (config.areas ?? []).some((area) => (area.paths ?? []).some((pattern) => pattern === `${target}/**`));
|
|
124
|
+
let directory = configuredDirectory;
|
|
125
|
+
try { directory ||= statSync(resolve(root, target)).isDirectory(); } catch { /* Missing concrete paths still receive scope checks. */ }
|
|
126
|
+
if (directory) continue;
|
|
127
|
+
}
|
|
128
|
+
const area = resolveMemoryArea(target, config);
|
|
129
|
+
if (area?.scope === 'local') errors.push({ ...item, target, sourceArea: source.id, targetArea: area.id });
|
|
130
|
+
}
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
package/src/validate/run.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { DEFAULT_VALIDATION_POLICY, cloneValidationPolicy, isMarkdownSizeExclude
|
|
|
8
8
|
import { cacheFile, collectIndexFiles, fingerprint } from '../index/files.mjs';
|
|
9
9
|
import { CACHE_DIR, CACHE_VERSION } from '../index/constants.mjs';
|
|
10
10
|
import { readIndex } from '../index/cache.mjs';
|
|
11
|
+
import { sharedLocalReferences } from './references.mjs';
|
|
11
12
|
|
|
12
13
|
export function runValidate(argv) {
|
|
13
14
|
const options = { root: '.', includeLocalMemory: false, checkIndex: false, maxLines: null, maxChars: null, linkCheck: true, json: false };
|
|
@@ -147,6 +148,11 @@ export function runValidate(argv) {
|
|
|
147
148
|
for (const [dir, files] of byDir) {
|
|
148
149
|
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.');
|
|
149
150
|
}
|
|
151
|
+
for (const [file, content] of fileCache) {
|
|
152
|
+
for (const item of sharedLocalReferences(content, root, file, memoryConfig)) {
|
|
153
|
+
addError(file, 'SHARED_LOCAL_MEMORY_REFERENCE', `${item.kind} from shared area "${item.sourceArea}" targets local area "${item.targetArea}": ${item.target}`, item.line, 'replace the local reference with shared durable documentation, or remove the dependency. Inline-code directory, glob, and placeholder usage examples are allowed.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
150
156
|
if (options.linkCheck) {
|
|
151
157
|
for (const [file, content] of fileCache) {
|
|
152
158
|
const fileDir = dirname(file);
|