@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "CLI for dotdotgod validation, local documentation search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -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
- return ranges
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
+ }
@@ -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);