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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.76.4",
3
+ "version": "0.76.6",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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
- // Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
57
- const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
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 href = match[2];
61
- // Skip external URLs
62
- if (/^https?:\/\//i.test(href)) continue;
63
- // Strip anchor fragment for path resolution
64
- const cleanHref = href.replace(/#.*$/, '');
65
- links.push({ text: match[1], href: cleanHref });
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.message.startsWith('body link') && w.message.includes('does not resolve')
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 match = warn.message.match(/body link `([^`]+)` does not resolve/);
118
- if (!match) continue;
119
-
120
- const brokenHref = match[1];
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
- '(?<!!)\\[([^\\]]+)\\]\\(' + escapeRegex(brokenHref) + '(#[^)]*)?\\)',
140
+ '(\\]\\(\\s*)' + (warn.meta.angle ? '<' : '') + escapeRegex(rawHref) + (warn.meta.angle ? '>' : '') + '(?=\\s|\\))',
132
141
  'g'
133
142
  );
134
- newBody = newBody.replace(linkRegex, (_, text, anchor) =>
135
- `[${text}](${correctHref}${anchor ?? ''})`
136
- );
137
- bodyFixes.push({ brokenHref, correctHref });
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) {
@@ -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?.terminalStatuses?.has(d.status)
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 = resolve(link.href, dir);
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 => /\.md(?:#|$)/.test(l.href ?? ''));
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 (/entry `.*` does not resolve|body link `.*` does not resolve/.test(message)) {
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
 
@@ -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, resolveRefPath, toRepoPath } from './util.mjs';
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), `resolveRefPath` for
25
- // the link (case-fold-aware), the index for the child's real status, and
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
- // `resolveRefPath` is case-fold-aware, so on a case-folding filesystem a row
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?.terminalStatuses?.has(d.status)
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 abs = resolveRefPath(href, hubDir, config.repoRoot);
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 (!abs) continue;
115
- const repoPath = toRepoPath(abs, config.repoRoot);
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
- const issue = {
269
+ doc.errors.push({
270
270
  path: doc.path,
271
- level: compatibilityWarning ? 'warning' : 'error',
271
+ level: 'error',
272
272
  message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
273
- meta: { kind: compatibilityWarning ? 'ref-resolution-compat' : 'ref-resolution', field, relPath },
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
- if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
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: `body link \`${link.href}\` does not resolve to an existing file.`,
287
- meta: { kind: 'ref-resolution', field: 'body-link', relPath: link.href },
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', 'ref-resolution-compat'].includes(entry.meta.kind)) return;
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);