dotmd-cli 0.71.4 → 0.72.1
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/init.mjs +112 -7
- package/src/prompts.mjs +12 -4
- package/src/query.mjs +22 -3
- 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/init.mjs
CHANGED
|
@@ -67,6 +67,55 @@ export const referenceFields = {
|
|
|
67
67
|
};
|
|
68
68
|
`;
|
|
69
69
|
|
|
70
|
+
// A git repository cannot hold an empty directory, so scaffolding `docs/plans/`
|
|
71
|
+
// and `docs/prompts/` and stopping there means both vanish for the next clone —
|
|
72
|
+
// `dotmd init` produced three committable files and two directories that existed
|
|
73
|
+
// only on the machine that ran it.
|
|
74
|
+
//
|
|
75
|
+
// plans/ gets a real sample rather than a keepfile: it is the one place a new user
|
|
76
|
+
// benefits from seeing the frontmatter shape before running `dotmd new`. It is
|
|
77
|
+
// written `status: planned` so it sits quietly in the pipeline instead of posing
|
|
78
|
+
// as live work, and it says how to delete itself.
|
|
79
|
+
//
|
|
80
|
+
// prompts/ gets a keepfile instead, and cannot get a sample: the live queue is
|
|
81
|
+
// gitignored by the rule this same command writes, and a committed prompt at
|
|
82
|
+
// `status: pending` would be silently consumed by the next no-arg `dotmd use`.
|
|
83
|
+
const SAMPLE_PLAN_NAME = 'example-plan.md';
|
|
84
|
+
const samplePlan = (today) => `---
|
|
85
|
+
type: plan
|
|
86
|
+
status: planned
|
|
87
|
+
created: ${today}
|
|
88
|
+
updated: ${today}
|
|
89
|
+
title: Example Plan
|
|
90
|
+
summary: A scaffolded sample showing what a dotmd plan looks like — safe to delete.
|
|
91
|
+
current_state: "Scaffolded by \`dotmd init\` as a shape reference. Nothing here is real work."
|
|
92
|
+
next_step: "Delete this file, or edit it into your first real plan."
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
# Example Plan
|
|
96
|
+
|
|
97
|
+
> A scaffolded sample showing what a dotmd plan looks like — safe to delete.
|
|
98
|
+
|
|
99
|
+
## Problem
|
|
100
|
+
|
|
101
|
+
\`dotmd init\` leaves this file behind so \`docs/plans/\` survives a clone (git cannot
|
|
102
|
+
track an empty directory) and so the frontmatter above has something to point at.
|
|
103
|
+
|
|
104
|
+
The fields that matter: \`status\` drives every listing, \`current_state\` and
|
|
105
|
+
\`next_step\` are what \`dotmd briefing\` reads out, and \`updated\` drives staleness.
|
|
106
|
+
Never hand-edit \`status:\` — \`dotmd set <status> <file>\` writes it, validates it
|
|
107
|
+
against the type, and runs the lifecycle hooks.
|
|
108
|
+
|
|
109
|
+
## Phases
|
|
110
|
+
|
|
111
|
+
- [ ] Delete this file: \`dotmd archive docs/plans/${SAMPLE_PLAN_NAME}\`
|
|
112
|
+
- [ ] Write a real one: \`dotmd new plan <name>\`
|
|
113
|
+
|
|
114
|
+
## Version History
|
|
115
|
+
|
|
116
|
+
- Scaffolded by \`dotmd init\`.
|
|
117
|
+
`;
|
|
118
|
+
|
|
70
119
|
const STARTER_INDEX = `# Docs
|
|
71
120
|
|
|
72
121
|
<!-- GENERATED:dotmd:start -->
|
|
@@ -290,6 +339,21 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
290
339
|
if (!dryRun) mkdirSync(subPath, { recursive: true });
|
|
291
340
|
process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
|
|
292
341
|
}
|
|
342
|
+
|
|
343
|
+
// Give the directory something git can carry — but only when it is genuinely
|
|
344
|
+
// empty. Re-running init over a populated tree must never drop a sample plan
|
|
345
|
+
// into someone's real estate.
|
|
346
|
+
const keeper = sub === 'plans'
|
|
347
|
+
? { file: SAMPLE_PLAN_NAME, body: samplePlan(new Date().toISOString().slice(0, 10)) }
|
|
348
|
+
: { file: '.gitkeep', body: '' };
|
|
349
|
+
const keeperPath = path.join(subPath, keeper.file);
|
|
350
|
+
const dirIsEmpty = !existsSync(subPath) || readdirSync(subPath).length === 0;
|
|
351
|
+
if (existsSync(keeperPath)) {
|
|
352
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${keeper.file}\n`);
|
|
353
|
+
} else if (dirIsEmpty || dryRun) {
|
|
354
|
+
if (!dryRun) writeFileSync(keeperPath, keeper.body, 'utf8');
|
|
355
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/${keeper.file}\n`);
|
|
356
|
+
}
|
|
293
357
|
}
|
|
294
358
|
|
|
295
359
|
if (existsSync(indexPath)) {
|
|
@@ -313,21 +377,38 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
313
377
|
process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
|
|
314
378
|
}
|
|
315
379
|
|
|
316
|
-
// .gitignore:
|
|
380
|
+
// .gitignore: two rules.
|
|
381
|
+
//
|
|
382
|
+
// .dotmd/ — session ownership records
|
|
383
|
+
// <docs>/prompts/*.md — the LIVE saved-prompt queue
|
|
384
|
+
//
|
|
385
|
+
// The second one is load-bearing. Saved prompts are session-local by design and
|
|
386
|
+
// the workflow docs say never to commit them, but nothing enforced that: a plain
|
|
387
|
+
// `git add -A` from any session swept the queue into the repo. Scoped with a
|
|
388
|
+
// single `*` so it stops at the directory — `prompts/archived/` is the committed
|
|
389
|
+
// historical record and must stay tracked. Anchored with a leading `/` so a
|
|
390
|
+
// `prompts/` directory elsewhere in the tree is unaffected.
|
|
317
391
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
318
|
-
const
|
|
392
|
+
const docsRel = path.relative(cwd, docsDir).split(path.sep).join('/');
|
|
393
|
+
const promptsIgnore = `/${docsRel ? `${docsRel}/` : ''}prompts/*.md`;
|
|
394
|
+
const ignoreRules = [
|
|
395
|
+
{ line: '.dotmd/', accepts: (l) => l === '.dotmd/' || l === '.dotmd' },
|
|
396
|
+
{ line: promptsIgnore, accepts: (l) => l === promptsIgnore || l === promptsIgnore.slice(1) },
|
|
397
|
+
];
|
|
319
398
|
if (existsSync(gitignorePath)) {
|
|
320
399
|
const current = readFileSync(gitignorePath, 'utf8');
|
|
321
|
-
const
|
|
322
|
-
|
|
400
|
+
const present = new Set(current.split('\n').map(l => l.trim()));
|
|
401
|
+
const missing = ignoreRules.filter(r => ![...present].some(l => r.accepts(l)));
|
|
402
|
+
if (missing.length) {
|
|
323
403
|
const sep = current.endsWith('\n') ? '' : '\n';
|
|
324
|
-
|
|
325
|
-
|
|
404
|
+
const added = missing.map(r => `${r.line}\n`).join('');
|
|
405
|
+
if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${added}`, 'utf8');
|
|
406
|
+
process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${missing.map(r => r.line).join(', ')})\n`);
|
|
326
407
|
} else {
|
|
327
408
|
process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
|
|
328
409
|
}
|
|
329
410
|
} else {
|
|
330
|
-
if (!dryRun) writeFileSync(gitignorePath, `${
|
|
411
|
+
if (!dryRun) writeFileSync(gitignorePath, ignoreRules.map(r => `${r.line}\n`).join(''), 'utf8');
|
|
331
412
|
process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
|
|
332
413
|
}
|
|
333
414
|
|
|
@@ -397,6 +478,30 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
397
478
|
}
|
|
398
479
|
}
|
|
399
480
|
|
|
481
|
+
// Render the index against what we just scaffolded.
|
|
482
|
+
//
|
|
483
|
+
// `docs.md` ships a "no docs yet" placeholder, which stopped being true the
|
|
484
|
+
// moment this command also started writing a sample plan — so a fresh init
|
|
485
|
+
// committed an index that contradicted the tree beside it, and the user's first
|
|
486
|
+
// `dotmd check` opened with a stale-index warning. The config passed into
|
|
487
|
+
// runInit predates the file we just wrote, so re-resolve before rendering.
|
|
488
|
+
//
|
|
489
|
+
// Best-effort: a repo that scaffolds correctly but cannot render its index is
|
|
490
|
+
// still a successful init, and the next command regenerates it anyway.
|
|
491
|
+
if (!dryRun) {
|
|
492
|
+
try {
|
|
493
|
+
const { resolveConfig } = await import('./config.mjs');
|
|
494
|
+
const freshConfig = await resolveConfig(cwd);
|
|
495
|
+
if (freshConfig.indexPath) {
|
|
496
|
+
const { buildIndex } = await import('./index.mjs');
|
|
497
|
+
const { writeRenderedIndex } = await import('./index-file.mjs');
|
|
498
|
+
writeRenderedIndex(() => buildIndex(freshConfig, { fast: true }), freshConfig);
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
// Leave the placeholder; `dotmd check` self-heals it on first run.
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
400
505
|
process.stdout.write(`\nReady. A few starting points:\n`);
|
|
401
506
|
process.stdout.write(` dotmd new doc my-doc # scaffold a reference doc\n`);
|
|
402
507
|
process.stdout.write(` dotmd new plan my-plan # scaffold an execution plan\n`);
|
package/src/prompts.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, resolveDocPath, resolveRefPath, isArchivedPath } from './util.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { runQuery } from './query.mjs';
|
|
7
7
|
import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
|
|
@@ -258,7 +258,7 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
258
258
|
|
|
259
259
|
const planRef = asString(parsed.plan);
|
|
260
260
|
let linkedClaim = null;
|
|
261
|
-
if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config);
|
|
261
|
+
if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
|
|
262
262
|
|
|
263
263
|
if (dryRun) {
|
|
264
264
|
const prefix = dim('[dry-run]');
|
|
@@ -353,8 +353,16 @@ export async function writeConsumedBody(body, archivedPath, write = null, linked
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
// `planRef` is a reference written in the prompt's own frontmatter, so it obeys
|
|
357
|
+
// the same convention every other ref field does: doc-relative first (baton's
|
|
358
|
+
// `../plans/x.md` from `docs/prompts/`), repo-root-relative second. Resolving it
|
|
359
|
+
// with `resolveDocPath` alone read only the repo-root form, so a doc-relative
|
|
360
|
+
// link — the form nothing validates, since `plan` is not a `referenceFields`
|
|
361
|
+
// entry — died as "missing" while pointing at a file that was plainly there.
|
|
362
|
+
function prepareLinkedPromptClaim(planRef, config, promptDir) {
|
|
363
|
+
let planPath = resolveRefPath(planRef, promptDir, config.repoRoot)
|
|
364
|
+
?? resolveDocPath(planRef, config)
|
|
365
|
+
?? resolveDocArg(planRef, config, { dieOnMiss: false });
|
|
358
366
|
if (!planPath || !existsSync(planPath)) die(`Linked plan is missing; prompt was not consumed: ${planRef}`);
|
|
359
367
|
planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
|
|
360
368
|
const raw = readFileSync(planPath, 'utf8');
|
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;
|
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
|
+
}
|