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 +19 -1
- package/dotmd.config.example.mjs +8 -0
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/config.mjs +15 -0
- package/src/query.mjs +22 -3
- package/src/reference-planner.mjs +118 -16
- package/src/render.mjs +2 -1
- package/src/validate.mjs +31 -0
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
|
-
|
|
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');
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -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
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
|
|
400
|
-
const at =
|
|
399
|
+
const raw = lines[i].trim();
|
|
400
|
+
const at = raw.toLowerCase().indexOf(needle);
|
|
401
401
|
if (at === -1) continue;
|
|
402
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
+
}
|