runlist 0.0.0 → 0.94.2
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 +1112 -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 +296 -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/public-inspection.mjs +19 -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 +795 -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/filing.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { extractBodyLinks } from './extractors.mjs';
|
|
5
|
+
import { isHubDoc, splitRowCells, findMarkedSpan, readPositionalToken } from './hub.mjs';
|
|
6
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
7
|
+
import { isArchivedPath, resolveRefPath, toRepoPath } from './util.mjs';
|
|
8
|
+
|
|
9
|
+
export function filingOptions(value) {
|
|
10
|
+
if (value === true) return { categoryDepth: 3 };
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
12
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') return null;
|
|
13
|
+
if (value.enabled === false) return null;
|
|
14
|
+
const depth = value.categoryDepth ?? 3;
|
|
15
|
+
return Number.isInteger(depth) && depth >= 2 && depth <= 6 ? { categoryDepth: depth } : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateFilingConfig(value) {
|
|
19
|
+
if (value === undefined || value === null || typeof value === 'boolean') return [];
|
|
20
|
+
if (typeof value !== 'object' || Array.isArray(value)) return ['Config: filing must be a boolean or an object.'];
|
|
21
|
+
const warnings = [];
|
|
22
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') warnings.push('Config: filing.enabled must be a boolean.');
|
|
23
|
+
if (value.categoryDepth !== undefined && (!Number.isInteger(value.categoryDepth) || value.categoryDepth < 2 || value.categoryDepth > 6)) {
|
|
24
|
+
warnings.push('Config: filing.categoryDepth must be an integer from 2 to 6.');
|
|
25
|
+
}
|
|
26
|
+
return warnings;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Preserve line numbers while ignoring examples and comments. Keep status
|
|
30
|
+
// markers: those short comments delimit an authored, managed status word.
|
|
31
|
+
function visibleBody(body) {
|
|
32
|
+
const comments = body.replace(/<!--[\s\S]*?-->/g, comment =>
|
|
33
|
+
/^<!--\s*\/?s\s*-->$/.test(comment) ? comment : comment.replace(/[^\n]/g, ' '));
|
|
34
|
+
let fence = null;
|
|
35
|
+
return comments.split('\n').map(line => {
|
|
36
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
37
|
+
if (fence) {
|
|
38
|
+
if (match && match[1][0] === fence[0] && match[1].length >= fence.length && !match[2].trim()) fence = null;
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
if (match) { fence = match[1]; return ''; }
|
|
42
|
+
return /^ {4}|^\t/.test(line) ? '' : line;
|
|
43
|
+
}).join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const delimiter = cells => cells.length > 0 && cells.every(cell => /^\s*:?-+:?\s*$/.test(cell.raw));
|
|
47
|
+
const headerWord = cell => cell.raw.replace(/[*_`]/g, '').trim().toLowerCase();
|
|
48
|
+
|
|
49
|
+
// Read first-cell subjects once, retaining category index rows independently
|
|
50
|
+
// of whether they carry the status needed to establish a plan home.
|
|
51
|
+
function scanSubjectRows(body, categoryDepth) {
|
|
52
|
+
const lines = visibleBody(body).split('\n');
|
|
53
|
+
const rows = [];
|
|
54
|
+
let category = null;
|
|
55
|
+
let table = null;
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
const heading = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
59
|
+
if (heading) {
|
|
60
|
+
if (heading[1].length <= categoryDepth) category = heading[1].length === categoryDepth ? heading[2].trim() : null;
|
|
61
|
+
}
|
|
62
|
+
if (!line.trim().startsWith('|')) { table = null; continue; }
|
|
63
|
+
const cells = splitRowCells(line);
|
|
64
|
+
if (!table) {
|
|
65
|
+
const next = lines[i + 1] ?? '';
|
|
66
|
+
if (next.trim().startsWith('|') && delimiter(splitRowCells(next))) {
|
|
67
|
+
table = { statusColumn: cells.findIndex(cell => ['status', 'state'].includes(headerWord(cell))) };
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (delimiter(cells)) continue;
|
|
72
|
+
const subject = extractBodyLinks(cells[0]?.raw ?? '').find(link => link.targetKind === 'document');
|
|
73
|
+
if (!subject) continue;
|
|
74
|
+
const statusCell = table.statusColumn >= 0 ? cells[table.statusColumn] : null;
|
|
75
|
+
// A marker belongs in a later cell, never inside the subject or its prose.
|
|
76
|
+
const marked = statusCell ? findMarkedSpan(statusCell.raw) :
|
|
77
|
+
cells.slice(1).map(cell => findMarkedSpan(cell.raw)).find(Boolean);
|
|
78
|
+
rows.push({ lineIndex: i, href: subject.href, category, statusCell, marked });
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A plan home requires a status-bearing subject row. Category index rows can
|
|
84
|
+
// categorize hubs, but do not file plans or establish an inherited parent home.
|
|
85
|
+
export function scanFilingRows(body, categoryDepth = 3) {
|
|
86
|
+
return scanSubjectRows(body, categoryDepth).filter(row => row.statusCell || row.marked);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function closed(doc, config) {
|
|
90
|
+
return isArchivedPath(doc.path, config) || (config.lifecycle?.isTerminal?.(doc.status, doc.type)
|
|
91
|
+
?? config.lifecycle?.terminalStatuses?.has(doc.status) ?? false);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function totals(plans) {
|
|
95
|
+
return {
|
|
96
|
+
plans: plans.length,
|
|
97
|
+
filedDirect: plans.filter(plan => plan.filing === 'direct').length,
|
|
98
|
+
filedThroughParent: plans.filter(plan => plan.filing === 'parent').length,
|
|
99
|
+
unfiled: plans.filter(plan => plan.filing === 'unfiled').length,
|
|
100
|
+
multiplyFiled: plans.filter(plan => plan.rows.length > 1 || new Set(plan.rows.flatMap(row => row.categories)).size > 1).length,
|
|
101
|
+
noCategory: plans.filter(plan => plan.rows.some(row => !row.categories.length)).length,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function scopeFilingCoverage(report, docs) {
|
|
106
|
+
if (!report) return null;
|
|
107
|
+
const selected = new Set(docs.map(doc => doc.path));
|
|
108
|
+
const plans = report.plans.filter(plan => selected.has(plan.path));
|
|
109
|
+
return { ...report, totals: totals(plans), plans };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Prepare compact row evidence while a single hub source is in hand. The
|
|
113
|
+
// assembler below consumes these detached records without rereading sources.
|
|
114
|
+
export function prepareFilingHub(hub, raw, config) {
|
|
115
|
+
const options = filingOptions(config.filing);
|
|
116
|
+
if (!options) return [];
|
|
117
|
+
const { body, bodyLineOffset } = extractFrontmatter(raw);
|
|
118
|
+
const dir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
119
|
+
const vocabulary = config.typeStatuses?.get('plan') ?? config.validStatuses ?? new Set();
|
|
120
|
+
return scanSubjectRows(body, options.categoryDepth).flatMap(row => {
|
|
121
|
+
const resolved = resolveBodyLinkTarget(row.href, dir, config.repoRoot);
|
|
122
|
+
if (!resolved.ok) return [];
|
|
123
|
+
const span = row.marked ?? readPositionalToken(row.statusCell, word => vocabulary.has(word.toLowerCase()));
|
|
124
|
+
return [{ hub: hub.path, line: bodyLineOffset + row.lineIndex + 1,
|
|
125
|
+
subject: toRepoPath(resolved.path, config.repoRoot), category: row.category,
|
|
126
|
+
printedStatus: span && vocabulary.has(span.text.toLowerCase()) ? span.text : null }];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function filingParentTarget(doc, config) {
|
|
131
|
+
const parents = doc.refFields?.parent_plan ?? [];
|
|
132
|
+
if (parents.length !== 1) return null;
|
|
133
|
+
const absolute = resolveRefPath(parents[0], path.dirname(path.join(config.repoRoot, doc.path)), config.repoRoot);
|
|
134
|
+
return absolute ? toRepoPath(absolute, config.repoRoot) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function filingLiveHub(doc, config) {
|
|
138
|
+
return isHubDoc(doc) && !closed(doc, config);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Pure report assembly. Filesystem resolution and source reads belong to the
|
|
142
|
+
// preparation phase, so native callers can advance those in bounded requests.
|
|
143
|
+
export function assembleFilingCoverage(docs, config, preparedRows, parentTargets, { failures = [], complete = true } = {}) {
|
|
144
|
+
const options = filingOptions(config.filing);
|
|
145
|
+
if (!options) return null;
|
|
146
|
+
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
147
|
+
const folded = new Map();
|
|
148
|
+
for (const doc of docs) {
|
|
149
|
+
const key = doc.path.toLowerCase();
|
|
150
|
+
folded.set(key, folded.has(key) ? null : doc);
|
|
151
|
+
}
|
|
152
|
+
const lookup = relative => byPath.get(relative) ?? folded.get(relative.toLowerCase()) ?? null;
|
|
153
|
+
const homes = new Map();
|
|
154
|
+
const categoryRows = new Map();
|
|
155
|
+
for (const row of preparedRows) {
|
|
156
|
+
const subject = lookup(row.subject);
|
|
157
|
+
if (!subject || subject.type !== 'plan' || subject.path === row.hub || closed(subject, config)) continue;
|
|
158
|
+
if (isHubDoc(subject)) {
|
|
159
|
+
if (!categoryRows.has(subject.path)) categoryRows.set(subject.path, []);
|
|
160
|
+
categoryRows.get(subject.path).push({ hub: row.hub, category: row.category });
|
|
161
|
+
}
|
|
162
|
+
if (!row.printedStatus) continue;
|
|
163
|
+
if (!homes.has(subject.path)) homes.set(subject.path, []);
|
|
164
|
+
homes.get(subject.path).push({ hub: row.hub, line: row.line,
|
|
165
|
+
subject: subject.path, category: row.category, categories: [], printedStatus: row.printedStatus });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Explicit first-cell hub index rows pass categories to the hub's plan rows,
|
|
169
|
+
// including indexes without status columns. Hub titles are not guessed.
|
|
170
|
+
const hubCategories = new Map();
|
|
171
|
+
const liveHubs = docs.filter(doc => isHubDoc(doc) && !closed(doc, config));
|
|
172
|
+
for (const hub of liveHubs) hubCategories.set(hub.path, new Set());
|
|
173
|
+
const dependents = new Map();
|
|
174
|
+
const queue = [];
|
|
175
|
+
const addCategory = (hub, category) => {
|
|
176
|
+
const categories = hubCategories.get(hub);
|
|
177
|
+
if (categories && !categories.has(category)) { categories.add(category); queue.push([hub, category]); }
|
|
178
|
+
};
|
|
179
|
+
for (const hub of liveHubs) for (const row of categoryRows.get(hub.path) ?? []) {
|
|
180
|
+
if (row.category) addCategory(hub.path, row.category);
|
|
181
|
+
else {
|
|
182
|
+
if (!dependents.has(row.hub)) dependents.set(row.hub, new Set());
|
|
183
|
+
dependents.get(row.hub).add(hub.path);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (let i = 0; i < queue.length; i++) {
|
|
187
|
+
const [hub, category] = queue[i];
|
|
188
|
+
for (const dependent of dependents.get(hub) ?? []) addCategory(dependent, category);
|
|
189
|
+
}
|
|
190
|
+
for (const rows of homes.values()) for (const row of rows) {
|
|
191
|
+
row.categories = row.category ? [row.category] : [...(hubCategories.get(row.hub) ?? [])].sort();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Parent inheritance is explicit and cycle-safe. A child's own home takes
|
|
195
|
+
// precedence. Cache each resolved ancestor so deep plan trees stay linear.
|
|
196
|
+
const memo = new Map();
|
|
197
|
+
const resolveHome = doc => {
|
|
198
|
+
const chain = [];
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
let current = doc;
|
|
201
|
+
let result = null;
|
|
202
|
+
while (current) {
|
|
203
|
+
if (memo.has(current.path)) { result = memo.get(current.path); break; }
|
|
204
|
+
if (seen.has(current.path) || closed(current, config)) break;
|
|
205
|
+
seen.add(current.path);
|
|
206
|
+
if (homes.has(current.path)) { result = { filedThrough: current.path, rows: homes.get(current.path) }; break; }
|
|
207
|
+
chain.push(current.path);
|
|
208
|
+
const parent = parentTargets.get(current.path);
|
|
209
|
+
current = parent ? lookup(parent) : null;
|
|
210
|
+
if (current?.type !== 'plan') break;
|
|
211
|
+
}
|
|
212
|
+
for (const item of chain) memo.set(item, result);
|
|
213
|
+
return result;
|
|
214
|
+
};
|
|
215
|
+
const plans = docs.filter(doc => doc.type === 'plan' && !isHubDoc(doc) && !closed(doc, config)).map(doc => {
|
|
216
|
+
const home = resolveHome(doc);
|
|
217
|
+
return { path: doc.path, status: doc.status,
|
|
218
|
+
filing: !home ? 'unfiled' : home.filedThrough === doc.path ? 'direct' : 'parent',
|
|
219
|
+
filedThrough: home?.filedThrough ?? null, rows: home?.rows ?? [] };
|
|
220
|
+
});
|
|
221
|
+
return { enabled: true, complete: complete && failures.length === 0, categoryDepth: options.categoryDepth,
|
|
222
|
+
totals: totals(plans), plans, failures };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function buildFilingCoverage(docs, config) {
|
|
226
|
+
if (!filingOptions(config.filing)) return null;
|
|
227
|
+
const rows = [], failures = [];
|
|
228
|
+
const parents = new Map(docs.map(doc => [doc.path, filingParentTarget(doc, config)]));
|
|
229
|
+
for (const hub of docs.filter(doc => filingLiveHub(doc, config))) {
|
|
230
|
+
try { rows.push(...prepareFilingHub(hub, readFileSync(path.join(config.repoRoot, hub.path), 'utf8'), config)); }
|
|
231
|
+
catch (error) { failures.push({ path: hub.path, message: error.message }); }
|
|
232
|
+
}
|
|
233
|
+
return assembleFilingCoverage(docs, config, rows, parents, { failures });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function filingFindings(report) {
|
|
237
|
+
if (!report) return [];
|
|
238
|
+
const findings = report.failures.map(failure => ({ path: failure.path, level: 'warning',
|
|
239
|
+
message: `Could not read hub for filing coverage: ${failure.message}`, meta: { kind: 'filing-read-failure' } }));
|
|
240
|
+
// Missing hubs make absence claims unreliable. Preserve evidence but do not
|
|
241
|
+
// declare missing or unique homes from an incomplete sweep.
|
|
242
|
+
if (!report.complete) return findings;
|
|
243
|
+
for (const plan of report.plans) {
|
|
244
|
+
const meta = { rows: plan.rows, filedThrough: plan.filedThrough, filing: plan.filing };
|
|
245
|
+
const via = plan.filing === 'parent' ? ` through parent \`${plan.filedThrough}\`` : '';
|
|
246
|
+
const locations = plan.rows.map(row => `${row.hub}:${row.line} [${row.categories.join(', ') || 'no category'}]`).join('; ');
|
|
247
|
+
const add = (kind, message) => findings.push({ path: plan.path, level: 'warning', message, meta: { ...meta, kind } });
|
|
248
|
+
if (!plan.rows.length) add('filing-unfiled', 'has no home in a status-bearing hub table row; links in prose or commentary are references.');
|
|
249
|
+
if (plan.rows.length > 1 || new Set(plan.rows.flatMap(row => row.categories)).size > 1) {
|
|
250
|
+
add('filing-multiple', `has multiple filing homes${via}: ${locations}`);
|
|
251
|
+
}
|
|
252
|
+
if (plan.rows.some(row => !row.categories.length)) add('filing-no-category', `is filed${via} under no category: ${locations}`);
|
|
253
|
+
}
|
|
254
|
+
return findings;
|
|
255
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { collectMembershipBackrefCandidates } from './hub-membership.mjs';
|
|
3
|
+
import { planChildParentUpdate } from './parent-plan.mjs';
|
|
4
|
+
import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
5
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
6
|
+
import { isHubDoc } from './hub.mjs';
|
|
7
|
+
import { bold, dim, green, yellow } from './color.mjs';
|
|
8
|
+
import { die } from './util.mjs';
|
|
9
|
+
|
|
10
|
+
function resolveHubArgs(args, docs) {
|
|
11
|
+
const paths = new Set();
|
|
12
|
+
for (const arg of args) {
|
|
13
|
+
const slug = arg.replace(/\.md$/, '');
|
|
14
|
+
const matches = docs.filter(doc =>
|
|
15
|
+
doc.path === arg || doc.path === `${slug}.md`
|
|
16
|
+
|| path.basename(doc.path, '.md') === path.basename(slug));
|
|
17
|
+
if (matches.length === 0) die(`No doc matches "${arg}".`);
|
|
18
|
+
if (matches.length > 1) {
|
|
19
|
+
die(`Multiple docs match "${arg}":\n${matches.map(match => ` ${match.path}`).join('\n')}`);
|
|
20
|
+
}
|
|
21
|
+
const [match] = matches;
|
|
22
|
+
if (!isHubDoc(match)) {
|
|
23
|
+
die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
|
|
24
|
+
+ 'Run `runlist fix-membership` with no argument to sweep every hub.');
|
|
25
|
+
}
|
|
26
|
+
paths.add(match.path);
|
|
27
|
+
}
|
|
28
|
+
return paths;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fixMembershipBackrefs(config, {
|
|
32
|
+
docs,
|
|
33
|
+
dryRun = false,
|
|
34
|
+
hubPaths = null,
|
|
35
|
+
quiet = false,
|
|
36
|
+
testHooks,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const planned = collectMembershipBackrefCandidates(docs, config, { hubPaths });
|
|
39
|
+
const updates = [];
|
|
40
|
+
const changes = [];
|
|
41
|
+
const skipped = [];
|
|
42
|
+
const guardByPath = new Map();
|
|
43
|
+
|
|
44
|
+
testHooks?.afterMembershipCandidates?.(planned);
|
|
45
|
+
for (const candidate of planned.candidates) {
|
|
46
|
+
if (candidate.childType !== 'plan') {
|
|
47
|
+
skipped.push({ path: candidate.childPath, hub: candidate.hubPath, reason: 'untyped-child' });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const update = planChildParentUpdate(candidate.childAbs, candidate.hubAbs, config);
|
|
51
|
+
if (update.raw !== candidate.childRaw) {
|
|
52
|
+
throw new MutationConflictError(`Child changed while membership repair was being prepared: ${candidate.childPath}`);
|
|
53
|
+
}
|
|
54
|
+
if (!update.update) {
|
|
55
|
+
skipped.push({
|
|
56
|
+
path: candidate.childPath,
|
|
57
|
+
hub: candidate.hubPath,
|
|
58
|
+
reason: update.reason,
|
|
59
|
+
...(update.existing ? { existing: update.existing } : {}),
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
updates.push(update.update);
|
|
64
|
+
guardByPath.set(candidate.hubAbs, { path: candidate.hubAbs, expectedContent: candidate.hubRaw });
|
|
65
|
+
changes.push({
|
|
66
|
+
path: candidate.childPath,
|
|
67
|
+
hub: candidate.hubPath,
|
|
68
|
+
ref: update.ref,
|
|
69
|
+
sources: candidate.sources,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ambiguous = planned.ambiguous.map(item => ({ path: item.childPath, hubs: item.hubPaths }));
|
|
74
|
+
const managed = [
|
|
75
|
+
...updates.map(update => update.path),
|
|
76
|
+
// Array.from, not `.values().map`: iterator helpers are Node 22+, and the
|
|
77
|
+
// package supports Node 20.
|
|
78
|
+
...Array.from(guardByPath.values(), guard => guard.path),
|
|
79
|
+
];
|
|
80
|
+
if (managed.length) authorizeManagedSweep(managed, config, { kind: 'Membership repair source' });
|
|
81
|
+
|
|
82
|
+
testHooks?.afterMembershipPlan?.({ changes, ambiguous, skipped });
|
|
83
|
+
if (!dryRun && updates.length > 0) {
|
|
84
|
+
mutateFileSet({ updates, guards: [...guardByPath.values()] }, { repoRoot: config.repoRoot, testHooks });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = {
|
|
88
|
+
fixed: changes.length,
|
|
89
|
+
skipped: skipped.length + ambiguous.length,
|
|
90
|
+
ambiguous: ambiguous.length,
|
|
91
|
+
changes,
|
|
92
|
+
skippedDetails: skipped,
|
|
93
|
+
ambiguousDetails: ambiguous,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (!quiet) {
|
|
97
|
+
const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
98
|
+
for (const change of changes) {
|
|
99
|
+
process.stdout.write(`${prefix}${green(dryRun ? 'Would set' : 'Set')}: ${change.path} `
|
|
100
|
+
+ `${dim(`parent_plan: ${change.ref} (stated by ${change.hub})`)}\n`);
|
|
101
|
+
}
|
|
102
|
+
for (const item of ambiguous) {
|
|
103
|
+
process.stdout.write(yellow(`Skipped ambiguous membership: ${item.path} is ranked by ${item.hubs.join(', ')}.`) + '\n');
|
|
104
|
+
}
|
|
105
|
+
for (const item of skipped) {
|
|
106
|
+
process.stdout.write(yellow(`Skipped membership repair: ${item.path} (${item.reason}).`) + '\n');
|
|
107
|
+
}
|
|
108
|
+
if (changes.length > 0) {
|
|
109
|
+
process.stdout.write(`\n${bold(`${prefix}${changes.length} membership back-reference${changes.length === 1 ? '' : 's'} ${dryRun ? 'would be repaired' : 'repaired'}.`)}\n`);
|
|
110
|
+
} else if (ambiguous.length === 0 && skipped.length === 0) {
|
|
111
|
+
process.stdout.write(green('Membership back-references are in sync.') + '\n');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function runFixMembership(argv, config, opts = {}) {
|
|
118
|
+
const { buildIndex } = await import('./index.mjs');
|
|
119
|
+
const json = argv.includes('--json');
|
|
120
|
+
const hubArgs = argv.filter(arg => !arg.startsWith('-'));
|
|
121
|
+
const docs = buildIndex(config).docs;
|
|
122
|
+
const hubPaths = hubArgs.length ? resolveHubArgs(hubArgs, docs) : null;
|
|
123
|
+
const result = fixMembershipBackrefs(config, {
|
|
124
|
+
docs,
|
|
125
|
+
dryRun: opts.dryRun,
|
|
126
|
+
hubPaths,
|
|
127
|
+
quiet: json,
|
|
128
|
+
testHooks: opts.testHooks,
|
|
129
|
+
});
|
|
130
|
+
if (json) {
|
|
131
|
+
process.stdout.write(`${JSON.stringify({ dryRun: Boolean(opts.dryRun), ...result }, null, 2)}\n`);
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
package/src/fix-refs.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
5
|
+
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
6
|
+
import { green, dim, yellow } from './color.mjs';
|
|
7
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
8
|
+
|
|
9
|
+
export function runFixRefs(argv, config, opts = {}) {
|
|
10
|
+
const { dryRun } = opts;
|
|
11
|
+
const result = fixBrokenRefs(config, { dryRun });
|
|
12
|
+
if (result.totalFixed === 0 && result.bodyLinksFixed === 0 && result.unfixableCount === 0) {
|
|
13
|
+
process.stdout.write(green('No broken references found.') + '\n');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Core logic for fixing broken references. Returns { totalFixed, unfixableCount }.
|
|
19
|
+
* Shared by `runlist fix-refs` and `runlist check --fix`.
|
|
20
|
+
*/
|
|
21
|
+
export function fixBrokenRefs(config, opts = {}) {
|
|
22
|
+
const { dryRun, quiet } = opts;
|
|
23
|
+
const index = buildIndex(config);
|
|
24
|
+
const allFiles = collectDocFiles(config);
|
|
25
|
+
authorizeManagedSweep(allFiles, config, { kind: 'Reference fix source' });
|
|
26
|
+
|
|
27
|
+
// Build a map of basename → absolute path for all docs
|
|
28
|
+
const basenameMap = new Map();
|
|
29
|
+
const duplicateBasenames = new Set();
|
|
30
|
+
for (const filePath of allFiles) {
|
|
31
|
+
const basename = path.basename(filePath);
|
|
32
|
+
if (basenameMap.has(basename)) {
|
|
33
|
+
duplicateBasenames.add(basename);
|
|
34
|
+
} else {
|
|
35
|
+
basenameMap.set(basename, filePath);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
40
|
+
let totalFixed = 0;
|
|
41
|
+
let unfixableCount = 0;
|
|
42
|
+
let bodyLinksFixed = 0;
|
|
43
|
+
|
|
44
|
+
// ── Fix broken frontmatter references ────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const brokenRefErrors = index.errors.filter(error => error.meta?.kind === 'ref-resolution');
|
|
47
|
+
|
|
48
|
+
if (brokenRefErrors.length > 0) {
|
|
49
|
+
const fixesByDoc = new Map();
|
|
50
|
+
|
|
51
|
+
for (const err of brokenRefErrors) {
|
|
52
|
+
const match = err.message.match(/entry `([^`]+)` does not resolve/);
|
|
53
|
+
if (!match) { unfixableCount++; continue; }
|
|
54
|
+
|
|
55
|
+
const brokenRef = match[1];
|
|
56
|
+
const brokenBasename = path.basename(brokenRef);
|
|
57
|
+
|
|
58
|
+
if (duplicateBasenames.has(brokenBasename)) { unfixableCount++; continue; }
|
|
59
|
+
|
|
60
|
+
const resolved = basenameMap.get(brokenBasename);
|
|
61
|
+
if (!resolved) { unfixableCount++; continue; }
|
|
62
|
+
|
|
63
|
+
const docAbsPath = path.join(config.repoRoot, err.path);
|
|
64
|
+
const docDir = path.dirname(docAbsPath);
|
|
65
|
+
const correctRelPath = path.relative(docDir, resolved).split(path.sep).join('/');
|
|
66
|
+
|
|
67
|
+
if (correctRelPath === brokenRef) { unfixableCount++; continue; }
|
|
68
|
+
|
|
69
|
+
if (!fixesByDoc.has(err.path)) fixesByDoc.set(err.path, []);
|
|
70
|
+
fixesByDoc.get(err.path).push({ brokenRef, correctRelPath });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [docPath, fixes] of fixesByDoc) {
|
|
74
|
+
const absPath = path.join(config.repoRoot, docPath);
|
|
75
|
+
let raw = readFileSync(absPath, 'utf8');
|
|
76
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
77
|
+
if (!fm) continue;
|
|
78
|
+
|
|
79
|
+
let newFm = fm;
|
|
80
|
+
for (const { brokenRef, correctRelPath } of fixes) {
|
|
81
|
+
newFm = newFm.split(brokenRef).join(correctRelPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (newFm !== fm && !dryRun) {
|
|
85
|
+
raw = replaceFrontmatter(raw, newFm);
|
|
86
|
+
writeFileSync(absPath, raw, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!quiet) {
|
|
90
|
+
process.stdout.write(`${prefix}${green('Fixed')}: ${docPath} (${fixes.length} ref${fixes.length > 1 ? 's' : ''})\n`);
|
|
91
|
+
for (const { brokenRef, correctRelPath } of fixes) {
|
|
92
|
+
process.stdout.write(`${prefix} ${dim(`${brokenRef} → ${correctRelPath}`)}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
totalFixed += fixes.length;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Fix broken body links ────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
for (const doc of index.docs) {
|
|
102
|
+
const brokenBodyErrors = doc.errors.filter(error =>
|
|
103
|
+
error.meta?.kind === 'body-link-resolution'
|
|
104
|
+
&& error.meta?.targetKind === 'document'
|
|
105
|
+
&& error.meta?.reason === 'missing'
|
|
106
|
+
);
|
|
107
|
+
if (!brokenBodyErrors.length) continue;
|
|
108
|
+
|
|
109
|
+
const absPath = path.join(config.repoRoot, doc.path);
|
|
110
|
+
let raw = readFileSync(absPath, 'utf8');
|
|
111
|
+
const { frontmatter: fm, body } = extractFrontmatter(raw);
|
|
112
|
+
let newBody = body;
|
|
113
|
+
const docDir = path.dirname(absPath);
|
|
114
|
+
const bodyFixes = [];
|
|
115
|
+
const seenBodyTargets = new Set();
|
|
116
|
+
|
|
117
|
+
for (const error of brokenBodyErrors) {
|
|
118
|
+
const brokenHref = error.meta.relPath;
|
|
119
|
+
const rawHref = error.meta.rawHref ?? brokenHref;
|
|
120
|
+
const targetKey = `${error.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
|
|
121
|
+
if (seenBodyTargets.has(targetKey)) continue;
|
|
122
|
+
seenBodyTargets.add(targetKey);
|
|
123
|
+
const brokenBasename = path.basename(brokenHref);
|
|
124
|
+
|
|
125
|
+
if (duplicateBasenames.has(brokenBasename)) continue;
|
|
126
|
+
const resolved = basenameMap.get(brokenBasename);
|
|
127
|
+
if (!resolved) continue;
|
|
128
|
+
|
|
129
|
+
const correctHref = path.relative(docDir, resolved).split(path.sep).join('/');
|
|
130
|
+
if (correctHref === brokenHref) continue;
|
|
131
|
+
|
|
132
|
+
const suffixAt = rawHref.search(/[?#]/);
|
|
133
|
+
const suffix = suffixAt === -1 ? '' : rawHref.slice(suffixAt);
|
|
134
|
+
const renderedHref = error.meta.angle
|
|
135
|
+
? `<${correctHref}${suffix}>`
|
|
136
|
+
: `${correctHref.replace(/([\s()[\]<>])/g, '\\$1')}${suffix}`;
|
|
137
|
+
const linkRegex = new RegExp(
|
|
138
|
+
'(\\]\\(\\s*)' + (error.meta.angle ? '<' : '') + escapeRegex(rawHref) + (error.meta.angle ? '>' : '') + '(?=\\s|\\))',
|
|
139
|
+
'g'
|
|
140
|
+
);
|
|
141
|
+
let replacements = 0;
|
|
142
|
+
newBody = newBody.replace(linkRegex, (_, prefix) => {
|
|
143
|
+
replacements++;
|
|
144
|
+
return `${prefix}${renderedHref}`;
|
|
145
|
+
});
|
|
146
|
+
for (let i = 0; i < replacements; i++) bodyFixes.push({ brokenHref, correctHref });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (bodyFixes.length > 0 && newBody !== body) {
|
|
150
|
+
if (!dryRun) {
|
|
151
|
+
writeFileSync(absPath, `---\n${fm}\n---\n${newBody}`, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
if (!quiet) {
|
|
154
|
+
process.stdout.write(`${prefix}${green('Fixed')}: ${doc.path} (${bodyFixes.length} body link${bodyFixes.length > 1 ? 's' : ''})\n`);
|
|
155
|
+
for (const { brokenHref, correctHref } of bodyFixes) {
|
|
156
|
+
process.stdout.write(`${prefix} ${dim(`${brokenHref} → ${correctHref}`)}\n`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
bodyLinksFixed += bodyFixes.length;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Summary ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const allFixed = totalFixed + bodyLinksFixed;
|
|
166
|
+
if (!quiet && allFixed > 0) {
|
|
167
|
+
process.stdout.write(`\n${prefix}${allFixed} fix${allFixed !== 1 ? 'es' : ''} applied.\n`);
|
|
168
|
+
}
|
|
169
|
+
if (!quiet && unfixableCount > 0) {
|
|
170
|
+
process.stdout.write(`${yellow(`${unfixableCount} broken reference(s) could not be auto-resolved`)} (file not found by basename).\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { totalFixed, unfixableCount, bodyLinksFixed };
|
|
174
|
+
}
|