runlist 0.0.0 → 0.94.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/LICENSE +21 -0
- package/README.md +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { referenceValues } from './reference-values.mjs';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
|
+
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
6
|
+
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
|
|
7
|
+
import { findLexicalDocsRoot } from './managed-path.mjs';
|
|
8
|
+
import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
|
|
9
|
+
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
10
|
+
import { checkIndex } from './index-file.mjs';
|
|
11
|
+
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
12
|
+
import { checkGlossaryConfig } from './glossary-check.mjs';
|
|
13
|
+
import { checkSkillDrift } from './skill-drift.mjs';
|
|
14
|
+
import { checkHubStatusDrift } from './sync-status.mjs';
|
|
15
|
+
import { checkHubMembershipDrift } from './hub-membership.mjs';
|
|
16
|
+
import {parseNativeRecord} from './native-record.mjs';
|
|
17
|
+
import { readShipsFrontmatter } from './stages.mjs';
|
|
18
|
+
import { buildFilingCoverage, filingFindings } from './filing.mjs';
|
|
19
|
+
|
|
20
|
+
// `fast: true` skips every pass that produces warnings/errors — the rendered
|
|
21
|
+
// index file consumes only status/title/snapshot/etc., not the validation
|
|
22
|
+
// output. Use it from `regenIndex` (post-mutation index refresh) where
|
|
23
|
+
// validation has already run elsewhere (or will, next time the user runs
|
|
24
|
+
// `runlist check`). Saves the full-repo `git log` scan in `checkGitStaleness`
|
|
25
|
+
// plus the bidirectional ref walk + claude-commands check.
|
|
26
|
+
//
|
|
27
|
+
// `errorsOnly: true` runs every built-in error-producing pass (per-file
|
|
28
|
+
// `validateDoc`, `checkIndex`) but skips the warning-only cross-doc
|
|
29
|
+
// passes (bidirectional refs, runlist back-pointers, git staleness, claude
|
|
30
|
+
// commands). Use it from `runlist hud` — the SessionStart hook only renders the
|
|
31
|
+
// error COUNT, so the warning-only passes are pure overhead there. Preserves
|
|
32
|
+
// the built-in invariant that HUD's error count matches `runlist check`.
|
|
33
|
+
export function buildIndex(config, opts = {}) {
|
|
34
|
+
const { fast = false, errorsOnly = false, autoHealIndex = false } = opts;
|
|
35
|
+
const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
|
|
36
|
+
const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
|
|
37
|
+
const skipWarningOnlyChecks = fast || errorsOnly;
|
|
38
|
+
const cache = openParseCache(config);
|
|
39
|
+
const scanFailures = [];
|
|
40
|
+
const docs = collectDocFiles(config, { onError: (directory, err) => {
|
|
41
|
+
scanFailures.push({ path: null, level: 'error', message: `Could not read scan directory ${toRepoPath(directory, config.repoRoot)}: ${err.message}`, meta: { kind: 'scan-failure' } });
|
|
42
|
+
} }).flatMap(f => {
|
|
43
|
+
try { return [parseDocFile(f, config, { fast, cache })]; }
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err.code !== 'ENOENT') throw err;
|
|
46
|
+
scanFailures.push({ path: toRepoPath(f, config.repoRoot), level: 'warning', message: 'Document disappeared after discovery; it was not checked.', meta: { kind: 'scan-disappeared' } });
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
if (cache && !config._execution?.suppressSideEffects) cache.save();
|
|
51
|
+
if (!fast) {
|
|
52
|
+
// Per-file validation (validateDoc) ran during parse without sibling
|
|
53
|
+
// visibility. Now that the full index is materialized, enrich
|
|
54
|
+
// unresolved-ref entries with "Did you mean..." candidates drawn from the
|
|
55
|
+
// index — mutates doc.errors/doc.warnings in place so the aggregations
|
|
56
|
+
// below pick up the enriched messages.
|
|
57
|
+
enrichRefErrorSuggestions(docs, config);
|
|
58
|
+
}
|
|
59
|
+
const warnings = scanFailures.filter(f => f.level === 'warning');
|
|
60
|
+
const errors = scanFailures.filter(f => f.level === 'error');
|
|
61
|
+
|
|
62
|
+
for (const doc of docs) {
|
|
63
|
+
warnings.push(...doc.warnings);
|
|
64
|
+
errors.push(...doc.errors);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!fast && invokeHooks && config.hooks.validate) {
|
|
68
|
+
const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
|
|
69
|
+
for (const doc of docs) {
|
|
70
|
+
try {
|
|
71
|
+
const result = config.hooks.validate(doc, ctx);
|
|
72
|
+
if (result?.errors) {
|
|
73
|
+
doc.errors.push(...result.errors);
|
|
74
|
+
errors.push(...result.errors);
|
|
75
|
+
}
|
|
76
|
+
if (result?.warnings) {
|
|
77
|
+
doc.warnings.push(...result.warnings);
|
|
78
|
+
warnings.push(...result.warnings);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const hookError = { path: doc.path, level: 'error', message: `Hook 'validate' threw: ${err.message}`, meta: { kind: 'hook-failure' } };
|
|
82
|
+
scanFailures.push(hookError);
|
|
83
|
+
doc.errors.push(hookError);
|
|
84
|
+
errors.push(hookError);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const transformedDocs = invokeHooks && config.hooks.transformDoc
|
|
90
|
+
? docs.map(d => {
|
|
91
|
+
try { return config.hooks.transformDoc(d) ?? d; }
|
|
92
|
+
catch (err) {
|
|
93
|
+
const failure = { path: d.path, level: 'warning', message: `Hook 'transformDoc' threw: ${err.message}`, meta: { kind: 'hook-failure' } };
|
|
94
|
+
warnings.push(failure);
|
|
95
|
+
scanFailures.push(failure);
|
|
96
|
+
return d;
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
: docs;
|
|
100
|
+
|
|
101
|
+
const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
|
|
102
|
+
status,
|
|
103
|
+
transformedDocs.filter(doc => doc.status === status).length,
|
|
104
|
+
]));
|
|
105
|
+
const knownStatuses = new Set(config.statusOrder);
|
|
106
|
+
for (const doc of transformedDocs) {
|
|
107
|
+
if (doc.status && !knownStatuses.has(doc.status)) {
|
|
108
|
+
countsByStatus[doc.status] = (countsByStatus[doc.status] ?? 0) + 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Per-type counts (F6): same input docs, keyed by `type` first so callers
|
|
113
|
+
// can distinguish `plan/partial` (work shipped + tail deferred) from
|
|
114
|
+
// `doc/partial` (incomplete reference material). Untyped docs (pre-0.30
|
|
115
|
+
// corpora) land under `unknown` rather than getting dropped silently.
|
|
116
|
+
const countsByType = {};
|
|
117
|
+
for (const doc of transformedDocs) {
|
|
118
|
+
if (!doc.status) continue;
|
|
119
|
+
const type = doc.type || 'unknown';
|
|
120
|
+
if (!countsByType[type]) countsByType[type] = {};
|
|
121
|
+
countsByType[type][doc.status] = (countsByType[type][doc.status] ?? 0) + 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!fast && config.indexPath) {
|
|
125
|
+
// `autoHealIndex` is opt-in from the caller (currently `runlist check`).
|
|
126
|
+
// When true, drift triggers an in-place rewrite and a
|
|
127
|
+
// warning instead of the old "Run `runlist index`" error — closing the
|
|
128
|
+
// class of nags produced by mutation paths that skip `regenIndex`
|
|
129
|
+
// (`lint --fix`, direct file edits, etc). `transformedDocs` here is
|
|
130
|
+
// always the canonical full set; CLI-level `--root`/`--type` filtering
|
|
131
|
+
// runs after `buildIndex` returns, so a rewrite is safe. Off by default
|
|
132
|
+
// so dry-run / print modes never mutate disk as a side effect.
|
|
133
|
+
const indexCheck = checkIndex(transformedDocs, config, {
|
|
134
|
+
autoHeal: autoHealIndex && scanFailures.length === 0,
|
|
135
|
+
rebuildDocs: autoHealIndex ? () => buildIndex(config, { fast: true }).docs : null,
|
|
136
|
+
testHooks: opts.testHooks,
|
|
137
|
+
});
|
|
138
|
+
warnings.push(...indexCheck.warnings);
|
|
139
|
+
errors.push(...indexCheck.errors);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Hub status drift produces ERRORS (a drifted marked span), so it runs in
|
|
143
|
+
// errorsOnly mode too — that's what keeps `runlist hud`'s error count equal to
|
|
144
|
+
// `runlist check`'s. Its warnings still obey the warning-only gate.
|
|
145
|
+
if (!fast) {
|
|
146
|
+
const hubStatus = checkHubStatusDrift(transformedDocs, config);
|
|
147
|
+
errors.push(...hubStatus.errors);
|
|
148
|
+
if (!skipWarningOnlyChecks) warnings.push(...hubStatus.warnings);
|
|
149
|
+
for (const entry of [...hubStatus.errors, ...(skipWarningOnlyChecks ? [] : hubStatus.warnings)]) {
|
|
150
|
+
const hub = transformedDocs.find(d => d.path === entry.path);
|
|
151
|
+
if (!hub) continue;
|
|
152
|
+
(entry.level === 'error' ? hub.errors : hub.warnings).push(entry);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!skipWarningOnlyChecks) {
|
|
157
|
+
const refCheck = checkBidirectionalReferences(transformedDocs, config);
|
|
158
|
+
warnings.push(...refCheck.warnings);
|
|
159
|
+
|
|
160
|
+
const runlistWarnings = checkRunlistBackPointers(transformedDocs, config);
|
|
161
|
+
warnings.push(...runlistWarnings);
|
|
162
|
+
for (const w of runlistWarnings) {
|
|
163
|
+
const child = transformedDocs.find(d => d.path === w.path);
|
|
164
|
+
if (child) child.warnings.push(w);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const membershipWarnings = checkHubMembershipDrift(transformedDocs, config);
|
|
168
|
+
warnings.push(...membershipWarnings);
|
|
169
|
+
for (const w of membershipWarnings) {
|
|
170
|
+
const owner = transformedDocs.find(d => d.path === w.path);
|
|
171
|
+
if (owner) owner.warnings.push(w);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
|
|
175
|
+
warnings.push(...coordHubWarnings);
|
|
176
|
+
for (const w of coordHubWarnings) {
|
|
177
|
+
const hub = transformedDocs.find(d => d.path === w.path);
|
|
178
|
+
if (hub) hub.warnings.push(w);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const roadmapHubWarnings = checkRoadmapHubExecutionMode(transformedDocs, config);
|
|
182
|
+
warnings.push(...roadmapHubWarnings);
|
|
183
|
+
for (const w of roadmapHubWarnings) {
|
|
184
|
+
const hub = transformedDocs.find(d => d.path === w.path);
|
|
185
|
+
if (hub) hub.warnings.push(w);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (gitStaleness) {
|
|
189
|
+
const gitWarnings = checkGitStaleness(transformedDocs, config, opts.gitMetadataOptions);
|
|
190
|
+
warnings.push(...gitWarnings);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const claudeWarnings = checkClaudeCommands(config.repoRoot);
|
|
194
|
+
warnings.push(...claudeWarnings);
|
|
195
|
+
|
|
196
|
+
const glossaryWarnings = checkGlossaryConfig(config);
|
|
197
|
+
warnings.push(...glossaryWarnings);
|
|
198
|
+
|
|
199
|
+
const skillDriftWarnings = checkSkillDrift(config);
|
|
200
|
+
warnings.push(...skillDriftWarnings);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const filingCoverage = !fast && !errorsOnly ? buildFilingCoverage(transformedDocs, config) : null;
|
|
204
|
+
if (filingCoverage) {
|
|
205
|
+
for (const failure of filingCoverage.failures) scanFailures.push({ path: failure.path, level: 'warning',
|
|
206
|
+
message: `Could not read hub for filing coverage: ${failure.message}`, meta: { kind: 'filing-read-failure' } });
|
|
207
|
+
if (scanFailures.length) filingCoverage.complete = false;
|
|
208
|
+
const byPath = new Map(transformedDocs.map(doc => [doc.path, doc]));
|
|
209
|
+
for (const finding of filingFindings(filingCoverage)) {
|
|
210
|
+
warnings.push(finding);
|
|
211
|
+
byPath.get(finding.path)?.warnings.push(finding);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
generatedAt: new Date().toISOString(),
|
|
217
|
+
filingCoverage,
|
|
218
|
+
docs: transformedDocs,
|
|
219
|
+
countsByStatus,
|
|
220
|
+
countsByType,
|
|
221
|
+
warnings,
|
|
222
|
+
errors,
|
|
223
|
+
scanCoverage: { complete: scanFailures.length === 0, failures: scanFailures },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function collectDocFiles(config, { onError = null } = {}) {
|
|
228
|
+
const files = [];
|
|
229
|
+
const skipPaths = new Set();
|
|
230
|
+
if (config.indexPath) skipPaths.add(config.indexPath);
|
|
231
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
for (const root of roots) {
|
|
234
|
+
walkMarkdownFiles(root, files, config.excludeDirs, skipPaths, seen, onError);
|
|
235
|
+
}
|
|
236
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Shared resolver for CLI file arguments — the single path every file-taking
|
|
240
|
+
// verb (`use`, `set`, `archive`, `touch`, `rename`, …) funnels through so
|
|
241
|
+
// bare slugs behave identically everywhere. Tries, in order: exact path
|
|
242
|
+
// (the resolveDocPath fast path), `<input>.md`, then a unique basename match
|
|
243
|
+
// across all doc roots. An ambiguous basename dies listing the candidates
|
|
244
|
+
// rather than guessing — a wrong auto-resolved mutation is worse than a
|
|
245
|
+
// retry. A full miss dies with did-you-mean suggestions drawn from the doc
|
|
246
|
+
// corpus; pass { dieOnMiss: false } to get null instead and keep a custom
|
|
247
|
+
// fallback at the call site.
|
|
248
|
+
export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
|
|
249
|
+
if (!input) return null;
|
|
250
|
+
const direct = resolveDocPath(input, config);
|
|
251
|
+
if (direct) return direct;
|
|
252
|
+
if (!input.endsWith('.md')) {
|
|
253
|
+
const withExt = resolveDocPath(input + '.md', config);
|
|
254
|
+
if (withExt) return withExt;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const slug = input.replace(/\.md$/, '');
|
|
258
|
+
const files = collectDocFiles(config);
|
|
259
|
+
const byBasename = files.filter(f => path.basename(f, '.md') === slug);
|
|
260
|
+
if (byBasename.length === 1) return byBasename[0];
|
|
261
|
+
if (byBasename.length > 1) {
|
|
262
|
+
die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!dieOnMiss) return null;
|
|
266
|
+
die(docArgMissMessage(input, config, files));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// `File not found` + the searched roots + up-to-3 did-you-mean candidates
|
|
270
|
+
// matched on basename and printed as repo-relative paths. Exported so verbs
|
|
271
|
+
// with their own resolution (e.g. interactive pickers) can reuse the message.
|
|
272
|
+
export function docArgMissMessage(input, config, files = collectDocFiles(config)) {
|
|
273
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
274
|
+
const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
|
|
275
|
+
const slug = String(input).split('/').pop().replace(/\.md$/, '');
|
|
276
|
+
const pathsByBase = new Map();
|
|
277
|
+
for (const f of files) {
|
|
278
|
+
const base = path.basename(f, '.md');
|
|
279
|
+
if (!pathsByBase.has(base)) pathsByBase.set(base, toRepoPath(f, config.repoRoot));
|
|
280
|
+
}
|
|
281
|
+
const hits = suggestCandidates(slug, [...pathsByBase.keys()]);
|
|
282
|
+
let msg = `File not found: ${input}\nSearched: ${searched}`;
|
|
283
|
+
if (hits.length) msg += `\nDid you mean: ${hits.map(b => pathsByBase.get(b)).join(', ')}?`;
|
|
284
|
+
return msg;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new Set(), onError = null) {
|
|
288
|
+
let entries;
|
|
289
|
+
try {
|
|
290
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
291
|
+
} catch (err) {
|
|
292
|
+
onError?.(directory, err);
|
|
293
|
+
warn(`Could not read directory ${directory}: ${err.message}`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
if (entry.isDirectory()) {
|
|
298
|
+
if (excludedDirs && excludedDirs.has(entry.name)) continue;
|
|
299
|
+
walkMarkdownFiles(path.join(directory, entry.name), files, excludedDirs, skipPaths, seen, onError);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const fullPath = path.join(directory, entry.name);
|
|
303
|
+
if (!entry.isFile() || !entry.name.endsWith('.md') || skipPaths.has(fullPath) || seen.has(fullPath)) continue;
|
|
304
|
+
seen.add(fullPath);
|
|
305
|
+
files.push(fullPath);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Everything parseDocFile takes from the file's text alone, with no config and
|
|
310
|
+
// no clock, which is what the parse cache may keep.
|
|
311
|
+
function extractDocText(frontmatter, body) {
|
|
312
|
+
const fmWarnings = [];
|
|
313
|
+
const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
|
|
314
|
+
if (Object.hasOwn(parsedFrontmatter, 'ships')) parsedFrontmatter.ships = readShipsFrontmatter(frontmatter);
|
|
315
|
+
return {
|
|
316
|
+
shipsMetadataVersion: 1,
|
|
317
|
+
parsedFrontmatter,
|
|
318
|
+
fmWarnings: fmWarnings.map(w => ({ message: w.message })),
|
|
319
|
+
headingTitle: extractFirstHeading(body),
|
|
320
|
+
bodySummary: extractSummary(body),
|
|
321
|
+
bodyStatusSnapshot: extractStatusSnapshot(body),
|
|
322
|
+
bodyNextStep: extractNextStep(body),
|
|
323
|
+
checklist: extractChecklistCounts(body),
|
|
324
|
+
bodyLinks: extractBodyLinks(body),
|
|
325
|
+
hasCloseout: /^##\s+Closeout/m.test(body),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function parseDocFile(filePath, config, opts = {}) {
|
|
330
|
+
const { fast = false, cache = null } = opts;
|
|
331
|
+
const relativePath = toRepoPath(filePath, config.repoRoot);
|
|
332
|
+
const stamp = cache && opts.source === undefined ? fileStamp(filePath) : null;
|
|
333
|
+
let text = stamp ? cache.get(relativePath, stamp) : null;
|
|
334
|
+
// Older cache entries cannot distinguish blank ships from an empty YAML list.
|
|
335
|
+
if (text && text.shipsMetadataVersion !== 1) text = null;
|
|
336
|
+
// Validation reads the body itself, so only a fast build can skip the read.
|
|
337
|
+
let body = null;
|
|
338
|
+
let rawSource = null;
|
|
339
|
+
if (!text || !fast) {
|
|
340
|
+
const raw = opts.source ?? readFileSync(filePath, 'utf8');
|
|
341
|
+
rawSource = raw;
|
|
342
|
+
const extracted = extractFrontmatter(raw);
|
|
343
|
+
body = extracted.body;
|
|
344
|
+
// A file rewritten between the stat and the read no longer matches its stamp.
|
|
345
|
+
if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
|
|
346
|
+
if (!text) {
|
|
347
|
+
text = extractDocText(extracted.frontmatter, body);
|
|
348
|
+
if (stamp) cache.set(relativePath, stamp, text);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
|
|
352
|
+
const nativeTitle = parsedFrontmatter.record_schema==='runlist.record/v1' ? asString(parsedFrontmatter.finding)??asString(parsedFrontmatter.question) : null;
|
|
353
|
+
const title = nativeTitle ?? asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
|
|
354
|
+
const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
|
|
355
|
+
// For terminal-status docs (archived / reference / deprecated by default),
|
|
356
|
+
// skip the body-scrape and the "No current_state set" fallback when the user
|
|
357
|
+
// didn't set `current_state:` in frontmatter explicitly. Body text on a
|
|
358
|
+
// settled doc often contains stale "in progress" / "FIXED (uncommitted)"
|
|
359
|
+
// snapshots from when the doc was live; surfacing those in the index lies
|
|
360
|
+
// about current state. Frontmatter still wins if explicit — the audit's
|
|
361
|
+
// criterion: "should defer to frontmatter when status is terminal."
|
|
362
|
+
const fmCurrentState = asString(parsedFrontmatter.current_state);
|
|
363
|
+
const docStatus = asString(parsedFrontmatter.status);
|
|
364
|
+
const isTerminalDoc = docStatus && (config.lifecycle?.isTerminal?.(docStatus, asString(parsedFrontmatter.type) ?? null)
|
|
365
|
+
?? config.lifecycle?.terminalStatuses?.has?.(docStatus));
|
|
366
|
+
// Track where currentState came from so renderers can prefix `(auto)` on
|
|
367
|
+
// body-scraped values. Frontmatter wins silently; body-scraped values flag
|
|
368
|
+
// their origin so the user knows the string was inferred (and that adding
|
|
369
|
+
// `current_state:` to frontmatter would override). The placeholder
|
|
370
|
+
// `'No current_state set'` is neither — origin stays null.
|
|
371
|
+
let currentState;
|
|
372
|
+
let currentStateOrigin = null;
|
|
373
|
+
if (fmCurrentState) {
|
|
374
|
+
currentState = fmCurrentState;
|
|
375
|
+
currentStateOrigin = 'frontmatter';
|
|
376
|
+
} else if (isTerminalDoc) {
|
|
377
|
+
currentState = null;
|
|
378
|
+
} else {
|
|
379
|
+
const scraped = text.bodyStatusSnapshot;
|
|
380
|
+
if (scraped) {
|
|
381
|
+
currentState = scraped;
|
|
382
|
+
currentStateOrigin = 'body';
|
|
383
|
+
} else {
|
|
384
|
+
currentState = 'No current_state set';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
|
|
388
|
+
// `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
|
|
389
|
+
// filing tickets naturally reach for the JIRA/Linear name. If both are set,
|
|
390
|
+
// they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
|
|
391
|
+
const blockers = mergeUniqueStrings(
|
|
392
|
+
normalizeBlockers(parsedFrontmatter.blockers),
|
|
393
|
+
normalizeBlockers(parsedFrontmatter.blocked_by),
|
|
394
|
+
);
|
|
395
|
+
const surface = asString(parsedFrontmatter.surface) ?? null;
|
|
396
|
+
const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
|
|
397
|
+
const moduleName = asString(parsedFrontmatter.module) ?? null;
|
|
398
|
+
const modules = normalizeStringList(parsedFrontmatter.modules);
|
|
399
|
+
const domain = asString(parsedFrontmatter.domain) ?? null;
|
|
400
|
+
const audience = asString(parsedFrontmatter.audience) ?? null;
|
|
401
|
+
const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
|
|
402
|
+
|
|
403
|
+
// Dynamic reference field extraction. A leading `>` on a value (e.g.
|
|
404
|
+
// `"> docs/audit-example-project.md"`) marks that single ref as one-way —
|
|
405
|
+
// the prefix is stripped so path resolution still works, and the direction
|
|
406
|
+
// is recorded on a parallel `refFieldDirections[field]` array indexed the
|
|
407
|
+
// same as `refFields[field]`. Bidirectional reciprocity checks consume the
|
|
408
|
+
// directions to skip outbound entries that opted out of expecting a back-ref.
|
|
409
|
+
const refFields = {};
|
|
410
|
+
const refFieldDirections = {};
|
|
411
|
+
for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
|
|
412
|
+
const { paths, directions } = referenceValues(parsedFrontmatter[field]);
|
|
413
|
+
refFields[field] = paths;
|
|
414
|
+
refFieldDirections[field] = directions;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Tag doc with its root
|
|
418
|
+
const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
|
|
419
|
+
const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
|
|
420
|
+
|
|
421
|
+
const docType = asString(parsedFrontmatter.type) ?? null;
|
|
422
|
+
|
|
423
|
+
const doc = {
|
|
424
|
+
path: relativePath,
|
|
425
|
+
root: rootLabel,
|
|
426
|
+
type: docType,
|
|
427
|
+
status: asString(parsedFrontmatter.status) ?? null,
|
|
428
|
+
// Preserve ships without coercing lists/objects into apparently valid stages.
|
|
429
|
+
ships: parsedFrontmatter.ships ?? null,
|
|
430
|
+
owner: asString(parsedFrontmatter.owner) ?? null,
|
|
431
|
+
surface,
|
|
432
|
+
surfaces: mergeUniqueStrings(surface ? [surface] : [], surfaces),
|
|
433
|
+
module: moduleName,
|
|
434
|
+
modules: mergeUniqueStrings(moduleName ? [moduleName] : [], modules),
|
|
435
|
+
domain,
|
|
436
|
+
audience,
|
|
437
|
+
executionMode,
|
|
438
|
+
title,
|
|
439
|
+
summary,
|
|
440
|
+
currentState,
|
|
441
|
+
currentStateOrigin,
|
|
442
|
+
nextStep,
|
|
443
|
+
blockers,
|
|
444
|
+
updated: asString(parsedFrontmatter.updated) ?? null,
|
|
445
|
+
created: asString(parsedFrontmatter.created) ?? null,
|
|
446
|
+
audited: asString(parsedFrontmatter.audited) ?? null,
|
|
447
|
+
auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
|
|
448
|
+
sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
|
|
449
|
+
checklist,
|
|
450
|
+
bodyLinks,
|
|
451
|
+
refFields,
|
|
452
|
+
refFieldDirections,
|
|
453
|
+
checklistCompletionRate: computeChecklistCompletionRate(checklist),
|
|
454
|
+
hasCloseout,
|
|
455
|
+
hasNextStep: Boolean(nextStep),
|
|
456
|
+
hasBlockers: blockers.length > 0,
|
|
457
|
+
daysSinceUpdate: computeDaysSinceUpdate(asString(parsedFrontmatter.updated) ?? null),
|
|
458
|
+
isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config, docType),
|
|
459
|
+
warnings: [],
|
|
460
|
+
errors: [],
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
for (const w of fmWarnings) {
|
|
464
|
+
doc.warnings.push({ path: relativePath, level: 'warning', message: w.message });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!fast) {
|
|
468
|
+
if(parsedFrontmatter.record_schema!==undefined&&['flag','decision'].includes(doc.type)){
|
|
469
|
+
const native=parseNativeRecord(rawSource);
|
|
470
|
+
doc.errors.push(...native.diagnostics.map(d=>({path:relativePath,level:'error',message:`${d.path}: ${d.message}`})));
|
|
471
|
+
return doc;
|
|
472
|
+
}
|
|
473
|
+
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
474
|
+
validatePlanShape(doc, body, parsedFrontmatter, config);
|
|
475
|
+
validateDocShape(doc, body, parsedFrontmatter, config);
|
|
476
|
+
}
|
|
477
|
+
return doc;
|
|
478
|
+
}
|