tldr-experts 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +913 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/hooks/answer-capture.js +174 -0
- package/dist/hooks/budget-gate.js +173 -0
- package/dist/hooks/chunk-0bt6yb2q.js +88 -0
- package/dist/hooks/chunk-1zwcxd3f.js +66 -0
- package/dist/hooks/chunk-9gb21660.js +66 -0
- package/dist/hooks/chunk-a8p2rc94.js +20 -0
- package/dist/hooks/chunk-ae6bkfs5.js +0 -0
- package/dist/hooks/chunk-azctppjh.js +198 -0
- package/dist/hooks/chunk-g395gk7e.js +503 -0
- package/dist/hooks/chunk-j234zf0t.js +339 -0
- package/dist/hooks/chunk-kw4tffzf.js +139 -0
- package/dist/hooks/chunk-p274ckxv.js +7435 -0
- package/dist/hooks/chunk-sdjnnmzz.js +497 -0
- package/dist/hooks/chunk-t56k6146.js +14 -0
- package/dist/hooks/chunk-t8tdv11p.js +35 -0
- package/dist/hooks/chunk-x98qs959.js +302 -0
- package/dist/hooks/chunk-y0jdr3et.js +627 -0
- package/dist/hooks/claim-sources.js +72 -0
- package/dist/hooks/dod-gate.js +198 -0
- package/dist/hooks/no-reask.js +67 -0
- package/dist/hooks/session-start.js +1348 -0
- package/dist/hooks/statusline.js +116 -0
- package/dist/tldrx.js +34219 -0
- package/env.yml +79 -0
- package/package.json +60 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/README.md +113 -0
- package/plugin/agents/README.md +22 -0
- package/plugin/hooks/hooks.json +107 -0
- package/plugin/skills/tldrx/SKILL.md +170 -0
- package/stages/build/stage.md +70 -0
- package/stages/build/stage.yml +54 -0
- package/stages/how/stage.md +89 -0
- package/stages/how/stage.yml +53 -0
- package/stages/plan/stage.md +89 -0
- package/stages/plan/stage.yml +48 -0
- package/stages/watch/stage.md +85 -0
- package/stages/watch/stage.yml +61 -0
- package/stages/what/stage.md +89 -0
- package/stages/what/stage.yml +61 -0
- package/templates/budget.yml +17 -0
- package/templates/competencies.yml +14 -0
- package/templates/env.yml +19 -0
- package/templates/epic.md +38 -0
- package/templates/expert.md +51 -0
- package/templates/experts/architect.md +77 -0
- package/templates/experts/delivery.md +76 -0
- package/templates/experts/developer.md +78 -0
- package/templates/experts/operations.md +74 -0
- package/templates/experts/product.md +78 -0
- package/templates/facts.yml +18 -0
- package/templates/handoff.md +63 -0
- package/templates/process.yml +33 -0
- package/templates/questions.md +63 -0
- package/templates/run.yml +24 -0
- package/templates/story.md +55 -0
- package/templates/watcher.md +68 -0
- package/templates/waves.yml +17 -0
- package/templates/workspace.yml +30 -0
- package/workflows/bugfix.yml +26 -0
- package/workflows/docs.yml +23 -0
- package/workflows/feature.yml +22 -0
- package/workflows/hotfix.yml +24 -0
- package/workflows/integration.yml +24 -0
- package/workflows/migration.yml +24 -0
- package/workflows/performance.yml +23 -0
- package/workflows/prototype.yml +24 -0
- package/workflows/refactor.yml +24 -0
- package/workflows/retro.yml +20 -0
- package/workflows/security-patch.yml +23 -0
- package/workflows/spike.yml +23 -0
- package/workflows/upgrade.yml +23 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROJECT_FRAMEWORK_DIR,
|
|
3
|
+
parseYaml
|
|
4
|
+
} from "./chunk-p274ckxv.js";
|
|
5
|
+
|
|
6
|
+
// src/core/text/srcToken.ts
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
9
|
+
var LINE_RE = /^\d{1,9}$/;
|
|
10
|
+
var LINE_RANGE_RE = /^(\d{1,9})-(\d{1,9})$/;
|
|
11
|
+
var ANSWER_RE = /^Q\d{1,6}$/;
|
|
12
|
+
var FACT_RE = /^F\d{3,6}$/;
|
|
13
|
+
var CMD_RE = /^\$ (.+) → exit (\d{1,3})$/;
|
|
14
|
+
var TRAILING_TOKEN_RE = /\[src: ([^\]]*)\]$/;
|
|
15
|
+
var TRAILING_CLOSER_RE = /[`'"’”»)\s.,;:!?]+$/;
|
|
16
|
+
var SRC_MARKER = "[src:";
|
|
17
|
+
var AIDLC_LINE_RE = /^(.+):(\d{1,9})$/;
|
|
18
|
+
var AIDLC_Q_RE = /^(.+)#(Q\d{1,6})$/;
|
|
19
|
+
function parseSrcToken(line, repos) {
|
|
20
|
+
const trimmed = trimTrailingClosers(line);
|
|
21
|
+
const match = TRAILING_TOKEN_RE.exec(trimmed);
|
|
22
|
+
if (match === null)
|
|
23
|
+
return null;
|
|
24
|
+
const inner = match[1] ?? "";
|
|
25
|
+
const refs = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
for (const piece of inner.split("; ")) {
|
|
28
|
+
const parsed = classifySrc(piece, repos);
|
|
29
|
+
if ("message" in parsed)
|
|
30
|
+
errors.push(parsed);
|
|
31
|
+
else
|
|
32
|
+
refs.push(parsed);
|
|
33
|
+
}
|
|
34
|
+
if (inner.trim() === "") {
|
|
35
|
+
return { raw: match[0], refs: [], errors: [{ raw: "", message: "empty [src: ] token" }] };
|
|
36
|
+
}
|
|
37
|
+
return { raw: match[0], refs, errors };
|
|
38
|
+
}
|
|
39
|
+
function trimTrailingClosers(line) {
|
|
40
|
+
const trimmed = line.replace(/\s+$/, "");
|
|
41
|
+
if (trimmed.endsWith("]"))
|
|
42
|
+
return trimmed;
|
|
43
|
+
const stripped = trimmed.replace(TRAILING_CLOSER_RE, "");
|
|
44
|
+
return stripped.endsWith("]") ? stripped : trimmed;
|
|
45
|
+
}
|
|
46
|
+
function hasSrcMarker(line) {
|
|
47
|
+
return line.includes(SRC_MARKER);
|
|
48
|
+
}
|
|
49
|
+
function classifySrc(src, repos) {
|
|
50
|
+
const raw = src;
|
|
51
|
+
if (raw === "")
|
|
52
|
+
return { raw, message: "empty source" };
|
|
53
|
+
if (raw.startsWith("https://")) {
|
|
54
|
+
if (raw.length <= "https://".length || /\s/.test(raw)) {
|
|
55
|
+
return { raw, message: "`doc` must be https:// followed by a non-space URL" };
|
|
56
|
+
}
|
|
57
|
+
return { kind: "doc", raw, url: raw };
|
|
58
|
+
}
|
|
59
|
+
if (raw.startsWith("http://")) {
|
|
60
|
+
return { raw, message: "`doc` sources must be https:// — http:// is rejected" };
|
|
61
|
+
}
|
|
62
|
+
if (raw.startsWith("$ ")) {
|
|
63
|
+
const m = CMD_RE.exec(raw);
|
|
64
|
+
if (m === null || m[1] === undefined || m[2] === undefined) {
|
|
65
|
+
return { raw, message: "`cmd` must read `$ <command> → exit <n>`" };
|
|
66
|
+
}
|
|
67
|
+
return { kind: "cmd", raw, command: m[1], exitCode: Number(m[2]) };
|
|
68
|
+
}
|
|
69
|
+
if (raw.startsWith("graph:")) {
|
|
70
|
+
const node = raw.slice("graph:".length);
|
|
71
|
+
if (node === "" || /\s/.test(node))
|
|
72
|
+
return { raw, message: "`graph:` needs a non-empty node id" };
|
|
73
|
+
return { kind: "graph", raw, node };
|
|
74
|
+
}
|
|
75
|
+
if (raw.startsWith("aidlc:"))
|
|
76
|
+
return parseAidlcSrc(raw);
|
|
77
|
+
if (raw.startsWith("absent:")) {
|
|
78
|
+
const path = raw.slice("absent:".length);
|
|
79
|
+
if (path === "")
|
|
80
|
+
return { raw, message: "`absent:` needs a path" };
|
|
81
|
+
return { kind: "absent", raw, path };
|
|
82
|
+
}
|
|
83
|
+
if (ANSWER_RE.test(raw))
|
|
84
|
+
return { kind: "answer", raw, q: raw };
|
|
85
|
+
if (FACT_RE.test(raw))
|
|
86
|
+
return { kind: "fact", raw, id: raw };
|
|
87
|
+
if (raw.startsWith("Q") || raw.startsWith("F")) {
|
|
88
|
+
if (!raw.includes(":")) {
|
|
89
|
+
return { raw, message: "expected `Q<n>` or `F<nnn>`" };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return parseFileSrc(raw, repos);
|
|
93
|
+
}
|
|
94
|
+
function parseAidlcSrc(raw) {
|
|
95
|
+
const rest = raw.slice("aidlc:".length);
|
|
96
|
+
if (rest === "")
|
|
97
|
+
return { raw, message: "`aidlc:` needs a path" };
|
|
98
|
+
if (rest.includes(".."))
|
|
99
|
+
return { raw, message: "`..` is not allowed in a source path" };
|
|
100
|
+
const question = AIDLC_Q_RE.exec(rest);
|
|
101
|
+
if (question !== null && question[1] !== undefined && question[2] !== undefined) {
|
|
102
|
+
return { kind: "aidlc", raw, path: question[1], line: null, q: question[2] };
|
|
103
|
+
}
|
|
104
|
+
const located = AIDLC_LINE_RE.exec(rest);
|
|
105
|
+
if (located !== null && located[1] !== undefined && located[2] !== undefined) {
|
|
106
|
+
const line = Number(located[2]);
|
|
107
|
+
if (line < 1)
|
|
108
|
+
return { raw, message: "line numbers are 1-based" };
|
|
109
|
+
return { kind: "aidlc", raw, path: located[1], line, q: null };
|
|
110
|
+
}
|
|
111
|
+
return { raw, message: "expected `aidlc:<file>:<line>` or `aidlc:<file>#Q<n>`" };
|
|
112
|
+
}
|
|
113
|
+
function parseFileSrc(raw, repos) {
|
|
114
|
+
const lastColon = raw.lastIndexOf(":");
|
|
115
|
+
if (lastColon <= 0) {
|
|
116
|
+
return { raw, message: "expected `[repo:]path:line[-line]`" };
|
|
117
|
+
}
|
|
118
|
+
const lineSpec = raw.slice(lastColon + 1);
|
|
119
|
+
const head = raw.slice(0, lastColon);
|
|
120
|
+
let startLine;
|
|
121
|
+
let endLine = null;
|
|
122
|
+
if (LINE_RE.test(lineSpec)) {
|
|
123
|
+
startLine = Number(lineSpec);
|
|
124
|
+
} else {
|
|
125
|
+
const range = LINE_RANGE_RE.exec(lineSpec);
|
|
126
|
+
if (range === null || range[1] === undefined || range[2] === undefined) {
|
|
127
|
+
return { raw, message: "expected a line number or `line-line` range after the last `:`" };
|
|
128
|
+
}
|
|
129
|
+
startLine = Number(range[1]);
|
|
130
|
+
endLine = Number(range[2]);
|
|
131
|
+
if (endLine < startLine)
|
|
132
|
+
return { raw, message: "line range ends before it starts" };
|
|
133
|
+
}
|
|
134
|
+
if (startLine < 1)
|
|
135
|
+
return { raw, message: "line numbers are 1-based" };
|
|
136
|
+
let repo = null;
|
|
137
|
+
let path = head;
|
|
138
|
+
const firstColon = head.indexOf(":");
|
|
139
|
+
if (firstColon > 0) {
|
|
140
|
+
const candidate = head.slice(0, firstColon);
|
|
141
|
+
if (/^[a-z0-9][a-z0-9-]{0,31}$/.test(candidate) && (repos === undefined || repos.has(candidate))) {
|
|
142
|
+
repo = candidate;
|
|
143
|
+
path = head.slice(firstColon + 1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (path === "")
|
|
147
|
+
return { raw, message: "empty path" };
|
|
148
|
+
if (path.includes(".."))
|
|
149
|
+
return { raw, message: "`..` is not allowed in a source path" };
|
|
150
|
+
return { kind: "file", raw, repo, path, startLine, endLine };
|
|
151
|
+
}
|
|
152
|
+
var lineCountCache = new Map;
|
|
153
|
+
var OK = { ok: true, outcome: "ok" };
|
|
154
|
+
function refused(message, resolved) {
|
|
155
|
+
return resolved === undefined ? { ok: false, outcome: "refused", message } : { ok: false, outcome: "refused", message, resolved };
|
|
156
|
+
}
|
|
157
|
+
function unverified(message) {
|
|
158
|
+
return { ok: true, outcome: "unverified", message };
|
|
159
|
+
}
|
|
160
|
+
function resolveSrc(ref, ctx, section, claim = "") {
|
|
161
|
+
switch (ref.kind) {
|
|
162
|
+
case "file": {
|
|
163
|
+
if (ref.repo !== null && !ctx.repos.has(ref.repo)) {
|
|
164
|
+
return refused(`unknown repo \`${ref.repo}\` (not in workspace.yml)`);
|
|
165
|
+
}
|
|
166
|
+
const bases = fileBases(ref, ctx);
|
|
167
|
+
for (const base of bases) {
|
|
168
|
+
if (!existsSync(base.abs) || !statSync(base.abs).isFile())
|
|
169
|
+
continue;
|
|
170
|
+
const lines = countLines(base.abs);
|
|
171
|
+
const highest = ref.endLine ?? ref.startLine;
|
|
172
|
+
if (highest > lines) {
|
|
173
|
+
return refused(`${ref.path} has ${lines} line(s); cited line ${highest}`, base.abs);
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, outcome: "ok", resolved: base.abs };
|
|
176
|
+
}
|
|
177
|
+
const tried = bases.map((b) => b.label).join(", ");
|
|
178
|
+
return refused(`no such file: ${ref.path} — tried ${tried}`, bases[0]?.abs);
|
|
179
|
+
}
|
|
180
|
+
case "cmd": {
|
|
181
|
+
if (section !== "Evidence ledger") {
|
|
182
|
+
return refused("`$ … → exit n` sources are only allowed in the Evidence ledger");
|
|
183
|
+
}
|
|
184
|
+
if (ctx.commands.size === 0) {
|
|
185
|
+
return unverified("no workspace.yml commands to check `$ … → exit n` against");
|
|
186
|
+
}
|
|
187
|
+
if (!ctx.commands.has(ref.command)) {
|
|
188
|
+
return refused(`command \`${ref.command}\` is not one of workspace.yml's commands`);
|
|
189
|
+
}
|
|
190
|
+
return OK;
|
|
191
|
+
}
|
|
192
|
+
case "fact":
|
|
193
|
+
return resolveFact(ref.id, ctx);
|
|
194
|
+
case "answer":
|
|
195
|
+
return resolveAnswer(ref.q, ctx);
|
|
196
|
+
case "graph":
|
|
197
|
+
return resolveGraph(ref.node, ctx);
|
|
198
|
+
case "doc":
|
|
199
|
+
return resolveDoc(ref.url, ctx);
|
|
200
|
+
case "absent":
|
|
201
|
+
return resolveAbsent(ref.path, ctx, section, claim);
|
|
202
|
+
case "aidlc":
|
|
203
|
+
return OK;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function resolveFact(id, ctx) {
|
|
207
|
+
const facts = loadFacts(ctx.root);
|
|
208
|
+
if (facts === null) {
|
|
209
|
+
return unverified(`no ${PROJECT_FRAMEWORK_DIR}/memory/facts.yml to check ${id} against`);
|
|
210
|
+
}
|
|
211
|
+
if (facts.retired.has(id)) {
|
|
212
|
+
return refused(`${id} is RETIRED in facts.yml — a retired fact is not evidence`);
|
|
213
|
+
}
|
|
214
|
+
if (!facts.live.has(id)) {
|
|
215
|
+
const known = facts.live.size === 0 ? "it has no live facts" : `it has ${String(facts.live.size)} live fact(s)`;
|
|
216
|
+
return refused(`no such fact ${id} in ${PROJECT_FRAMEWORK_DIR}/memory/facts.yml — ${known}`);
|
|
217
|
+
}
|
|
218
|
+
return OK;
|
|
219
|
+
}
|
|
220
|
+
function resolveAnswer(q, ctx) {
|
|
221
|
+
const runDir = ctx.runDir ?? null;
|
|
222
|
+
if (runDir === null || runDir === "") {
|
|
223
|
+
return unverified(`no run directory in context to look ${q} up in`);
|
|
224
|
+
}
|
|
225
|
+
const ids = loadQuestionIds(runDir);
|
|
226
|
+
if (ids === null)
|
|
227
|
+
return refused(`no questions.md anywhere in this run — ${q} cites a question nobody asked`);
|
|
228
|
+
if (!ids.has(q)) {
|
|
229
|
+
const known = ids.size === 0 ? "none are declared" : `declared: ${[...ids].sort().join(", ")}`;
|
|
230
|
+
return refused(`no such question ${q} in this run's questions.md — ${known}`);
|
|
231
|
+
}
|
|
232
|
+
return OK;
|
|
233
|
+
}
|
|
234
|
+
function resolveGraph(node, ctx) {
|
|
235
|
+
const graph = loadGraphNodes(ctx.root);
|
|
236
|
+
if (graph !== null) {
|
|
237
|
+
if (graph.has(node))
|
|
238
|
+
return OK;
|
|
239
|
+
return refused(`no node \`${node}\` in graphify-out/graph.json (${String(graph.size)} node(s))`);
|
|
240
|
+
}
|
|
241
|
+
const map = loadMapTokens(ctx.root);
|
|
242
|
+
if (map === null) {
|
|
243
|
+
return unverified(`no graphify-out/graph.json and no ${PROJECT_FRAMEWORK_DIR}/map/ to check \`${node}\` against`);
|
|
244
|
+
}
|
|
245
|
+
if (map.has(node))
|
|
246
|
+
return OK;
|
|
247
|
+
return refused(`\`${node}\` is not a node id or a path named anywhere in ${PROJECT_FRAMEWORK_DIR}/map/`);
|
|
248
|
+
}
|
|
249
|
+
function resolveDoc(url, ctx) {
|
|
250
|
+
const known = loadDeclaredUrls(ctx);
|
|
251
|
+
if (known.has(url) || known.has(url.replace(/[/#?]+$/, "")))
|
|
252
|
+
return OK;
|
|
253
|
+
return unverified(`${url} is not fetched offline and no input, map or knowledge file cites it`);
|
|
254
|
+
}
|
|
255
|
+
function resolveAbsent(path, ctx, section, claim) {
|
|
256
|
+
if (section !== "Unknowns" && claim !== "" && !isNegativeClaim(claim)) {
|
|
257
|
+
return refused(`absent:${path} sources a positive claim — an absence can only support a negative one ` + "(say what is NOT there: `no`, `not`, `never`, `none`, `absent`), or move it under Unknowns");
|
|
258
|
+
}
|
|
259
|
+
const abs = isAbsolute(path) ? normalize(path) : normalize(join(ctx.root, path));
|
|
260
|
+
if (!existsSync(abs))
|
|
261
|
+
return OK;
|
|
262
|
+
return unverified(`${path} EXISTS — "found nothing in it" is a claim about its contents, unchecked here`);
|
|
263
|
+
}
|
|
264
|
+
var NEGATIVE_RE = /\b(no|not|none|never|nothing|nobody|nowhere|neither|nor|absent|absence|missing|lacks?|lacking|without|unknown|undocumented|unrecorded|untracked|zero|n\/a)\b|n't\b/i;
|
|
265
|
+
function isNegativeClaim(text) {
|
|
266
|
+
return NEGATIVE_RE.test(text);
|
|
267
|
+
}
|
|
268
|
+
function fileBases(ref, ctx) {
|
|
269
|
+
if (isAbsolute(ref.path)) {
|
|
270
|
+
return [{ label: "the absolute path as written", abs: normalize(ref.path) }];
|
|
271
|
+
}
|
|
272
|
+
if (ref.repo !== null) {
|
|
273
|
+
const rel = ctx.repos.get(ref.repo) ?? "";
|
|
274
|
+
return [{ label: repoLabel(ref.repo, rel), abs: normalize(join(ctx.root, rel, ref.path)) }];
|
|
275
|
+
}
|
|
276
|
+
const bases = [
|
|
277
|
+
{ label: "workspace root", abs: normalize(join(ctx.root, ref.path)) }
|
|
278
|
+
];
|
|
279
|
+
const runDir = ctx.runDir ?? null;
|
|
280
|
+
if (runDir !== null && runDir !== "") {
|
|
281
|
+
bases.push({ label: `run dir ${displayPath(ctx.root, runDir)}`, abs: normalize(join(runDir, ref.path)) });
|
|
282
|
+
}
|
|
283
|
+
const slash = ref.path.indexOf("/");
|
|
284
|
+
if (slash > 0) {
|
|
285
|
+
const name = ref.path.slice(0, slash);
|
|
286
|
+
const rel = ctx.repos.get(name);
|
|
287
|
+
if (rel !== undefined) {
|
|
288
|
+
bases.push({
|
|
289
|
+
label: repoLabel(name, rel),
|
|
290
|
+
abs: normalize(join(ctx.root, rel, ref.path.slice(slash + 1)))
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return bases;
|
|
295
|
+
}
|
|
296
|
+
function repoLabel(name, rel) {
|
|
297
|
+
const at = rel === "" || rel === "." ? "workspace root" : rel;
|
|
298
|
+
return `repo \`${name}\` (${at})`;
|
|
299
|
+
}
|
|
300
|
+
function displayPath(root, dir) {
|
|
301
|
+
const rel = relative(root, dir);
|
|
302
|
+
return rel === "" || rel.startsWith("..") ? dir : rel.split(sep).join("/");
|
|
303
|
+
}
|
|
304
|
+
function countLines(path) {
|
|
305
|
+
const cached = lineCountCache.get(path);
|
|
306
|
+
if (cached !== undefined)
|
|
307
|
+
return cached;
|
|
308
|
+
const text = readFileSync(path, "utf8");
|
|
309
|
+
const count = text === "" ? 0 : text.split(`
|
|
310
|
+
`).length - (text.endsWith(`
|
|
311
|
+
`) ? 1 : 0);
|
|
312
|
+
lineCountCache.set(path, count);
|
|
313
|
+
return count;
|
|
314
|
+
}
|
|
315
|
+
var factsCache = new Map;
|
|
316
|
+
var questionsCache = new Map;
|
|
317
|
+
var graphCache = new Map;
|
|
318
|
+
var mapCache = new Map;
|
|
319
|
+
var urlCache = new Map;
|
|
320
|
+
var MAX_SCAN_FILES = 400;
|
|
321
|
+
var MAX_SCAN_BYTES = 512 * 1024;
|
|
322
|
+
var MAX_SCAN_DEPTH = 4;
|
|
323
|
+
function loadFacts(root) {
|
|
324
|
+
const cached = factsCache.get(root);
|
|
325
|
+
if (cached !== undefined)
|
|
326
|
+
return cached;
|
|
327
|
+
const index = readFacts(join(root, PROJECT_FRAMEWORK_DIR, "memory", "facts.yml"));
|
|
328
|
+
factsCache.set(root, index);
|
|
329
|
+
return index;
|
|
330
|
+
}
|
|
331
|
+
function readFacts(path) {
|
|
332
|
+
if (!existsSync(path))
|
|
333
|
+
return null;
|
|
334
|
+
const live = new Set;
|
|
335
|
+
const retired = new Set;
|
|
336
|
+
try {
|
|
337
|
+
const doc = parseYaml(readFileSync(path, "utf8"));
|
|
338
|
+
const rows = doc?.facts;
|
|
339
|
+
if (!Array.isArray(rows))
|
|
340
|
+
return { live, retired };
|
|
341
|
+
for (const row of rows) {
|
|
342
|
+
const id = typeof row?.id === "string" ? row.id : null;
|
|
343
|
+
if (id === null)
|
|
344
|
+
continue;
|
|
345
|
+
const gone = row.retired;
|
|
346
|
+
const isGone = gone !== null && gone !== undefined && typeof gone === "object" && gone.at !== null && gone.at !== undefined;
|
|
347
|
+
if (isGone)
|
|
348
|
+
retired.add(id);
|
|
349
|
+
else
|
|
350
|
+
live.add(id);
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return { live, retired };
|
|
356
|
+
}
|
|
357
|
+
function loadQuestionIds(runDir) {
|
|
358
|
+
const cached = questionsCache.get(runDir);
|
|
359
|
+
if (cached !== undefined)
|
|
360
|
+
return cached;
|
|
361
|
+
const files = findFiles(runDir, (name) => name === "questions.md", MAX_SCAN_DEPTH);
|
|
362
|
+
if (files.length === 0) {
|
|
363
|
+
questionsCache.set(runDir, null);
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const ids = new Set;
|
|
367
|
+
const heading = /^#{2,4}\s*(Q\d{1,6})\b/gm;
|
|
368
|
+
for (const file of files) {
|
|
369
|
+
const text = readCapped(file);
|
|
370
|
+
for (const match of text.matchAll(heading)) {
|
|
371
|
+
if (match[1] !== undefined)
|
|
372
|
+
ids.add(match[1]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
questionsCache.set(runDir, ids);
|
|
376
|
+
return ids;
|
|
377
|
+
}
|
|
378
|
+
function loadGraphNodes(root) {
|
|
379
|
+
const cached = graphCache.get(root);
|
|
380
|
+
if (cached !== undefined)
|
|
381
|
+
return cached;
|
|
382
|
+
const ids = readGraphNodes(root);
|
|
383
|
+
graphCache.set(root, ids);
|
|
384
|
+
return ids;
|
|
385
|
+
}
|
|
386
|
+
function readGraphNodes(root) {
|
|
387
|
+
const roots = [join(root, PROJECT_FRAMEWORK_DIR, "graphify-out"), join(root, "graphify-out")];
|
|
388
|
+
const files = [];
|
|
389
|
+
for (const dir of roots) {
|
|
390
|
+
if (!existsSync(dir))
|
|
391
|
+
continue;
|
|
392
|
+
files.push(...findFiles(dir, (name) => name === "graph.json", MAX_SCAN_DEPTH));
|
|
393
|
+
}
|
|
394
|
+
if (files.length === 0)
|
|
395
|
+
return null;
|
|
396
|
+
const ids = new Set;
|
|
397
|
+
for (const file of files) {
|
|
398
|
+
try {
|
|
399
|
+
const doc = JSON.parse(readCapped(file));
|
|
400
|
+
const nodes = doc?.nodes;
|
|
401
|
+
if (!Array.isArray(nodes))
|
|
402
|
+
continue;
|
|
403
|
+
for (const node of nodes) {
|
|
404
|
+
for (const key of ["id", "name", "label"]) {
|
|
405
|
+
const value = node?.[key];
|
|
406
|
+
if (typeof value === "string" && value !== "") {
|
|
407
|
+
ids.add(value);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
return ids.size === 0 ? null : ids;
|
|
415
|
+
}
|
|
416
|
+
function loadMapTokens(root) {
|
|
417
|
+
const cached = mapCache.get(root);
|
|
418
|
+
if (cached !== undefined)
|
|
419
|
+
return cached;
|
|
420
|
+
const dir = join(root, PROJECT_FRAMEWORK_DIR, "map");
|
|
421
|
+
if (!existsSync(dir)) {
|
|
422
|
+
mapCache.set(root, null);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const tokens = new Set;
|
|
426
|
+
const word = /[A-Za-z0-9_./-]{2,}/g;
|
|
427
|
+
for (const file of findFiles(dir, (name) => name.endsWith(".md") || name.endsWith(".json"), MAX_SCAN_DEPTH)) {
|
|
428
|
+
for (const match of readCapped(file).matchAll(word))
|
|
429
|
+
tokens.add(match[0]);
|
|
430
|
+
}
|
|
431
|
+
mapCache.set(root, tokens);
|
|
432
|
+
return tokens;
|
|
433
|
+
}
|
|
434
|
+
function loadDeclaredUrls(ctx) {
|
|
435
|
+
const key = `${ctx.root}\x00${ctx.runDir ?? ""}`;
|
|
436
|
+
const cached = urlCache.get(key);
|
|
437
|
+
if (cached !== undefined)
|
|
438
|
+
return cached;
|
|
439
|
+
const urls = new Set;
|
|
440
|
+
const url = /https:\/\/[^\s)\]"'`<>]+/g;
|
|
441
|
+
const runDir = ctx.runDir ?? "";
|
|
442
|
+
const dirs = [
|
|
443
|
+
join(ctx.root, PROJECT_FRAMEWORK_DIR, "map"),
|
|
444
|
+
join(ctx.root, PROJECT_FRAMEWORK_DIR, "knowledge"),
|
|
445
|
+
join(ctx.root, PROJECT_FRAMEWORK_DIR, "experts"),
|
|
446
|
+
...runDir === "" ? [] : [runDir]
|
|
447
|
+
];
|
|
448
|
+
for (const dir of dirs) {
|
|
449
|
+
if (!existsSync(dir))
|
|
450
|
+
continue;
|
|
451
|
+
for (const file of findFiles(dir, (name) => name.endsWith(".md") && name !== "handoff.md", MAX_SCAN_DEPTH)) {
|
|
452
|
+
for (const match of readCapped(file).matchAll(url)) {
|
|
453
|
+
urls.add(match[0].replace(/[.,;:]+$/, ""));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
urlCache.set(key, urls);
|
|
458
|
+
return urls;
|
|
459
|
+
}
|
|
460
|
+
function findFiles(dir, accept, depth) {
|
|
461
|
+
const found = [];
|
|
462
|
+
const walk = (current, left) => {
|
|
463
|
+
if (left < 0 || found.length >= MAX_SCAN_FILES)
|
|
464
|
+
return;
|
|
465
|
+
let entries;
|
|
466
|
+
try {
|
|
467
|
+
entries = readdirSync(current);
|
|
468
|
+
} catch {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
for (const name of entries) {
|
|
472
|
+
if (found.length >= MAX_SCAN_FILES)
|
|
473
|
+
return;
|
|
474
|
+
if (name.startsWith(".") && name !== PROJECT_FRAMEWORK_DIR)
|
|
475
|
+
continue;
|
|
476
|
+
if (name === "node_modules")
|
|
477
|
+
continue;
|
|
478
|
+
const abs = join(current, name);
|
|
479
|
+
let isDir;
|
|
480
|
+
try {
|
|
481
|
+
isDir = statSync(abs).isDirectory();
|
|
482
|
+
} catch {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (isDir)
|
|
486
|
+
walk(abs, left - 1);
|
|
487
|
+
else if (accept(name))
|
|
488
|
+
found.push(abs);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
walk(dir, depth);
|
|
492
|
+
return found;
|
|
493
|
+
}
|
|
494
|
+
function readCapped(path) {
|
|
495
|
+
try {
|
|
496
|
+
const text = readFileSync(path, "utf8");
|
|
497
|
+
return text.length > MAX_SCAN_BYTES ? text.slice(0, MAX_SCAN_BYTES) : text;
|
|
498
|
+
} catch {
|
|
499
|
+
return "";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export { parseSrcToken, hasSrcMarker, classifySrc, resolveSrc };
|