dotmd-cli 0.71.3 → 0.72.0

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/bin/dotmd.mjs CHANGED
@@ -526,6 +526,10 @@ Options:
526
526
  --errors-only Show only errors, suppress warnings entirely
527
527
  --fix Auto-fix broken refs, lint issues, and regenerate index
528
528
  --json Output errors and warnings as JSON (always full detail)
529
+ --min-docs <n> Fail if fewer than <n> docs were scanned — a floor that
530
+ tells "nothing is wrong" apart from "nothing was looked
531
+ at". Overrides \`minDocs\` in config for this run;
532
+ skipped when checking specific paths.
529
533
  --dry-run, -n Preview fixes without writing (with --fix)`,
530
534
 
531
535
  archive: `dotmd archive <file-or-slug> — archive a document
@@ -1760,7 +1764,19 @@ async function main() {
1760
1764
  const errorsOnly = args.includes('--errors-only');
1761
1765
  const noCollapse = args.includes('--no-collapse');
1762
1766
  const verbose = args.includes('--verbose');
1763
- const checkTargets = restArgs.filter(arg => !arg.startsWith('-'));
1767
+ // `--min-docs N` overrides the configured floor for this run (CI passes it
1768
+ // without editing config). Its value is not a path target.
1769
+ const minDocsFlagIdx = restArgs.indexOf('--min-docs');
1770
+ const minDocsRaw = minDocsFlagIdx === -1 ? null : restArgs[minDocsFlagIdx + 1];
1771
+ if (minDocsFlagIdx !== -1 && !/^\d+$/.test(minDocsRaw ?? '')) {
1772
+ die('`--min-docs` needs a positive integer, e.g. `dotmd check --min-docs 500`.');
1773
+ }
1774
+ const minDocsOverride = minDocsRaw == null ? null : Number(minDocsRaw);
1775
+ const minDocsValueIdx = minDocsFlagIdx === -1 ? -1 : minDocsFlagIdx + 1;
1776
+ const checkTargets = restArgs.filter((arg, i) => !arg.startsWith('-') && i !== minDocsValueIdx);
1777
+ const { applyScanFloor } = await import('../src/validate.mjs');
1778
+ const scanFloorConfig = minDocsOverride == null ? config : { ...config, minDocs: minDocsOverride };
1779
+ const applyFloor = (idx) => applyScanFloor(idx, scanFloorConfig, { scoped: checkTargets.length > 0 });
1764
1780
  const skippedCheckHooks = config._execution?.suppressSideEffects
1765
1781
  ? ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
1766
1782
  .filter(name => typeof config.hooks?.[name] === 'function')
@@ -1810,6 +1826,7 @@ async function main() {
1810
1826
  const freshIndex = buildIndex(config);
1811
1827
  applyIndexFilters(freshIndex);
1812
1828
  applyPathScopeToIndex(freshIndex, config, checkTargets);
1829
+ applyFloor(freshIndex);
1813
1830
  if (args.includes('--json')) {
1814
1831
  process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
1815
1832
  } else {
@@ -1821,6 +1838,7 @@ async function main() {
1821
1838
  }
1822
1839
 
1823
1840
  applyPathScopeToIndex(index, config, checkTargets);
1841
+ applyFloor(index);
1824
1842
 
1825
1843
  if (args.includes('--json')) {
1826
1844
  process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
@@ -13,6 +13,14 @@ export const archiveDir = 'archived';
13
13
  // Directories to skip when scanning
14
14
  export const excludeDirs = ['evidence'];
15
15
 
16
+ // Floor under the scan surface. `dotmd check` fails when it scans fewer docs than
17
+ // this, so a broken root or an over-eager exclude can't read as a clean estate —
18
+ // zero errors and zero docs look identical otherwise. Off when unset. Set it well
19
+ // below your real count (round down hard); raise it as the corpus grows.
20
+ // Override for one run with `dotmd check --min-docs <n>`; skipped for path-scoped
21
+ // checks, which are deliberate subsets.
22
+ // export const minDocs = 500;
23
+
16
24
  // Document types — each type has its own status vocabulary and context layout.
17
25
  // Defaults: plan, doc, prompt. Override to customize statuses per type, or add new types.
18
26
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.71.3",
3
+ "version": "0.72.0",
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",
package/src/commands.mjs CHANGED
@@ -142,7 +142,7 @@ const definitions = [
142
142
  form('migrate <type>', { subcommands: ['migrate'], args: positionals(1, 1), options: [flag('--yes', '-y'), flag('--json'), flag('--ignore-lifecycle-override')] }),
143
143
  form('', { options: [value('--type'), flag('--json')] }),
144
144
  ]),
145
- command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose')] })]),
145
+ command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose'), value('--min-docs')] })]),
146
146
  command('index', mutates('repo-generated index destination; --print is read-only'), 'mutate', [form('', { options: [flag('--print')] })]),
147
147
 
148
148
  command('self-check', none, 'internal', [form('', { options: [flag('--json')] })], { visibility: 'internal' }),
package/src/config.mjs CHANGED
@@ -20,6 +20,8 @@ const DEFAULTS = {
20
20
  root: '.',
21
21
  archiveDir: 'archived',
22
22
  excludeDirs: [],
23
+ // Floor under the scan surface; null = off. See `applyScanFloor` in validate.mjs.
24
+ minDocs: null,
23
25
 
24
26
  types: {
25
27
  plan: {
@@ -517,6 +519,18 @@ export async function resolveConfig(cwd, explicitConfigPath) {
517
519
  }
518
520
  }
519
521
 
522
+ // A floor under the scan surface. Unset (the default) is off, so no existing
523
+ // repo changes behavior. See `applyScanFloor` in src/validate.mjs for why a
524
+ // silent zero-doc pass is the failure mode worth spending a config key on.
525
+ let minDocs = null;
526
+ if (config.minDocs !== undefined && config.minDocs !== null) {
527
+ if (!Number.isInteger(config.minDocs) || config.minDocs < 1) {
528
+ earlyWarnings.push(`Config: minDocs must be a positive integer; ignoring \`${config.minDocs}\`.`);
529
+ } else {
530
+ minDocs = config.minDocs;
531
+ }
532
+ }
533
+
520
534
  const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
521
535
 
522
536
  return {
@@ -531,6 +545,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
531
545
  archiveDir: config.archiveDir,
532
546
  excludeDirs: new Set(config.excludeDirs),
533
547
  docsRootPrefix,
548
+ minDocs,
534
549
 
535
550
  statusOrder,
536
551
  validStatuses,
package/src/query.mjs CHANGED
@@ -396,14 +396,33 @@ function scanBodyForKeyword(doc, needle, config) {
396
396
  const lines = body.split('\n');
397
397
  const matches = [];
398
398
  for (let i = 0; i < lines.length && matches.length < MAX_BODY_MATCHES; i++) {
399
- const text = lines[i].trim();
400
- const at = text.toLowerCase().indexOf(needle);
399
+ const raw = lines[i].trim();
400
+ const at = raw.toLowerCase().indexOf(needle);
401
401
  if (at === -1) continue;
402
- matches.push({ line: bodyLineOffset + i + 1, text: excerptAround(text, at, needle.length) });
402
+ const { text, at: shown } = displayableExcerptLine(raw, at, needle);
403
+ matches.push({ line: bodyLineOffset + i + 1, text: excerptAround(text, shown, needle.length) });
403
404
  }
404
405
  return matches;
405
406
  }
406
407
 
408
+ // HTML comments are invisible when the markdown renders, so they are noise in an
409
+ // excerpt — and dotmd's own conventions put them inline (a managed status token in
410
+ // a hub table row reads as `| [x](x.md) | <!--s-->active<!--/s--> — next |`), which
411
+ // is paid on every agent read of every search result.
412
+ //
413
+ // Matching still happens against the raw line, so a needle that only occurs INSIDE
414
+ // a comment is never lost: if stripping would hide the match, the raw line is shown
415
+ // instead. Whitespace is squeezed only on lines a strip actually touched.
416
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
417
+
418
+ export function displayableExcerptLine(raw, at, needle) {
419
+ if (!raw.includes('<!--')) return { text: raw, at };
420
+ const stripped = raw.replace(HTML_COMMENT_RE, '').replace(/[ \t]{2,}/g, ' ').trim();
421
+ const shown = stripped.toLowerCase().indexOf(needle);
422
+ if (shown === -1) return { text: raw, at };
423
+ return { text: stripped, at: shown };
424
+ }
425
+
407
426
  // Window a long line around the match so the needle is always visible.
408
427
  function excerptAround(text, at, needleLen) {
409
428
  if (text.length <= EXCERPT_WIDTH) return text;
@@ -1,10 +1,21 @@
1
- import { realpathSync } from 'node:fs';
1
+ import { realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter } from './frontmatter.mjs';
4
4
 
5
5
  function slash(value) { return value.split(path.sep).join('/'); }
6
6
 
7
- function canonicalExisting(filePath) {
7
+ function canonicalExisting(filePath, memo = null) {
8
+ const key = memo ? path.resolve(filePath) : null;
9
+ if (memo) {
10
+ const hit = memo.get(key);
11
+ if (hit !== undefined) return hit;
12
+ }
13
+ const identity = resolveCanonical(filePath);
14
+ if (memo) memo.set(key, identity);
15
+ return identity;
16
+ }
17
+
18
+ function resolveCanonical(filePath) {
8
19
  try { return realpathSync(filePath); }
9
20
  catch {
10
21
  const suffix = [];
@@ -36,31 +47,118 @@ export function configuredReferenceFields(config) {
36
47
  ])];
37
48
  }
38
49
 
50
+ // Marks a case fold shared by two corpus documents. On a case-sensitive
51
+ // filesystem those are genuinely two files, and nothing may collapse them.
52
+ const AMBIGUOUS_FOLD = Symbol('ambiguous case fold');
53
+
54
+ // The set carries four indexes beside the identities themselves:
55
+ // canonical — memoized path resolution. Resolving one reference costs two
56
+ // `canonicalExisting` calls, and the repository-relative spelling usually
57
+ // does NOT exist, which sends it up the tree doing a realpath per ancestor.
58
+ // Over a corpus-wide sweep that is tens of thousands of syscalls across a
59
+ // few hundred distinct paths, and it dominated the reference rewrite. The
60
+ // memo lives here so it is scoped to one sweep and cannot outlive a
61
+ // mutation — `dotmd bulk` builds a fresh set per move. Within a sweep it is
62
+ // also more consistent than re-resolving per token, which could observe the
63
+ // filesystem changing midway.
64
+ // paths — identity to the spelling the corpus used.
65
+ // names — identity to every basename it answers to (symlink aliases).
66
+ // folded — case fold to identity, for filesystems that fold case.
39
67
  export function createReferenceIdentitySet(filePaths) {
40
68
  const identities = new Set();
41
69
  identities.paths = new Map();
42
- for (const filePath of filePaths) {
43
- const identity = canonicalExisting(filePath);
44
- identities.add(identity);
45
- identities.paths.set(identity, path.resolve(filePath));
46
- }
70
+ identities.canonical = new Map();
71
+ identities.names = new Map();
72
+ identities.folded = new Map();
73
+ identities.symlinked = false;
74
+ for (const filePath of filePaths) registerIdentity(identities, filePath);
47
75
  return identities;
48
76
  }
49
77
 
78
+ function registerIdentity(identities, filePath) {
79
+ const resolved = path.resolve(filePath);
80
+ const identity = canonicalExisting(filePath, identities.canonical);
81
+ // A corpus path whose realpath differs is a symlink, so one document can be
82
+ // reachable under more than one name. `names` records every one of them —
83
+ // and `symlinked` warns the candidate prefilter that names are not a
84
+ // reliable signal in this repo at all.
85
+ if (identity !== resolved) identities.symlinked = true;
86
+ identities.add(identity);
87
+ identities.paths.set(identity, resolved);
88
+ let names = identities.names.get(identity);
89
+ if (!names) identities.names.set(identity, names = new Set());
90
+ names.add(path.basename(resolved).toLowerCase());
91
+ const key = identity.toLowerCase();
92
+ const seen = identities.folded.get(key);
93
+ identities.folded.set(key, seen === undefined || seen === identity ? identity : AMBIGUOUS_FOLD);
94
+ return identity;
95
+ }
96
+
97
+ // Can this document possibly hold a reference to `oldIdentity`? Every token the
98
+ // rewriter will touch has to survive `/\.md$/i` and then resolve to the moved
99
+ // file, so the document must spell one of that file's names somewhere. Checking
100
+ // that first skips the fence-aware walk for the ~99% of a corpus that never
101
+ // mentions the file — the measured difference on a 2,000-doc repo is 950ms of
102
+ // rewriting versus 120ms.
103
+ //
104
+ // The comparison mirrors the resolver: case-folded (a case-insensitive
105
+ // filesystem resolves `FOO.MD` to `foo.md`, and folding can only over-include),
106
+ // and with the same backslash escapes unwound, so `my\ plan.md` still matches
107
+ // `my plan.md`. Percent-encoding needs no handling — the resolver does not
108
+ // decode it either, so `foo%20bar.md` never resolves in the first place.
109
+ //
110
+ // The boundary: a symlink whose name differs from its target's. Aliases inside
111
+ // the corpus are covered by `names`; a symlink that is NOT itself a collected
112
+ // doc is not, so any repo that symlinks docs at all fails open to the full walk.
113
+ function mayReferenceIdentity(content, oldIdentity, oldPath, identities) {
114
+ if (!identities?.names || identities.symlinked) return true;
115
+ const names = identities.names.get(oldIdentity);
116
+ const haystack = (content.includes('\\') ? content.replace(/\\([\s()[\]<>])/g, '$1') : content).toLowerCase();
117
+ if (haystack.includes(path.basename(oldPath).toLowerCase())) return true;
118
+ if (names) for (const name of names) if (haystack.includes(name)) return true;
119
+ return false;
120
+ }
121
+
50
122
  // Both interpretations are evaluated. A local document wins only when the
51
123
  // repo-relative spelling is absent or names the same identity; disagreement is
52
124
  // rejected rather than guessed.
53
125
  export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
54
126
  if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
55
127
  const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
56
- const local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
57
- const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
58
- const localExists = identities.has(local);
59
- const repositoryExists = identities.has(repository);
60
- if (localExists && repositoryExists && local !== repository) {
128
+ const memo = identities?.canonical ?? null;
129
+ const local = matchIdentity(canonicalExisting(path.resolve(path.dirname(documentPath), clean), memo), identities);
130
+ const repository = matchIdentity(canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')), memo), identities);
131
+ if (local && repository && local !== repository) {
61
132
  throw new AmbiguousReferenceError(token, documentPath, local, repository);
62
133
  }
63
- return localExists ? local : (repositoryExists ? repository : null);
134
+ return local ?? repository ?? null;
135
+ }
136
+
137
+ // `realpath` resolves symlinks but NOT case: on a case-insensitive filesystem
138
+ // `realpath("CASING.MD")` hands back the caller's spelling, so an exact compare
139
+ // misses a link that names a real document. Validation disagreed — it resolves
140
+ // with `existsSync`, which does not care about case — so `dotmd check` called
141
+ // such a link fine, a move silently left it pointing at the old path, and only
142
+ // THEN did check call it broken.
143
+ //
144
+ // The tie-break is the inode, not a guess about the filesystem: same device and
145
+ // inode means the two spellings are one file, which is only ever true where the
146
+ // filesystem itself folds case. On a case-sensitive filesystem `Foo.md` and
147
+ // `foo.md` are separate inodes and stay separate here, and two corpus documents
148
+ // that differ only by case poison their shared fold so neither is guessed at.
149
+ function matchIdentity(candidate, identities) {
150
+ if (identities.has(candidate)) return candidate;
151
+ const folded = identities.folded?.get(candidate.toLowerCase());
152
+ if (folded === undefined || folded === AMBIGUOUS_FOLD) return null;
153
+ return sameFileOnDisk(candidate, folded) ? folded : null;
154
+ }
155
+
156
+ function sameFileOnDisk(left, right) {
157
+ try {
158
+ const a = statSync(left);
159
+ const b = statSync(right);
160
+ return a.dev === b.dev && a.ino === b.ino && a.ino !== 0;
161
+ } catch { return false; }
64
162
  }
65
163
 
66
164
  function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
@@ -264,7 +362,10 @@ export function rewriteDocumentReferences(content, {
264
362
  }) {
265
363
  const { frontmatter, body } = extractFrontmatter(content);
266
364
  if (!frontmatter) return content;
267
- const oldIdentity = canonicalExisting(oldPath);
365
+ const oldIdentity = canonicalExisting(oldPath, identities?.canonical ?? null);
366
+ // `rebaseAll` rewrites every reference the document holds because the
367
+ // document itself moved, so no single name can gate it.
368
+ if (!rebaseAll && !mayReferenceIdentity(content, oldIdentity, oldPath, identities)) return content;
268
369
  const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
269
370
  const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
270
371
  const nextBody = rewriteMarkdown(body, args);
@@ -273,8 +374,9 @@ export function rewriteDocumentReferences(content, {
273
374
 
274
375
  export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
275
376
  const identities = createReferenceIdentitySet(documents.map(document => document.path));
276
- const oldIdentity = canonicalExisting(oldPath);
277
- identities.add(oldIdentity);
377
+ // Register rather than bare-add: the case fold and name index have to know
378
+ // about the moved document too, or a differently-cased link to it misses.
379
+ const oldIdentity = registerIdentity(identities, oldPath);
278
380
  const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
279
381
  if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
280
382
  const movedContent = rewriteDocumentReferences(source.content, {
package/src/render.mjs CHANGED
@@ -501,7 +501,8 @@ function _renderCheck(index, opts = {}) {
501
501
  if (index.errors.length > 0) {
502
502
  lines.push(red('Errors'));
503
503
  for (const issue of index.errors) {
504
- lines.push(`- ${issue.path}: ${issue.message}`);
504
+ // Repo-level findings (e.g. the scan floor) carry no path.
505
+ lines.push(issue.path ? `- ${issue.path}: ${issue.message}` : `- ${issue.message}`);
505
506
  }
506
507
  lines.push('');
507
508
  const actions = renderManualFixes({ errors: index.errors, warnings: [] }).trimEnd();
package/src/validate.mjs CHANGED
@@ -703,3 +703,34 @@ export function computeChecklistCompletionRate(checklist) {
703
703
  if (!checklist.total) return null;
704
704
  return Number((checklist.completed / checklist.total).toFixed(4));
705
705
  }
706
+
707
+ // A floor under the scan surface.
708
+ //
709
+ // Every other check in this file asks "is this doc wrong?" — none of them can ask
710
+ // "did we look at anything?". If the scan surface breaks (a root that stopped
711
+ // resolving, a config edit that narrowed the tree, a rename that moved docs/ out
712
+ // from under us), `dotmd check` reports zero errors, and that output is
713
+ // byte-identical to a clean estate. A guard's whole failure mode is going quiet,
714
+ // and this is the one that would go quiet silently.
715
+ //
716
+ // Off unless `minDocs` is configured — a floor is a claim about YOUR corpus size
717
+ // and dotmd cannot guess it. Deliberately an error, not a warning: a warning exits
718
+ // 0, which is the exact outcome this exists to prevent.
719
+ export function applyScanFloor(index, config, { scoped = false } = {}) {
720
+ const floor = config?.minDocs;
721
+ if (!floor) return index;
722
+ // A path-scoped check (`dotmd check docs/plans/x.md`) is a deliberate subset, so
723
+ // the floor would fire on every single-file check. Not a corpus claim at all.
724
+ if (scoped) return index;
725
+ if (index.docs.length >= floor) return index;
726
+
727
+ index.errors.push({
728
+ path: null,
729
+ level: 'error',
730
+ message: `only ${index.docs.length} docs in the scan surface (expected at least ${floor}) — `
731
+ + 'the file list broke, so a pass here would mean nothing. Check `root`/`excludeDirs` in '
732
+ + 'your config, or lower `minDocs` if the corpus genuinely shrank.',
733
+ meta: { kind: 'scan-floor', found: index.docs.length, floor },
734
+ });
735
+ return index;
736
+ }