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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { suggestCandidates } from './util.mjs';
|
|
4
|
+
|
|
5
|
+
function sectionHeadingRegex(sectionHeading) {
|
|
6
|
+
return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function checkGlossaryConfig(config) {
|
|
10
|
+
const glossaryConfig = config.raw?.glossary;
|
|
11
|
+
if (!glossaryConfig?.path) return [];
|
|
12
|
+
|
|
13
|
+
const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
|
|
14
|
+
if (!existsSync(filePath)) {
|
|
15
|
+
return [{
|
|
16
|
+
path: glossaryConfig.path,
|
|
17
|
+
level: 'warning',
|
|
18
|
+
message: `Glossary file configured at \`${glossaryConfig.path}\` but the file does not exist.`,
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const content = readFileSync(filePath, 'utf8');
|
|
23
|
+
const section = glossaryConfig.section ?? 'Terminology';
|
|
24
|
+
if (sectionHeadingRegex(section).test(content)) return [];
|
|
25
|
+
|
|
26
|
+
const headings = [...content.matchAll(/^##\s+(.+?)\s*$/gm)].map(m => m[1].trim());
|
|
27
|
+
const suggestions = suggestCandidates(section, headings, 3);
|
|
28
|
+
const nearby = suggestions.length ? suggestions : headings.slice(0, 3);
|
|
29
|
+
const hint = nearby.length ? ` Nearby headings: ${nearby.map(s => `\`${s}\``).join(', ')}.` : '';
|
|
30
|
+
return [{
|
|
31
|
+
path: glossaryConfig.path,
|
|
32
|
+
level: 'warning',
|
|
33
|
+
message: `Glossary config points at section \`## ${section}\`, but that heading is missing in \`${glossaryConfig.path}\`.${hint} Update \`glossary.path\` / \`glossary.section\` or restore the heading.`,
|
|
34
|
+
}];
|
|
35
|
+
}
|
package/src/glossary.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildIndex } from './index.mjs';
|
|
4
|
+
import { die, warn, suggestCandidates } from './util.mjs';
|
|
5
|
+
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
|
+
|
|
7
|
+
function sectionHeadingRegex(sectionHeading) {
|
|
8
|
+
return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseGlossaryTable(content, sectionHeading) {
|
|
12
|
+
const headingRegex = sectionHeadingRegex(sectionHeading);
|
|
13
|
+
const match = content.match(headingRegex);
|
|
14
|
+
if (!match) return { found: false, entries: [] };
|
|
15
|
+
|
|
16
|
+
const sectionStart = match.index + match[0].length;
|
|
17
|
+
const nextHeading = content.indexOf('\n## ', sectionStart);
|
|
18
|
+
const section = nextHeading > -1 ? content.slice(sectionStart, nextHeading) : content.slice(sectionStart);
|
|
19
|
+
|
|
20
|
+
const entries = [];
|
|
21
|
+
const lines = section.split('\n');
|
|
22
|
+
let headerParsed = false;
|
|
23
|
+
let columns = [];
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (!line.startsWith('|')) continue;
|
|
27
|
+
const cells = line.split('|').slice(1, -1).map(c => c.trim());
|
|
28
|
+
|
|
29
|
+
if (!headerParsed) {
|
|
30
|
+
columns = cells.map(c => c.toLowerCase().replace(/\*\*/g, ''));
|
|
31
|
+
headerParsed = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (cells.every(c => /^[-:]+$/.test(c))) continue;
|
|
36
|
+
|
|
37
|
+
const term = cells[0]?.replace(/\*\*/g, '').trim();
|
|
38
|
+
if (!term) continue;
|
|
39
|
+
|
|
40
|
+
const entry = { term };
|
|
41
|
+
for (let i = 1; i < columns.length; i++) {
|
|
42
|
+
entry[columns[i]] = cells[i] || '';
|
|
43
|
+
}
|
|
44
|
+
entries.push(entry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Schema→UI mappings
|
|
48
|
+
const mappingRegex = /^-\s+`([^`]+)`\s+→\s+"([^"]+)"\s*(?:\(([^)]+)\))?/gm;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = mappingRegex.exec(section)) !== null) {
|
|
51
|
+
entries.push({ term: m[1], meaning: `UI label: "${m[2]}"${m[3] ? ` (${m[3]})` : ''}`, tiers: 'schema→UI' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { found: true, entries };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadGlossary(config) {
|
|
58
|
+
const glossaryConfig = config.raw?.glossary;
|
|
59
|
+
if (!glossaryConfig?.path) return null;
|
|
60
|
+
|
|
61
|
+
const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
|
|
62
|
+
if (!existsSync(filePath)) {
|
|
63
|
+
warn(`Glossary file not found: ${glossaryConfig.path}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const content = readFileSync(filePath, 'utf8');
|
|
68
|
+
const section = glossaryConfig.section ?? 'Terminology';
|
|
69
|
+
const result = parseGlossaryTable(content, section);
|
|
70
|
+
return { ...result, path: glossaryConfig.path, section };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function matchTerm(query, entries) {
|
|
74
|
+
const lower = query.toLowerCase();
|
|
75
|
+
|
|
76
|
+
// Exact match first
|
|
77
|
+
const exact = entries.filter(e => e.term.toLowerCase() === lower);
|
|
78
|
+
if (exact.length > 0) return exact;
|
|
79
|
+
|
|
80
|
+
// Term starts with query
|
|
81
|
+
const startsWith = entries.filter(e => e.term.toLowerCase().startsWith(lower));
|
|
82
|
+
if (startsWith.length > 0) return startsWith;
|
|
83
|
+
|
|
84
|
+
// Substring in term
|
|
85
|
+
const termMatch = entries.filter(e => e.term.toLowerCase().includes(lower));
|
|
86
|
+
if (termMatch.length > 0) return termMatch;
|
|
87
|
+
|
|
88
|
+
// Substring in meaning (broadest)
|
|
89
|
+
return entries.filter(e => e.meaning?.toLowerCase().includes(lower));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findRelatedDocs(entry, index) {
|
|
93
|
+
const termLower = entry.term.toLowerCase();
|
|
94
|
+
// Split compound terms like "Trail / Summit / Expedition"
|
|
95
|
+
const termParts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
|
|
96
|
+
|
|
97
|
+
return index.docs.filter(d => {
|
|
98
|
+
// Module match (exact)
|
|
99
|
+
if (d.module?.toLowerCase() === termLower) return true;
|
|
100
|
+
if (d.modules?.some(m => m.toLowerCase() === termLower)) return true;
|
|
101
|
+
// Module match on term parts
|
|
102
|
+
if (termParts.some(p => d.module?.toLowerCase() === p || d.modules?.some(m => m.toLowerCase() === p))) return true;
|
|
103
|
+
// Path contains term (for terms like "example-term" that aren't modules)
|
|
104
|
+
if (termParts.some(p => p.length >= 4 && d.path.toLowerCase().includes(p))) return true;
|
|
105
|
+
// Title match
|
|
106
|
+
if (termParts.some(p => p.length >= 4 && d.title?.toLowerCase().includes(p))) return true;
|
|
107
|
+
return false;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findSeeAlso(entry, allEntries) {
|
|
112
|
+
const termLower = entry.term.toLowerCase();
|
|
113
|
+
const parts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
|
|
114
|
+
|
|
115
|
+
return allEntries.filter(other => {
|
|
116
|
+
if (other.term === entry.term) return false;
|
|
117
|
+
const otherLower = other.term.toLowerCase();
|
|
118
|
+
// Other term contains this term or vice versa
|
|
119
|
+
if (otherLower.includes(termLower) || termLower.includes(otherLower)) return true;
|
|
120
|
+
// Other meaning references this term
|
|
121
|
+
if (other.meaning?.toLowerCase().includes(termLower)) return true;
|
|
122
|
+
// Part match
|
|
123
|
+
if (parts.some(p => p.length >= 4 && (otherLower.includes(p) || other.meaning?.toLowerCase().includes(p)))) return true;
|
|
124
|
+
return false;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderEntry(entry, index, allEntries) {
|
|
129
|
+
const lines = [];
|
|
130
|
+
lines.push(`${green(bold(entry.term))}`);
|
|
131
|
+
if (entry.meaning) lines.push(` ${entry.meaning}`);
|
|
132
|
+
if (entry.tiers) lines.push(` ${dim(`Tiers: ${entry.tiers}`)}`);
|
|
133
|
+
|
|
134
|
+
const relatedDocs = findRelatedDocs(entry, index);
|
|
135
|
+
|
|
136
|
+
if (relatedDocs.length > 0) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
|
|
139
|
+
// Module entry point (the main module doc, e.g. ledger.md)
|
|
140
|
+
const entryPoint = relatedDocs.find(d =>
|
|
141
|
+
d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
|
|
142
|
+
);
|
|
143
|
+
if (entryPoint) {
|
|
144
|
+
lines.push(` ${bold('Entry point:')} ${entryPoint.path}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Other module docs (count, not list)
|
|
148
|
+
const moduleDocs = relatedDocs.filter(d => d.root?.includes('modules') && d !== entryPoint);
|
|
149
|
+
if (moduleDocs.length > 0) {
|
|
150
|
+
lines.push(` ${bold('Module docs:')} ${moduleDocs.length} files in ${dim(path.dirname(moduleDocs[0].path))}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Plans grouped by status
|
|
154
|
+
const planGroups = [
|
|
155
|
+
['Active', 'active'],
|
|
156
|
+
['Paused', 'paused'],
|
|
157
|
+
['Ready', 'ready'],
|
|
158
|
+
['Planned', 'planned'],
|
|
159
|
+
['Blocked', 'blocked'],
|
|
160
|
+
['Research', 'research'],
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
for (const [label, status] of planGroups) {
|
|
164
|
+
const plans = relatedDocs.filter(d => d.type === 'plan' && d.status === status);
|
|
165
|
+
if (plans.length === 0) continue;
|
|
166
|
+
lines.push(` ${bold(`${label}:`)} ${plans.map(d => path.basename(d.path, '.md')).join(', ')}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// See also: related glossary terms
|
|
171
|
+
const seeAlso = findSeeAlso(entry, allEntries);
|
|
172
|
+
if (seeAlso.length > 0) {
|
|
173
|
+
lines.push(` ${dim('See also:')} ${seeAlso.map(e => e.term).join(', ')}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push('');
|
|
177
|
+
return lines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function runGlossary(argv, config) {
|
|
181
|
+
const json = argv.includes('--json');
|
|
182
|
+
const listAll = argv.includes('--list');
|
|
183
|
+
const term = argv.find(a => !a.startsWith('-'));
|
|
184
|
+
|
|
185
|
+
const result = loadGlossary(config);
|
|
186
|
+
if (!result) die('No glossary configured. Add glossary: { path, section } to your runlist config.');
|
|
187
|
+
if (!result.found) {
|
|
188
|
+
die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in runlist.config.mjs.`);
|
|
189
|
+
}
|
|
190
|
+
if (result.entries.length === 0) {
|
|
191
|
+
die(`Glossary section "## ${result.section}" found in ${result.path} but contains no recognizable entries (expected markdown table or schema→UI bullets).`);
|
|
192
|
+
}
|
|
193
|
+
const entries = result.entries;
|
|
194
|
+
|
|
195
|
+
if (json && listAll) {
|
|
196
|
+
const index = buildIndex(config);
|
|
197
|
+
const enriched = entries.map(entry => ({
|
|
198
|
+
...entry,
|
|
199
|
+
relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
|
|
200
|
+
seeAlso: findSeeAlso(entry, entries).map(e => e.term),
|
|
201
|
+
}));
|
|
202
|
+
process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (listAll) {
|
|
207
|
+
process.stdout.write(bold('Glossary') + dim(` (${entries.length} terms)`) + '\n\n');
|
|
208
|
+
const maxTerm = Math.max(...entries.map(e => e.term.length));
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
process.stdout.write(` ${green(entry.term.padEnd(maxTerm + 2))} ${entry.meaning || ''}\n`);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!term) die('Usage: runlist glossary <term> | --list | --json');
|
|
216
|
+
|
|
217
|
+
const matches = matchTerm(term, entries);
|
|
218
|
+
|
|
219
|
+
if (json) {
|
|
220
|
+
const index = buildIndex(config);
|
|
221
|
+
const enriched = matches.map(entry => ({
|
|
222
|
+
...entry,
|
|
223
|
+
relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
|
|
224
|
+
seeAlso: findSeeAlso(entry, entries).map(e => e.term),
|
|
225
|
+
}));
|
|
226
|
+
process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (matches.length === 0) {
|
|
231
|
+
const suggestions = suggestCandidates(term, entries.map(e => e.term));
|
|
232
|
+
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
233
|
+
process.stdout.write(dim(`No glossary match for "${term}".${hint}`) + '\n');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const index = buildIndex(config);
|
|
238
|
+
for (const entry of matches) {
|
|
239
|
+
process.stdout.write(renderEntry(entry, index, entries));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { loadGlossary };
|
package/src/graph.mjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { toSlug, toRepoPath, warn, resolveRefPath } from './util.mjs';
|
|
3
|
+
import { bold, red, green, dim } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
const STATUS_COLORS = {
|
|
6
|
+
active: '#b3e6b3',
|
|
7
|
+
ready: '#b3d9ff',
|
|
8
|
+
planned: '#ffffb3',
|
|
9
|
+
research: '#e6ccff',
|
|
10
|
+
blocked: '#ffb3b3',
|
|
11
|
+
reference: '#d9d9d9',
|
|
12
|
+
archived: '#e6e6e6',
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_COLOR = '#f2f2f2';
|
|
15
|
+
|
|
16
|
+
function tupleSetHas(root, values) {
|
|
17
|
+
let current = root;
|
|
18
|
+
for (let i = 0; i < values.length - 1; i++) {
|
|
19
|
+
current = current.get(values[i]);
|
|
20
|
+
if (!current) return false;
|
|
21
|
+
}
|
|
22
|
+
return current.has(values.at(-1));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tupleSetAdd(root, values) {
|
|
26
|
+
let current = root;
|
|
27
|
+
for (let i = 0; i < values.length - 1; i++) {
|
|
28
|
+
if (!current.has(values[i])) current.set(values[i], new Map());
|
|
29
|
+
current = current.get(values[i]);
|
|
30
|
+
}
|
|
31
|
+
current.set(values.at(-1), true);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildGraph(index, config, filters = {}) {
|
|
35
|
+
const biFields = new Set(config.referenceFields.bidirectional || []);
|
|
36
|
+
const uniFields = new Set(config.referenceFields.unidirectional || []);
|
|
37
|
+
const allRefFields = [...biFields, ...uniFields];
|
|
38
|
+
|
|
39
|
+
// Filter docs
|
|
40
|
+
let docs = index.docs;
|
|
41
|
+
if (filters.statuses?.length) {
|
|
42
|
+
docs = docs.filter(d => filters.statuses.includes(d.status));
|
|
43
|
+
}
|
|
44
|
+
if (filters.module) {
|
|
45
|
+
const m = filters.module.toLowerCase();
|
|
46
|
+
docs = docs.filter(d => (d.modules ?? []).some(mod => mod.toLowerCase() === m) || (d.module ?? '').toLowerCase() === m);
|
|
47
|
+
}
|
|
48
|
+
if (filters.surface) {
|
|
49
|
+
const s = filters.surface.toLowerCase();
|
|
50
|
+
docs = docs.filter(d => (d.surfaces ?? []).some(sf => sf.toLowerCase() === s) || (d.surface ?? '').toLowerCase() === s);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const docPathSet = new Set(docs.map(d => d.path));
|
|
54
|
+
const allDocPaths = new Set(index.docs.map(d => d.path));
|
|
55
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
56
|
+
|
|
57
|
+
// Build nodes
|
|
58
|
+
const nodes = docs.map(d => ({
|
|
59
|
+
id: d.path,
|
|
60
|
+
slug: toSlug(d),
|
|
61
|
+
title: d.title,
|
|
62
|
+
status: d.status,
|
|
63
|
+
module: d.module,
|
|
64
|
+
modules: d.modules,
|
|
65
|
+
surface: d.surface,
|
|
66
|
+
surfaces: d.surfaces,
|
|
67
|
+
edgeCount: 0,
|
|
68
|
+
}));
|
|
69
|
+
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
70
|
+
|
|
71
|
+
// Build edges
|
|
72
|
+
const edges = [];
|
|
73
|
+
const edgeKeys = new Map();
|
|
74
|
+
const referencedPaths = new Set();
|
|
75
|
+
|
|
76
|
+
for (const doc of docs) {
|
|
77
|
+
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
78
|
+
|
|
79
|
+
for (const field of allRefFields) {
|
|
80
|
+
for (const relPath of (doc.refFields[field] || [])) {
|
|
81
|
+
const resolved = resolveRefPath(relPath, docDir, config.repoRoot) ?? path.resolve(docDir, relPath);
|
|
82
|
+
const targetPath = toRepoPath(resolved, config.repoRoot);
|
|
83
|
+
const edgeKey = [doc.path, targetPath, field];
|
|
84
|
+
if (tupleSetHas(edgeKeys, edgeKey)) continue;
|
|
85
|
+
tupleSetAdd(edgeKeys, edgeKey);
|
|
86
|
+
|
|
87
|
+
const broken = !allDocPaths.has(targetPath);
|
|
88
|
+
const external = !broken && !docPathSet.has(targetPath);
|
|
89
|
+
|
|
90
|
+
edges.push({
|
|
91
|
+
source: doc.path,
|
|
92
|
+
target: targetPath,
|
|
93
|
+
field,
|
|
94
|
+
type: biFields.has(field) ? 'bidirectional' : 'unidirectional',
|
|
95
|
+
broken,
|
|
96
|
+
external,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
referencedPaths.add(targetPath);
|
|
100
|
+
referencedPaths.add(doc.path);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Count edges per node
|
|
106
|
+
for (const edge of edges) {
|
|
107
|
+
if (nodeMap.has(edge.source)) nodeMap.get(edge.source).edgeCount++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Find orphans (no outgoing or incoming edges among filtered docs)
|
|
111
|
+
const connectedPaths = new Set();
|
|
112
|
+
for (const edge of edges) {
|
|
113
|
+
connectedPaths.add(edge.source);
|
|
114
|
+
if (!edge.broken && !edge.external) connectedPaths.add(edge.target);
|
|
115
|
+
}
|
|
116
|
+
const orphans = docs.filter(d => !connectedPaths.has(d.path)).map(d => d.path);
|
|
117
|
+
|
|
118
|
+
const brokenEdges = edges.filter(e => e.broken);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
nodes,
|
|
122
|
+
edges,
|
|
123
|
+
orphans,
|
|
124
|
+
brokenEdges,
|
|
125
|
+
stats: {
|
|
126
|
+
nodeCount: nodes.length,
|
|
127
|
+
edgeCount: edges.length,
|
|
128
|
+
orphanCount: orphans.length,
|
|
129
|
+
brokenEdgeCount: brokenEdges.length,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Text renderer ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export function renderGraphText(graph, config) {
|
|
137
|
+
const defaultRenderer = (g) => _renderGraphText(g, config);
|
|
138
|
+
if (!config._execution?.suppressSideEffects && config.hooks.renderGraph) {
|
|
139
|
+
try { return config.hooks.renderGraph(graph, defaultRenderer); }
|
|
140
|
+
catch (err) { warn(`Hook 'renderGraph' threw: ${err.message}`); }
|
|
141
|
+
}
|
|
142
|
+
return defaultRenderer(graph);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _renderGraphText(graph, config) {
|
|
146
|
+
const { nodes, edges, orphans, stats } = graph;
|
|
147
|
+
|
|
148
|
+
if (stats.nodeCount === 0) return 'No documents found.\n';
|
|
149
|
+
|
|
150
|
+
const allRefFields = [
|
|
151
|
+
...(config.referenceFields.bidirectional || []),
|
|
152
|
+
...(config.referenceFields.unidirectional || []),
|
|
153
|
+
];
|
|
154
|
+
if (allRefFields.length === 0) {
|
|
155
|
+
return `Graph — ${stats.nodeCount} docs, no reference fields configured\n\nAdd \`referenceFields\` to your config to enable relationship tracking.\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const lines = [];
|
|
159
|
+
const parts = [`${stats.nodeCount} docs`, `${stats.edgeCount} edges`];
|
|
160
|
+
if (stats.orphanCount > 0) parts.push(`${stats.orphanCount} orphans`);
|
|
161
|
+
if (stats.brokenEdgeCount > 0) parts.push(`${stats.brokenEdgeCount} broken`);
|
|
162
|
+
lines.push(bold(`Graph`) + dim(` — ${parts.join(', ')}`));
|
|
163
|
+
lines.push('');
|
|
164
|
+
|
|
165
|
+
// Compute max field name length for alignment
|
|
166
|
+
const fieldNames = [...new Set(edges.map(e => e.field))];
|
|
167
|
+
const maxFieldLen = fieldNames.length > 0 ? Math.max(...fieldNames.map(f => f.length)) : 0;
|
|
168
|
+
|
|
169
|
+
// Group edges by source
|
|
170
|
+
const edgesBySource = new Map();
|
|
171
|
+
for (const edge of edges) {
|
|
172
|
+
if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []);
|
|
173
|
+
edgesBySource.get(edge.source).push(edge);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Build a slug lookup for targets
|
|
177
|
+
const allDocByPath = new Map();
|
|
178
|
+
for (const n of nodes) allDocByPath.set(n.id, n.slug);
|
|
179
|
+
|
|
180
|
+
// Render each node with edges
|
|
181
|
+
const nodesWithEdges = nodes.filter(n => edgesBySource.has(n.id));
|
|
182
|
+
const nodesWithoutEdges = nodes.filter(n => !edgesBySource.has(n.id) && !orphans.includes(n.id));
|
|
183
|
+
|
|
184
|
+
for (const node of nodesWithEdges) {
|
|
185
|
+
lines.push(`${node.slug} ${dim(`(${node.status})`)}`);
|
|
186
|
+
for (const edge of edgesBySource.get(node.id)) {
|
|
187
|
+
const targetSlug = allDocByPath.get(edge.target) ?? path.basename(edge.target, '.md');
|
|
188
|
+
const fieldPad = edge.field.padEnd(maxFieldLen);
|
|
189
|
+
let line = ` ${'──'} ${fieldPad} ${'──'} ${targetSlug}`;
|
|
190
|
+
if (edge.broken) line += ' ' + red('[broken]');
|
|
191
|
+
if (edge.external) line += ' ' + dim('[external]');
|
|
192
|
+
lines.push(line);
|
|
193
|
+
}
|
|
194
|
+
lines.push('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (orphans.length > 0) {
|
|
198
|
+
const orphanSlugs = orphans.map(p => path.basename(p, '.md'));
|
|
199
|
+
lines.push(`${dim('Orphans')}: ${orphanSlugs.join(', ')}`);
|
|
200
|
+
lines.push('');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── DOT renderer ───────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export function renderGraphDot(graph, config) {
|
|
209
|
+
const { nodes, edges } = graph;
|
|
210
|
+
const lines = [];
|
|
211
|
+
lines.push('digraph runlist {');
|
|
212
|
+
lines.push(' rankdir=LR;');
|
|
213
|
+
lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
|
|
214
|
+
lines.push('');
|
|
215
|
+
|
|
216
|
+
// Nodes
|
|
217
|
+
const nodeSet = new Set(nodes.map(n => n.id));
|
|
218
|
+
for (const node of nodes) {
|
|
219
|
+
const color = STATUS_COLORS[node.status] ?? DEFAULT_COLOR;
|
|
220
|
+
lines.push(` ${dotString(node.id)} [label=${dotString(`${node.slug}\n(${node.status ?? 'unknown'})`)}, fillcolor=${dotString(color)}];`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Synthesize broken/external target nodes
|
|
224
|
+
const syntheticNodes = new Set();
|
|
225
|
+
for (const edge of edges) {
|
|
226
|
+
const targetSlug = path.basename(edge.target, '.md');
|
|
227
|
+
if (!nodeSet.has(edge.target) && !syntheticNodes.has(edge.target)) {
|
|
228
|
+
syntheticNodes.add(edge.target);
|
|
229
|
+
if (edge.broken) {
|
|
230
|
+
lines.push(` ${dotString(edge.target)} [label=${dotString(`${targetSlug}\n(unknown)`)}, style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
|
|
231
|
+
} else if (edge.external) {
|
|
232
|
+
lines.push(` ${dotString(edge.target)} [label=${dotString(`${targetSlug}\n(filtered)`)}, style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lines.push('');
|
|
238
|
+
|
|
239
|
+
// Detect mutual bidirectional edges for dir=both rendering
|
|
240
|
+
const biEdgeIndex = new Map();
|
|
241
|
+
for (const edge of edges) {
|
|
242
|
+
if (edge.type !== 'bidirectional') continue;
|
|
243
|
+
tupleSetAdd(biEdgeIndex, [edge.source, edge.target, edge.field]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const rendered = new Map();
|
|
247
|
+
for (const edge of edges) {
|
|
248
|
+
const [first, second] = edge.source.localeCompare(edge.target) <= 0
|
|
249
|
+
? [edge.source, edge.target]
|
|
250
|
+
: [edge.target, edge.source];
|
|
251
|
+
const edgeKey = [first, second, edge.field];
|
|
252
|
+
|
|
253
|
+
if (tupleSetHas(rendered, edgeKey)) continue;
|
|
254
|
+
tupleSetAdd(rendered, edgeKey);
|
|
255
|
+
|
|
256
|
+
if (edge.broken) {
|
|
257
|
+
lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [style=dashed, color=red, label=${dotString(edge.field)}];`);
|
|
258
|
+
} else if (edge.type === 'bidirectional') {
|
|
259
|
+
// Check if reverse edge exists
|
|
260
|
+
if (tupleSetHas(biEdgeIndex, [edge.target, edge.source, edge.field])) {
|
|
261
|
+
lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [dir=both, label=${dotString(edge.field)}, color="#666666"];`);
|
|
262
|
+
} else {
|
|
263
|
+
lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [label=${dotString(edge.field)}, color="#666666"];`);
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
const style = edge.external ? ', style=dashed' : '';
|
|
267
|
+
lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [label=${dotString(edge.field)}, color="#999999"${style}];`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
lines.push('}');
|
|
272
|
+
return lines.join('\n') + '\n';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function dotString(value) {
|
|
276
|
+
const escaped = String(value).replace(/[\\"\r\n\x00-\x1f\x7f]/g, character => {
|
|
277
|
+
if (character === '\\') return '\\\\';
|
|
278
|
+
if (character === '"') return '\\"';
|
|
279
|
+
if (character === '\r') return '\\r';
|
|
280
|
+
if (character === '\n') return '\\n';
|
|
281
|
+
return `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
|
|
282
|
+
});
|
|
283
|
+
return `"${escaped}"`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── JSON renderer ──────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
export function renderGraphJson(graph) {
|
|
289
|
+
return JSON.stringify({
|
|
290
|
+
generatedAt: new Date().toISOString(),
|
|
291
|
+
stats: graph.stats,
|
|
292
|
+
nodes: graph.nodes,
|
|
293
|
+
edges: graph.edges.map(({ source, target, field, type, broken }) => ({
|
|
294
|
+
source, target, field, type, broken,
|
|
295
|
+
})),
|
|
296
|
+
orphans: graph.orphans,
|
|
297
|
+
}, null, 2) + '\n';
|
|
298
|
+
}
|