dotmd-cli 0.76.4 → 0.76.6
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/body-link.mjs +40 -0
- package/src/extractors.mjs +20 -8
- package/src/fix-refs.mjs +21 -10
- package/src/hub-membership.mjs +9 -2
- package/src/prompts.mjs +1 -1
- package/src/render.mjs +7 -1
- package/src/sync-status.mjs +10 -8
- package/src/validate.mjs +23 -10
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function isWithin(root, candidate) {
|
|
5
|
+
const relative = path.relative(root, candidate);
|
|
6
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Markdown body links are filesystem paths relative to the document that
|
|
10
|
+
// contains them. They deliberately do not inherit frontmatter references'
|
|
11
|
+
// repo-root fallback: that fallback can make a broken Markdown link look valid.
|
|
12
|
+
export function resolveBodyLinkTarget(href, docDir, repoRoot) {
|
|
13
|
+
if (!href) return { ok: false, reason: 'missing' };
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(repoRoot);
|
|
16
|
+
const candidate = path.resolve(docDir, href);
|
|
17
|
+
if (!isWithin(root, candidate)) return { ok: false, reason: 'outside-repo' };
|
|
18
|
+
if (!existsSync(candidate)) return { ok: false, reason: 'missing' };
|
|
19
|
+
|
|
20
|
+
let canonicalRoot;
|
|
21
|
+
let canonicalTarget;
|
|
22
|
+
try {
|
|
23
|
+
canonicalRoot = realpathSync(root);
|
|
24
|
+
canonicalTarget = realpathSync(candidate);
|
|
25
|
+
} catch {
|
|
26
|
+
return { ok: false, reason: 'unreadable' };
|
|
27
|
+
}
|
|
28
|
+
if (!isWithin(canonicalRoot, canonicalTarget)) return { ok: false, reason: 'outside-repo' };
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const stat = statSync(canonicalTarget);
|
|
32
|
+
if (!stat.isFile() && !stat.isDirectory()) return { ok: false, reason: 'unsupported-target' };
|
|
33
|
+
} catch {
|
|
34
|
+
return { ok: false, reason: 'unreadable' };
|
|
35
|
+
}
|
|
36
|
+
// Return the authored lexical route after using the canonical route only for
|
|
37
|
+
// containment. Callers index documents by their repo-relative spelling (and
|
|
38
|
+
// may intentionally include an in-repo symlink alias).
|
|
39
|
+
return { ok: true, path: candidate, realPath: canonicalTarget };
|
|
40
|
+
}
|
package/src/extractors.mjs
CHANGED
|
@@ -53,16 +53,28 @@ export function extractBodyLinks(body) {
|
|
|
53
53
|
.replace(/^```[\s\S]*?^```/gm, '')
|
|
54
54
|
.replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
|
|
55
55
|
const links = [];
|
|
56
|
-
//
|
|
57
|
-
|
|
56
|
+
// Supported inline destinations are a whitespace-free token (with Markdown
|
|
57
|
+
// backslash escapes) or an angle-bracket destination, plus an optional
|
|
58
|
+
// quoted/parenthesized title. Reference links, HTML links, and nested
|
|
59
|
+
// unescaped parentheses remain deliberately outside this lightweight parser.
|
|
60
|
+
const regex = /(?<!!)\[([^\]\n]+)\]\(\s*(<[^>\n]+>|(?:\\.|[^\s()])+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^\n)]*\)))?\s*\)/g;
|
|
58
61
|
let match;
|
|
59
62
|
while ((match = regex.exec(stripped)) !== null) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
const destination = match[2];
|
|
64
|
+
const angle = destination.startsWith('<') && destination.endsWith('>');
|
|
65
|
+
const rawHref = angle ? destination.slice(1, -1) : destination;
|
|
66
|
+
// These destinations are meaningful to a renderer or another application,
|
|
67
|
+
// not portable repo-local filesystem targets.
|
|
68
|
+
if (/^(?:#|\/|[a-z][a-z\d+.-]*:)/i.test(rawHref)) continue;
|
|
69
|
+
|
|
70
|
+
const rawPath = rawHref.replace(/[?#].*$/, '');
|
|
71
|
+
if (!rawPath) continue;
|
|
72
|
+
const unescaped = rawPath.replace(/\\([\s()[\]<>])/g, '$1');
|
|
73
|
+
let href;
|
|
74
|
+
try { href = decodeURIComponent(unescaped); }
|
|
75
|
+
catch { href = unescaped; }
|
|
76
|
+
const targetKind = /\.md$/i.test(href) ? 'document' : 'file';
|
|
77
|
+
links.push({ text: match[1], rawHref, href, targetKind, angle });
|
|
66
78
|
}
|
|
67
79
|
return links;
|
|
68
80
|
}
|
package/src/fix-refs.mjs
CHANGED
|
@@ -102,7 +102,9 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
102
102
|
|
|
103
103
|
for (const doc of index.docs) {
|
|
104
104
|
const brokenBodyWarnings = doc.warnings.filter(w =>
|
|
105
|
-
w.
|
|
105
|
+
w.meta?.kind === 'body-link-resolution'
|
|
106
|
+
&& w.meta?.targetKind === 'document'
|
|
107
|
+
&& w.meta?.reason === 'missing'
|
|
106
108
|
);
|
|
107
109
|
if (!brokenBodyWarnings.length) continue;
|
|
108
110
|
|
|
@@ -112,12 +114,14 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
112
114
|
let newBody = body;
|
|
113
115
|
const docDir = path.dirname(absPath);
|
|
114
116
|
const bodyFixes = [];
|
|
117
|
+
const seenBodyTargets = new Set();
|
|
115
118
|
|
|
116
119
|
for (const warn of brokenBodyWarnings) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
const brokenHref = warn.meta.relPath;
|
|
121
|
+
const rawHref = warn.meta.rawHref ?? brokenHref;
|
|
122
|
+
const targetKey = `${warn.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
|
|
123
|
+
if (seenBodyTargets.has(targetKey)) continue;
|
|
124
|
+
seenBodyTargets.add(targetKey);
|
|
121
125
|
const brokenBasename = path.basename(brokenHref);
|
|
122
126
|
|
|
123
127
|
if (duplicateBasenames.has(brokenBasename)) continue;
|
|
@@ -127,14 +131,21 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
127
131
|
const correctHref = path.relative(docDir, resolved).split(path.sep).join('/');
|
|
128
132
|
if (correctHref === brokenHref) continue;
|
|
129
133
|
|
|
134
|
+
const suffixAt = rawHref.search(/[?#]/);
|
|
135
|
+
const suffix = suffixAt === -1 ? '' : rawHref.slice(suffixAt);
|
|
136
|
+
const renderedHref = warn.meta.angle
|
|
137
|
+
? `<${correctHref}${suffix}>`
|
|
138
|
+
: `${correctHref.replace(/([\s()[\]<>])/g, '\\$1')}${suffix}`;
|
|
130
139
|
const linkRegex = new RegExp(
|
|
131
|
-
'(
|
|
140
|
+
'(\\]\\(\\s*)' + (warn.meta.angle ? '<' : '') + escapeRegex(rawHref) + (warn.meta.angle ? '>' : '') + '(?=\\s|\\))',
|
|
132
141
|
'g'
|
|
133
142
|
);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
let replacements = 0;
|
|
144
|
+
newBody = newBody.replace(linkRegex, (_, prefix) => {
|
|
145
|
+
replacements++;
|
|
146
|
+
return `${prefix}${renderedHref}`;
|
|
147
|
+
});
|
|
148
|
+
for (let i = 0; i < replacements; i++) bodyFixes.push({ brokenHref, correctHref });
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
if (bodyFixes.length > 0 && newBody !== body) {
|
package/src/hub-membership.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { resolveRefPath, toRepoPath } from './util.mjs';
|
|
5
5
|
import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
|
|
6
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
6
7
|
|
|
7
8
|
// Membership drift: a hub's list of children and the plans that claim it via
|
|
8
9
|
// `parent_plan:` are two halves of one relationship, and either half can go
|
|
@@ -41,7 +42,8 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
41
42
|
const warnings = [];
|
|
42
43
|
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
43
44
|
// type stays loud for another that declared the same name.
|
|
44
|
-
const quiet = (d) => config.lifecycle?.
|
|
45
|
+
const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
|
|
46
|
+
?? config.lifecycle?.terminalStatuses?.has(d.status))
|
|
45
47
|
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
46
48
|
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
47
49
|
const refFields = [
|
|
@@ -54,6 +56,11 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
54
56
|
const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
|
|
55
57
|
return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
56
58
|
};
|
|
59
|
+
const resolveBody = (link, dir) => {
|
|
60
|
+
if (link.targetKind && link.targetKind !== 'document') return null;
|
|
61
|
+
const result = resolveBodyLinkTarget(link.href, dir, config.repoRoot);
|
|
62
|
+
return result.ok ? byPath.get(toRepoPath(result.path, config.repoRoot)) ?? null : null;
|
|
63
|
+
};
|
|
57
64
|
|
|
58
65
|
// Everything a hub says about other docs: every configured reference field
|
|
59
66
|
// plus every body link. Config-driven rather than a hardcoded field list, so a
|
|
@@ -68,7 +75,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
for (const link of (hub.bodyLinks ?? [])) {
|
|
71
|
-
const target =
|
|
78
|
+
const target = resolveBody(link, dir);
|
|
72
79
|
if (target) known.add(target.path);
|
|
73
80
|
}
|
|
74
81
|
return known;
|
package/src/prompts.mjs
CHANGED
|
@@ -118,7 +118,7 @@ function findPromptTarget(promptDoc, config) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
const links = promptDoc.bodyLinks ?? [];
|
|
121
|
-
const mdLink = links.find(l =>
|
|
121
|
+
const mdLink = links.find(l => l.targetKind === 'document');
|
|
122
122
|
if (mdLink) return resolveBodyLink(mdLink.href, promptDoc.path);
|
|
123
123
|
return null;
|
|
124
124
|
}
|
package/src/render.mjs
CHANGED
|
@@ -454,7 +454,13 @@ export function classifyIssueAction(issue) {
|
|
|
454
454
|
if (/`modules` is required/.test(message)) {
|
|
455
455
|
return { action: `edit ${file} modules: or run dotmd lint --fix if scaffolded empty`, fixable: false, label: 'module metadata' };
|
|
456
456
|
}
|
|
457
|
-
if (
|
|
457
|
+
if (issue?.meta?.kind === 'body-link-resolution') {
|
|
458
|
+
if (issue.meta.targetKind === 'document' && issue.meta.reason === 'missing') {
|
|
459
|
+
return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'document links' };
|
|
460
|
+
}
|
|
461
|
+
return { action: `edit ${file}: correct the linked file, asset, or directory`, fixable: false, label: 'file links' };
|
|
462
|
+
}
|
|
463
|
+
if (/entry `.*` does not resolve/.test(message)) {
|
|
458
464
|
return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'references' };
|
|
459
465
|
}
|
|
460
466
|
|
package/src/sync-status.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
|
-
import { die, isArchivedPath,
|
|
4
|
+
import { die, isArchivedPath, toRepoPath } from './util.mjs';
|
|
5
5
|
import { cyan, dim, green, red, yellow } from './color.mjs';
|
|
6
6
|
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
7
8
|
import {
|
|
8
9
|
MARKER_CLOSE,
|
|
9
10
|
MARKER_OPEN,
|
|
@@ -21,8 +22,8 @@ import {
|
|
|
21
22
|
// dotmd already reads those rows to compute next-pickup, so the guard is
|
|
22
23
|
// standing next to the data it needs. Everything it needs is already owned:
|
|
23
24
|
// `config.types[<type>].statuses` for the vocabulary (type-aware, so a `doc`
|
|
24
|
-
// rowed in a plan hub is judged by the doc vocabulary),
|
|
25
|
-
//
|
|
25
|
+
// rowed in a plan hub is judged by the doc vocabulary), the strict Markdown
|
|
26
|
+
// body-link resolver, the index for the child's real status, and
|
|
26
27
|
// archive-dir-outranks-frontmatter for archived children.
|
|
27
28
|
//
|
|
28
29
|
// The three-way split below is the part that is easy to get wrong. A row with
|
|
@@ -79,7 +80,7 @@ function truncate(text, max = 60) {
|
|
|
79
80
|
// for that hub by name, so the noise-control rule doesn't apply).
|
|
80
81
|
export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
81
82
|
const docByPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
82
|
-
//
|
|
83
|
+
// The filesystem resolver is case-fold-aware, so on a case-folding filesystem a row
|
|
83
84
|
// linking `BILLING-A.md` resolves to the file that is indexed as
|
|
84
85
|
// `billing-a.md` — a path string the exact map can't answer. Fold as a
|
|
85
86
|
// fallback, and only when the fold is unambiguous: on a case-SENSITIVE
|
|
@@ -91,7 +92,8 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
|
91
92
|
}
|
|
92
93
|
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
93
94
|
// type stays loud for another that declared the same name.
|
|
94
|
-
const quiet = (d) => config.lifecycle?.
|
|
95
|
+
const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
|
|
96
|
+
?? config.lifecycle?.terminalStatuses?.has(d.status))
|
|
95
97
|
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
96
98
|
const out = [];
|
|
97
99
|
|
|
@@ -108,11 +110,11 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
|
108
110
|
const rows = [];
|
|
109
111
|
for (const row of scanHubStatusRows(body)) {
|
|
110
112
|
const href = row.ref.replace(/#.*$/, '');
|
|
111
|
-
const
|
|
113
|
+
const resolution = resolveBodyLinkTarget(href, hubDir, config.repoRoot);
|
|
112
114
|
// A row whose link is broken is already a body-link finding. Reporting it
|
|
113
115
|
// again as unreadable status would double-report one problem.
|
|
114
|
-
if (!
|
|
115
|
-
const repoPath = toRepoPath(
|
|
116
|
+
if (!resolution.ok) continue;
|
|
117
|
+
const repoPath = toRepoPath(resolution.path, config.repoRoot);
|
|
116
118
|
const target = docByPath.get(repoPath) ?? docByFoldedPath.get(repoPath.toLowerCase()) ?? null;
|
|
117
119
|
if (!target || target.path === hub.path) continue;
|
|
118
120
|
const actual = effectiveStatus(target, config);
|
package/src/validate.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
|
|
|
3
3
|
import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
|
|
4
4
|
import { toRepoPath } from './util.mjs';
|
|
5
5
|
import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
|
|
6
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
6
7
|
|
|
7
8
|
const NOW = new Date();
|
|
8
9
|
|
|
@@ -262,29 +263,39 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
262
263
|
const skipRefValidation = config.lifecycle.isTerminal?.(doc.status, doc.type)
|
|
263
264
|
?? config.lifecycle.terminalStatuses.has(doc.status);
|
|
264
265
|
if (!skipRefValidation) {
|
|
265
|
-
const compatibilityWarning = config.lifecycle.skipsWarnings(doc.status, doc.type);
|
|
266
266
|
for (const field of allRefFields) {
|
|
267
267
|
for (const relPath of (doc.refFields[field] || [])) {
|
|
268
268
|
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
269
|
-
|
|
269
|
+
doc.errors.push({
|
|
270
270
|
path: doc.path,
|
|
271
|
-
level:
|
|
271
|
+
level: 'error',
|
|
272
272
|
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
273
|
-
meta: { kind:
|
|
274
|
-
};
|
|
275
|
-
(compatibilityWarning ? doc.warnings : doc.errors).push(issue);
|
|
273
|
+
meta: { kind: 'ref-resolution', field, relPath },
|
|
274
|
+
});
|
|
276
275
|
}
|
|
277
276
|
}
|
|
278
277
|
}
|
|
279
278
|
|
|
280
279
|
// Validate body links resolve to existing files
|
|
281
280
|
for (const link of (doc.bodyLinks || [])) {
|
|
282
|
-
|
|
281
|
+
const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
|
|
282
|
+
if (!resolution.ok) {
|
|
283
|
+
const shownHref = link.rawHref ?? link.href;
|
|
283
284
|
doc.warnings.push({
|
|
284
285
|
path: doc.path,
|
|
285
286
|
level: 'warning',
|
|
286
|
-
message:
|
|
287
|
-
|
|
287
|
+
message: resolution.reason === 'outside-repo'
|
|
288
|
+
? `body link \`${shownHref}\` escapes the repository.`
|
|
289
|
+
: `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
|
|
290
|
+
meta: {
|
|
291
|
+
kind: 'body-link-resolution',
|
|
292
|
+
field: 'body-link',
|
|
293
|
+
relPath: link.href,
|
|
294
|
+
rawHref: shownHref,
|
|
295
|
+
targetKind: link.targetKind ?? 'document',
|
|
296
|
+
angle: link.angle === true,
|
|
297
|
+
reason: resolution.reason,
|
|
298
|
+
},
|
|
288
299
|
});
|
|
289
300
|
}
|
|
290
301
|
}
|
|
@@ -328,7 +339,9 @@ function candidatePathsForType(docs, type) {
|
|
|
328
339
|
// name implies one (e.g. `related_plans` → plans only).
|
|
329
340
|
export function enrichRefErrorSuggestions(docs, config) {
|
|
330
341
|
const enrich = (entry) => {
|
|
331
|
-
if (!entry?.meta || !['ref-resolution', '
|
|
342
|
+
if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
|
|
343
|
+
if (entry.meta.kind === 'body-link-resolution'
|
|
344
|
+
&& (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
|
|
332
345
|
if (entry._suggested) return;
|
|
333
346
|
const inferred = inferRefFieldType(entry.meta.field);
|
|
334
347
|
const candidates = candidatePathsForType(docs, inferred);
|