@sleepy_panda_srl/portulan 0.1.3 → 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.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
@@ -0,0 +1,1132 @@
1
+ #!/usr/bin/env node
2
+ // A code file's outline, one line per symbol, and a Markdown file's, one line per heading with its size.
3
+ //
4
+ // Exit 0 printed · 1 no definition for `--find`, or no heading for `<file>#<heading>` · 2 could not run.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ const USAGE = "usage: node cli/symbols.mjs <file>... | <file.md>#<heading>... | --find <name>...";
13
+
14
+ /** Past this many characters a line's code is cut with `…`, so an outline stays one line per symbol. */
15
+ const WIDTH = 110;
16
+
17
+ export class CannotOutline extends Error {}
18
+
19
+ /** `js`, `sh`, `md`, or null for a file this does not read: by extension, then by a `#!` line. */
20
+ export function languageOf(file, firstLine = "") {
21
+ const ext = path.extname(file);
22
+ if ([".mjs", ".js", ".cjs"].includes(ext)) return "js";
23
+ if ([".sh", ".bash"].includes(ext)) return "sh";
24
+ if ([".md", ".markdown"].includes(ext)) return "md";
25
+ if (ext !== "" || !firstLine.startsWith("#!")) return null;
26
+ if (/\bnode\b/.test(firstLine)) return "js";
27
+ if (/\b(ba|da|z)?sh\b/.test(firstLine)) return "sh";
28
+ return null;
29
+ }
30
+
31
+ // ===========================================================================================
32
+ // 1. JavaScript: tokens, brackets, statements
33
+ // ===========================================================================================
34
+
35
+ const KEYWORDS_BEFORE_REGEX = new Set(["return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
36
+ "throw", "case", "default", "do", "else", "yield", "await"]);
37
+ const TEST_CALLS = new Set(["describe", "test", "it", "suite", "before", "after", "beforeEach", "afterEach"]);
38
+ const NAME_START = /[A-Za-z_$\u0080-\uffff]/;
39
+ const NAME_PART = /[\w$\u0080-\uffff]/;
40
+
41
+ /** Whether a `/` opens a regular expression is a guess, and matching the brackets is what checks it. */
42
+ export function scanJs(src) {
43
+ const tokens = [];
44
+ const comments = [];
45
+ const push = (type, value, start, end) => tokens.push({ type, value, start, end });
46
+ const newline = src.indexOf("\n");
47
+ scan(src, src.startsWith("#!") ? (newline === -1 ? src.length : newline) : 0, false, { push, comments });
48
+ return { tokens, comments };
49
+ }
50
+
51
+ // A `/` divides after a value, as after the `}` of an object literal (a `{` where an expression goes); elsewhere it opens a regex.
52
+ function slashReader() {
53
+ let prev = null;
54
+ const braces = [];
55
+ const expressionKeyword = (token) => token.type === "name" && !token.property && KEYWORDS_BEFORE_REGEX.has(token.value);
56
+ return {
57
+ note(type, value) {
58
+ const token = { type, value, property: prev?.type === "punct" && (prev.value === "." || prev.value === "?.") };
59
+ if (type === "punct" && value === "{") {
60
+ braces.push(prev !== null && (prev.type === "punct" ? ![")", ";", "{", "}", "=>"].includes(prev.value)
61
+ : prev.type !== "name" || (expressionKeyword(prev) && prev.value !== "do" && prev.value !== "else")));
62
+ }
63
+ if (type === "punct" && value === "}") token.literal = braces.pop() === true;
64
+ prev = token;
65
+ },
66
+ regexAllowed: () =>
67
+ prev === null ||
68
+ (prev.type === "punct" && (prev.value === "}" ? !prev.literal : ![")", "]", "++", "--"].includes(prev.value))) ||
69
+ expressionKeyword(prev),
70
+ };
71
+ }
72
+
73
+ // `nested` scans a `${}` to its closing brace and records nothing, so only the file's own level is tokenised.
74
+ function scan(src, i, nested, sink) {
75
+ let depth = 0;
76
+ const slash = slashReader();
77
+ const out = {
78
+ push: (type, value, start, end) => {
79
+ slash.note(type, value);
80
+ if (!nested) sink.push(type, value, start, end);
81
+ },
82
+ regexAllowed: slash.regexAllowed,
83
+ };
84
+ const n = src.length;
85
+ while (i < n) {
86
+ const c = src[i];
87
+ if (/\s/.test(c)) { i++; continue; }
88
+ if (c === "/" && src[i + 1] === "/") {
89
+ const e = src.indexOf("\n", i);
90
+ const end = e === -1 ? n : e;
91
+ if (!nested) sink.comments.push({ start: i, end, block: false });
92
+ i = end;
93
+ continue;
94
+ }
95
+ if (c === "/" && src[i + 1] === "*") {
96
+ const e = src.indexOf("*/", i + 2);
97
+ if (e === -1) throw new CannotOutline(`an unterminated comment at offset ${i}`);
98
+ if (!nested) sink.comments.push({ start: i, end: e + 2, block: true });
99
+ i = e + 2;
100
+ continue;
101
+ }
102
+ if (c === "'" || c === '"') {
103
+ let j = i + 1;
104
+ while (j < n && src[j] !== c) {
105
+ if (src[j] === "\\") j++;
106
+ else if (src[j] === "\n") throw new CannotOutline(`an unterminated string at offset ${i}`);
107
+ j++;
108
+ }
109
+ if (j >= n) throw new CannotOutline(`an unterminated string at offset ${i}`);
110
+ out.push("string", src.slice(i, j + 1), i, j + 1);
111
+ i = j + 1;
112
+ continue;
113
+ }
114
+ if (c === "`") {
115
+ let j = i + 1;
116
+ while (j < n && src[j] !== "`") {
117
+ if (src[j] === "\\") j += 2;
118
+ else if (src[j] === "$" && src[j + 1] === "{") j = scan(src, j + 2, true, sink);
119
+ else j++;
120
+ }
121
+ if (j >= n) throw new CannotOutline(`an unterminated template at offset ${i}`);
122
+ out.push("template", src.slice(i, j + 1), i, j + 1);
123
+ i = j + 1;
124
+ continue;
125
+ }
126
+ if (c === "/" && out.regexAllowed()) {
127
+ let j = i + 1;
128
+ let inClass = false;
129
+ while (j < n && (inClass || src[j] !== "/")) {
130
+ if (src[j] === "\\") j++;
131
+ else if (src[j] === "[") inClass = true;
132
+ else if (src[j] === "]") inClass = false;
133
+ else if (src[j] === "\n") throw new CannotOutline(`an unterminated regular expression at offset ${i}`);
134
+ j++;
135
+ }
136
+ if (j >= n) throw new CannotOutline(`an unterminated regular expression at offset ${i}`);
137
+ j++;
138
+ while (j < n && NAME_PART.test(src[j])) j++;
139
+ out.push("regex", src.slice(i, j), i, j);
140
+ i = j;
141
+ continue;
142
+ }
143
+ if (NAME_START.test(c) || (c === "#" && NAME_START.test(src[i + 1] ?? ""))) {
144
+ let j = i + 1;
145
+ while (j < n && NAME_PART.test(src[j])) j++;
146
+ out.push("name", src.slice(i, j), i, j);
147
+ i = j;
148
+ continue;
149
+ }
150
+ if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
151
+ let j = i + 1;
152
+ while (j < n && /[\w.]/.test(src[j])) j++;
153
+ out.push("number", src.slice(i, j), i, j);
154
+ i = j;
155
+ continue;
156
+ }
157
+ if (c === "{") depth++;
158
+ if (c === "}") {
159
+ if (nested && depth === 0) return i + 1;
160
+ depth--;
161
+ }
162
+ const three = src.slice(i, i + 3);
163
+ const two = src.slice(i, i + 2);
164
+ const op = ["...", "===", "!==", "**=", "<<=", ">>=", "&&=", "||=", "??="].includes(three) ? three
165
+ : ["=>", "==", "!=", "&&", "||", "??", "?.", "++", "--", "+=", "-=", "*=", "/=", "%=", "<=", ">=", "**"].includes(two) ? two
166
+ : c;
167
+ out.push("punct", op, i, i + op.length);
168
+ i += op.length;
169
+ }
170
+ if (nested) throw new CannotOutline("an unterminated template expression");
171
+ return i;
172
+ }
173
+
174
+ /** Each bracket token's partner, by index, or a refusal naming the first one that has none. */
175
+ function matchBrackets(tokens, lineOf) {
176
+ const match = new Map();
177
+ const stack = [];
178
+ const pairs = { ")": "(", "]": "[", "}": "{" };
179
+ tokens.forEach((token, index) => {
180
+ if (token.type !== "punct") return;
181
+ if (token.value === "(" || token.value === "[" || token.value === "{") stack.push(index);
182
+ else if (token.value in pairs) {
183
+ const open = stack.pop();
184
+ if (open === undefined) throw new CannotOutline(`an unmatched \`${token.value}\` on line ${lineOf(token.start)}`);
185
+ if (tokens[open].value !== pairs[token.value]) {
186
+ throw new CannotOutline(`a \`${token.value}\` on line ${lineOf(token.start)} closing the \`${tokens[open].value}\` of line ${lineOf(tokens[open].start)}`);
187
+ }
188
+ match.set(open, index);
189
+ match.set(index, open);
190
+ }
191
+ });
192
+ if (stack.length) throw new CannotOutline(`an unclosed \`${tokens[stack.at(-1)].value}\` on line ${lineOf(tokens[stack.at(-1)].start)}`);
193
+ return match;
194
+ }
195
+
196
+ /** A source's line starts, and the 1-based line holding an offset. */
197
+ function lineIndex(src) {
198
+ const starts = [0];
199
+ for (let i = src.indexOf("\n"); i !== -1; i = src.indexOf("\n", i + 1)) starts.push(i + 1);
200
+ const lineOf = (offset) => {
201
+ let lo = 0;
202
+ let hi = starts.length - 1;
203
+ while (lo < hi) {
204
+ const mid = (lo + hi + 1) >> 1;
205
+ if (starts[mid] <= offset) lo = mid;
206
+ else hi = mid - 1;
207
+ }
208
+ return lo + 1;
209
+ };
210
+ const lines = src.endsWith("\n") ? starts.length - 1 : starts.length;
211
+ return { lineOf, lines };
212
+ }
213
+
214
+ // The statement that starts at token `i` ends at the index this returns, never at or past `stop`.
215
+ function statementEnd(t, i, stop, match) {
216
+ const at = (k) => (k < stop ? t[k] : undefined);
217
+ const is = (k, value) => at(k)?.value === value && at(k)?.type !== "string";
218
+ const skip = (k) => (match.has(k) && ["(", "[", "{"].includes(t[k].value) ? match.get(k) : k);
219
+ const body = (k) => (is(k, "{") ? match.get(k) : statementEnd(t, k, stop, match));
220
+ const head = t[i].value;
221
+ if (is(i, ";")) return i;
222
+ if (is(i, "{")) return match.get(i);
223
+ let k = i;
224
+ while (["export", "default", "async"].includes(at(k)?.value) && at(k).type === "name") k++;
225
+ const keyword = at(k)?.type === "name" ? at(k).value : null;
226
+ if (keyword === "function" || keyword === "class") {
227
+ let j = k + 1;
228
+ while (j < stop && !is(j, "{")) j = skip(j) + 1;
229
+ if (j >= stop) return stop - 1;
230
+ if (keyword === "class" || at(j - 1)?.value === ")") return match.get(j);
231
+ }
232
+ if (head === "if" || head === "while" || head === "with" || (head === "for" && !is(i + 1, "."))) {
233
+ let j = i + 1;
234
+ if (is(j, "await")) j++;
235
+ if (!is(j, "(")) return simpleEnd(t, i, stop, match);
236
+ let end = body(match.get(j) + 1);
237
+ if (head === "if" && is(end + 1, "else")) end = body(end + 2);
238
+ return end;
239
+ }
240
+ if (head === "do" && at(i).type === "name") {
241
+ let end = body(i + 1);
242
+ if (is(end + 1, "while") && is(end + 2, "(")) end = match.get(end + 2);
243
+ return is(end + 1, ";") ? end + 1 : end;
244
+ }
245
+ if (head === "switch" && is(i + 1, "(") && is(match.get(i + 1) + 1, "{")) return match.get(match.get(i + 1) + 1);
246
+ if (head === "try" && is(i + 1, "{")) {
247
+ let end = match.get(i + 1);
248
+ if (is(end + 1, "catch")) {
249
+ let j = end + 2;
250
+ if (is(j, "(")) j = match.get(j) + 1;
251
+ if (is(j, "{")) end = match.get(j);
252
+ }
253
+ if (is(end + 1, "finally") && is(end + 2, "{")) end = match.get(end + 2);
254
+ return end;
255
+ }
256
+ return simpleEnd(t, i, stop, match);
257
+ }
258
+
259
+ // Ends at `;` or where ASI would; an import, or an export of `{ … }` or `*`, runs on to its `from "…"` and any `with { … }`.
260
+ function simpleEnd(t, i, stop, match) {
261
+ const clause = t[i].type === "name" && ((t[i].value === "import" && t[i + 1]?.value !== "(" && t[i + 1]?.value !== ".") ||
262
+ (t[i].value === "export" && (t[i + 1]?.value === "{" || t[i + 1]?.value === "*")));
263
+ let source = false;
264
+ for (let k = i; k < stop;) {
265
+ if (t[k].type === "punct" && t[k].value === ";") return k;
266
+ if (t[k].type === "string") source = true;
267
+ const last = t[k].type === "punct" && ["(", "[", "{"].includes(t[k].value) ? match.get(k) : k;
268
+ const next = last + 1 < stop ? t[last + 1] : null;
269
+ const continues = ["instanceof", "in", "of"].includes(next?.value) || (clause && next?.value === (source ? "with" : "from"));
270
+ if (next?.type === "name" && !continues && next.line > t[last].endLine && endsValue(t[last])) return last;
271
+ k = last + 1;
272
+ }
273
+ return stop - 1;
274
+ }
275
+
276
+ // Whether a statement may end on this token: never on an operator, or on a keyword awaiting its operand.
277
+ function endsValue(token) {
278
+ if (token.type === "name") return !KEYWORDS_BEFORE_REGEX.has(token.value);
279
+ return token.type !== "punct" || [")", "]", "}", "++", "--"].includes(token.value);
280
+ }
281
+
282
+ /** The statements between two token indexes, as [first, last] index pairs. */
283
+ function statements(t, from, stop, match) {
284
+ const out = [];
285
+ for (let i = from; i < stop;) {
286
+ const last = statementEnd(t, i, stop, match);
287
+ out.push([i, last]);
288
+ i = last + 1;
289
+ }
290
+ return out;
291
+ }
292
+
293
+ // ===========================================================================================
294
+ // 2. JavaScript: what each statement is, and the line that names it
295
+ // ===========================================================================================
296
+
297
+ function oneLine(text) {
298
+ return text.replace(/\s+/g, " ").replace(/([([{]) /g, "$1").replace(/ ([)\]}])/g, "$1").trim();
299
+ }
300
+
301
+ function cut(text) {
302
+ return text.length > WIDTH ? `${text.slice(0, WIDTH - 1)}…` : text;
303
+ }
304
+
305
+ /** `{ lines, entries }`, with 1-based inclusive lines; throws `CannotOutline` where the brackets do not match. */
306
+ export function outlineJs(src) {
307
+ const { tokens: t, comments } = scanJs(src);
308
+ const { lineOf, lines } = lineIndex(src);
309
+ for (const token of t) {
310
+ token.line = lineOf(token.start);
311
+ token.endLine = lineOf(token.end - 1);
312
+ }
313
+ for (const comment of comments) {
314
+ comment.line = lineOf(comment.start);
315
+ comment.endLine = lineOf(comment.end - 1);
316
+ comment.text = src.slice(comment.start, comment.end);
317
+ }
318
+ const match = matchBrackets(t, lineOf);
319
+ let p = 0;
320
+ for (const comment of comments) {
321
+ while (p < t.length && t[p].start < comment.start) p++;
322
+ comment.trailing = p > 0 && t[p - 1].endLine === comment.line;
323
+ }
324
+ const srcLines = src.split("\n");
325
+ const firstLineOf = (token) => srcLines[token.line - 1].slice(token.start - (src.lastIndexOf("\n", token.start - 1) + 1));
326
+
327
+ // A comment block directly above a line (no blank line between, no banner in it) opens that span.
328
+ const byEndLine = new Map(comments.map((c) => [c.endLine, c]));
329
+ const docStart = (line) => {
330
+ let start = line;
331
+ for (let c = byEndLine.get(start - 1); c && !c.trailing && !isBanner(c.text); c = byEndLine.get(start - 1)) start = c.line;
332
+ return start;
333
+ };
334
+
335
+ const signature = (first, last) => {
336
+ const head = t[first];
337
+ let k = first;
338
+ while (k <= last && ["export", "default", "async", "static", "get", "set", "accessor"].includes(t[k].value) && t[k].type === "name") k++;
339
+ if (t[k]?.value === "*") k++;
340
+ const declares = t[k]?.type === "name" && ["function", "class"].includes(t[k].value);
341
+ let stopAt = last + 1;
342
+ if (declares || isMethod(t, k, last, match)) {
343
+ for (let j = k; j <= last; j = (["(", "["].includes(t[j].value) && t[j].type === "punct" ? match.get(j) : j) + 1) {
344
+ if (t[j].type === "punct" && t[j].value === "{") { stopAt = j; break; }
345
+ }
346
+ }
347
+ const text = stopAt <= last
348
+ ? oneLine(src.slice(head.start, t[stopAt].start))
349
+ : firstLineOf(head).trim();
350
+ return cut(text);
351
+ };
352
+
353
+ const nameOf = (first, last) => {
354
+ let k = first;
355
+ while (["export", "default", "async", "static", "get", "set", "accessor", "function", "class", "const", "let", "var"].includes(t[k]?.value) && t[k].type === "name") k++;
356
+ if (t[k]?.value === "*") k = k + 1;
357
+ // `class extends Base` has no name of its own; `extends` is the next word, not the class's name.
358
+ if (t[k]?.value === "extends" && t[k - 1]?.value === "class") return null;
359
+ return t[k]?.type === "name" && k <= last ? t[k].value : null;
360
+ };
361
+
362
+ const kindOf = (first) => {
363
+ let k = first;
364
+ while (["export", "default", "async"].includes(t[k]?.value) && t[k].type === "name") k++;
365
+ return t[k]?.type === "name" ? t[k].value : null;
366
+ };
367
+
368
+ const entry = (first, last, extra = {}) => ({
369
+ start: docStart(t[first].line),
370
+ end: t[last].endLine,
371
+ text: signature(first, last),
372
+ name: null,
373
+ children: [],
374
+ ...extra,
375
+ });
376
+
377
+ const members = (open) => {
378
+ const out = [];
379
+ for (let i = open + 1; i < match.get(open);) {
380
+ if (t[i].type === "punct" && t[i].value === ";") { i++; continue; }
381
+ const last = memberEnd(t, i, match.get(open), match);
382
+ out.push(entry(i, last, { name: memberName(t, i) }));
383
+ i = last + 1;
384
+ }
385
+ return out;
386
+ };
387
+
388
+ const tests = (first, last) => {
389
+ // Only a suite's callback is outlined, never a test's body: the first `{` an arrow or function opens in the call.
390
+ if (!["describe", "suite"].includes(t[first].value)) return [];
391
+ let call = first + 1;
392
+ while (t[call]?.value === "." && t[call + 1]?.type === "name") call += 2;
393
+ if (t[call]?.value !== "(" || call > last) return [];
394
+ for (let j = call + 1; j < match.get(call); j++) {
395
+ if (t[j].type === "punct" && t[j].value === "{" && (t[j - 1].value === "=>" || t[j - 1].value === ")")) {
396
+ return statements(t, j + 1, match.get(j), match).flatMap(([a, b]) => {
397
+ if (isTestCall(t, a)) return [entry(a, b, { children: tests(a, b) })];
398
+ const kind = kindOf(a);
399
+ if (kind === "function" || kind === "const" || kind === "let" || kind === "var" || kind === "class") {
400
+ return [entry(a, b, { name: kind === "class" ? nameOf(a, b) : declaredNames(t, a, b, match) })];
401
+ }
402
+ return [];
403
+ });
404
+ }
405
+ if (t[j].type === "punct" && ["(", "[", "{"].includes(t[j].value)) j = match.get(j);
406
+ }
407
+ return [];
408
+ };
409
+
410
+ const entries = [];
411
+ const imports = [];
412
+ for (const [first, last] of statements(t, 0, t.length, match)) {
413
+ if (first === last && t[first].value === ";") continue;
414
+ const kind = kindOf(first);
415
+ if (t[first].value === "import" && t[first].type === "name" && t[first + 1]?.value !== "(" && t[first + 1]?.value !== ".") {
416
+ imports.push([first, last]);
417
+ continue;
418
+ }
419
+ if (kind === "class") {
420
+ const open = firstBrace(t, first, last, match);
421
+ entries.push(entry(first, last, { name: nameOf(first, last), children: open === -1 ? [] : members(open) }));
422
+ } else if (kind === "function" || kind === "const" || kind === "let" || kind === "var") {
423
+ entries.push(entry(first, last, { name: declaredNames(t, first, last, match) }));
424
+ } else if (isTestCall(t, first)) {
425
+ entries.push(entry(first, last, { children: tests(first, last) }));
426
+ } else {
427
+ entries.push(entry(first, last));
428
+ }
429
+ }
430
+
431
+ // An import's module is its first string outside braces: `{ "a-b" as ab }` and `with { … }` hold strings too.
432
+ const sourceOf = (first, last) => {
433
+ for (let k = first; k <= last; k++) {
434
+ if (t[k].type === "string") return [t[k].value.slice(1, -1)];
435
+ if (t[k].type === "punct" && t[k].value === "{") k = match.get(k);
436
+ }
437
+ return [];
438
+ };
439
+
440
+ const runs = [];
441
+ for (const [first, last] of imports) {
442
+ const run = runs.at(-1);
443
+ if (run && t[first].line <= t[run.last].endLine + 1) run.last = last;
444
+ else runs.push({ first, last, from: [] });
445
+ runs.at(-1).from.push(...sourceOf(first, last));
446
+ }
447
+ for (const run of runs) {
448
+ entries.push({ start: docStart(t[run.first].line), end: t[run.last].endLine, text: cut(`import ${run.from.join(", ")}`), name: null, children: [] });
449
+ }
450
+
451
+ // The file's own comment before its first statement, when no statement claims it, names the file.
452
+ const firstCode = entries.length ? Math.min(...entries.map((e) => e.start)) : lines + 1;
453
+ const head = comments.filter((c) => c.endLine < firstCode);
454
+ if (head.length) {
455
+ const text = head.map((c) => c.text).join("\n").split("\n").map((line) => line.replace(/^\s*(\/\/+|\/\*+|\*\/|\*)\s?/, "").trim()).find((line) => line && !/^[-=*/]+$/.test(line));
456
+ if (text) entries.push({ start: head[0].line, end: head.at(-1).endLine, text: cut(`// ${text}`), name: null, children: [], header: true });
457
+ }
458
+
459
+ const sections = banners(comments.filter((c) => !c.block).map((c) => ({ line: c.line, text: c.text.slice(2) })));
460
+ return { lines, entries: place(order(entries), sections, lines, "//") };
461
+ }
462
+
463
+ // A comment line that opens or rules off a section, so no span's opening comment reaches past it.
464
+ function isBanner(text) {
465
+ return /^\s*(\/\/|#)\s*([-=]{4,}|\d+[a-z]\.\s)/.test(text);
466
+ }
467
+
468
+ function isTestCall(t, i) {
469
+ let k = i;
470
+ if (t[k]?.type !== "name" || !TEST_CALLS.has(t[k].value)) return false;
471
+ while (t[k + 1]?.value === "." && t[k + 2]?.type === "name") k += 2;
472
+ return t[k + 1]?.value === "(";
473
+ }
474
+
475
+ function isMethod(t, k, last, match) {
476
+ let j = k;
477
+ if (t[j]?.value === "[" && t[j].type === "punct") j = match.get(j);
478
+ return t[j + 1]?.value === "(" && t[j + 1].type === "punct" && t[match.get(j + 1) + 1]?.value === "{";
479
+ }
480
+
481
+ function firstBrace(t, first, last, match) {
482
+ for (let j = first; j <= last; j++) {
483
+ if (t[j].type !== "punct") continue;
484
+ if (t[j].value === "{") return j;
485
+ if (t[j].value === "(" || t[j].value === "[") j = match.get(j);
486
+ }
487
+ return -1;
488
+ }
489
+
490
+ // A class member ends at its body's `}` (a method, an accessor, a static block) or at its `;`.
491
+ function memberEnd(t, i, stop, match) {
492
+ let k = i;
493
+ while (k < stop && t[k].type === "name" && ["static", "async", "get", "set", "accessor"].includes(t[k].value) &&
494
+ !(t[k + 1]?.value === "(" || t[k + 1]?.value === "=" || t[k + 1]?.value === ";")) k++;
495
+ if (t[k]?.value === "{") return match.get(k);
496
+ if (t[k]?.value === "*") k++;
497
+ if (t[k]?.value === "[") k = match.get(k);
498
+ if (t[k + 1]?.value === "(" && t[match.get(k + 1) + 1]?.value === "{") return match.get(match.get(k + 1) + 1);
499
+ for (let j = k; j < stop; j++) {
500
+ if (t[j].type === "punct" && t[j].value === ";") return j;
501
+ if (t[j].type === "punct" && ["(", "[", "{"].includes(t[j].value)) j = match.get(j);
502
+ if (j + 1 < stop && t[j + 1].line > t[j].endLine && t[j + 1].type === "name" && endsValue(t[j])) return j;
503
+ }
504
+ return stop - 1;
505
+ }
506
+
507
+ function memberName(t, i) {
508
+ let k = i;
509
+ while (t[k]?.type === "name" && ["static", "async", "get", "set", "accessor"].includes(t[k].value) &&
510
+ !(t[k + 1]?.value === "(" || t[k + 1]?.value === "=" || t[k + 1]?.value === ";")) k++;
511
+ if (t[k]?.value === "*") k++;
512
+ return t[k]?.type === "name" ? t[k].value : null;
513
+ }
514
+
515
+ function declaredNames(t, first, last, match) {
516
+ let k = first;
517
+ while (["export", "default", "async"].includes(t[k]?.value) && t[k].type === "name") k++;
518
+ if (t[k].value === "function") {
519
+ const name = t[k + 1]?.value === "*" ? t[k + 2] : t[k + 1];
520
+ return name?.type === "name" ? [name.value] : null;
521
+ }
522
+ const names = [];
523
+ for (let j = k + 1; j <= last;) {
524
+ if (t[j].type === "name") names.push(t[j].value);
525
+ else if (t[j].type === "punct" && ["{", "["].includes(t[j].value)) names.push(...patternNames(t, j, match));
526
+ else break;
527
+ j = nextComma(t, j, last + 1, match);
528
+ if (t[j]?.value !== ",") break;
529
+ j++;
530
+ }
531
+ return names.length ? names : null;
532
+ }
533
+
534
+ function nextComma(t, j, stop, match) {
535
+ for (; j < stop; j++) {
536
+ if (t[j].type !== "punct") continue;
537
+ if (t[j].value === "," || t[j].value === ";") return j;
538
+ if (["(", "[", "{"].includes(t[j].value)) j = match.get(j);
539
+ }
540
+ return stop;
541
+ }
542
+
543
+ // The names a destructuring pattern binds: `{ a, b: c, d = 1, ...e }` binds a, c, d and e.
544
+ function patternNames(t, open, match) {
545
+ const names = [];
546
+ const close = match.get(open);
547
+ for (let j = open + 1; j < close;) {
548
+ const end = nextComma(t, j, close, match);
549
+ let a = j;
550
+ if (t[a]?.value === "...") a++;
551
+ if (t[open].value === "{") {
552
+ for (let c = a; c < end; c++) {
553
+ if (t[c].type === "punct" && t[c].value === ":") { a = c + 1; break; }
554
+ if (t[c].type === "punct" && ["(", "[", "{"].includes(t[c].value)) c = match.get(c);
555
+ }
556
+ }
557
+ if (t[a]?.type === "name" && a < end) names.push(t[a].value);
558
+ else if (t[a]?.type === "punct" && ["{", "["].includes(t[a].value)) names.push(...patternNames(t, a, match));
559
+ j = end + 1;
560
+ }
561
+ return names;
562
+ }
563
+
564
+ // ===========================================================================================
565
+ // 3. Shell: functions and titled banners
566
+ // ===========================================================================================
567
+
568
+ const SH_FUNCTION = /^(\s*)(?:function\s+([A-Za-z_][\w:.-]*)\s*(?:\(\s*\))?|([A-Za-z_][\w:.-]*)\s*\(\s*\))\s*(\{.*)?$/;
569
+
570
+ // `<<` or `<<-` and a word unquoted into its delimiter; none inside quotes, after a comment or in `(( ))`, and `<<<` is a here-string.
571
+ function heredocs(text, line) {
572
+ const arithmetic = [];
573
+ for (let i = text.indexOf("(("); i !== -1; i = text.indexOf("((", i + 2)) {
574
+ let depth = 0;
575
+ let j = i;
576
+ for (; j < text.length; j++) {
577
+ if (text[j] === "(") depth++;
578
+ else if (text[j] === ")" && --depth === 0) break;
579
+ }
580
+ arithmetic.push([i, j]);
581
+ }
582
+ const out = [];
583
+ for (const operator of text.matchAll(/(?<!<)<<(?!<)(-?)[ \t]*/g)) {
584
+ const before = text.slice(0, operator.index);
585
+ if ((before.split('"').length - 1) % 2 === 1 || (before.split("'").length - 1) % 2 === 1) continue;
586
+ if (/(^|[\s;&|()])#/.test(before) || arithmetic.some(([a, b]) => a < operator.index && operator.index < b)) continue;
587
+ const start = operator.index + operator[0].length;
588
+ let delimiter = "";
589
+ let k = start;
590
+ while (k < text.length && !/[\s|&;()<>]/.test(text[k])) {
591
+ const c = text[k];
592
+ if (c === "'" || c === '"') {
593
+ let close = k + 1;
594
+ while (close < text.length && text[close] !== c) close += c === '"' && text[close] === "\\" ? 2 : 1;
595
+ if (close >= text.length) throw new CannotOutline(`an unclosed quote in the here-document delimiter on line ${line}`);
596
+ delimiter += c === '"' ? text.slice(k + 1, close).replace(/\\(["\\$`])/g, "$1") : text.slice(k + 1, close);
597
+ k = close + 1;
598
+ } else if (c === "\\") {
599
+ delimiter += text[k + 1] ?? "";
600
+ k += 2;
601
+ } else {
602
+ delimiter += c;
603
+ k++;
604
+ }
605
+ }
606
+ if (k > start) out.push({ strip: operator[1] === "-", delimiter });
607
+ }
608
+ return out;
609
+ }
610
+
611
+ // Words after which another command may start: a brace or a keyword standing where a command does.
612
+ const SH_COMMAND_WORDS = new Set(["{", "}", "!", "then", "do", "else", "elif", "if", "while", "until", "time"]);
613
+
614
+ // A brace is a word of its own only where a command may start: never in `${x}`, `{a,b}`, quotes, `\}` or a comment.
615
+ function functionEnd(code, at, from, name, line) {
616
+ const stack = [];
617
+ let depth = 0;
618
+ let command = true;
619
+ let word = "";
620
+ // True when the word just ended is the body's closing `}`; `next`, when given, says whether a command may follow.
621
+ const endWord = (next) => {
622
+ const brace = word !== "" && command && (word === "{" || word === "}");
623
+ if (brace) depth += word === "{" ? 1 : -1;
624
+ if (word !== "") command = command && SH_COMMAND_WORDS.has(word);
625
+ if (next !== undefined) command = next;
626
+ word = "";
627
+ return brace && depth === 0;
628
+ };
629
+ for (let k = at; k < code.length; k++) {
630
+ const text = code[k].text;
631
+ let continued = false;
632
+ for (let i = k === at ? from : 0; i < text.length; i++) {
633
+ const c = text[i];
634
+ const top = stack.at(-1);
635
+ if (top === "'") { if (c === "'") stack.pop(); continue; }
636
+ if (top === "$'" || top === "`") {
637
+ if (c === "\\") i++;
638
+ else if (c === top.at(-1)) stack.pop();
639
+ continue;
640
+ }
641
+ if (top === '"' || top === "${") {
642
+ if (c === "\\") i++;
643
+ else if (c === (top === '"' ? '"' : "}")) stack.pop();
644
+ else if (c === "$" && (text[i + 1] === "(" || text[i + 1] === "{")) stack.push(`$${text[++i]}`);
645
+ else if (c === "`") stack.push("`");
646
+ else if (top === "${" && (c === '"' || (c === "'" && !stack.includes('"')))) stack.push(c);
647
+ continue;
648
+ }
649
+ // Code: the file's own level, or inside `$(…)`, where `)` closes the substitution.
650
+ if (c === " " || c === "\t") { if (endWord() && !stack.length) return code[k].line; continue; }
651
+ if (c === "#" && word === "") break;
652
+ if (c === "\\") {
653
+ if (i === text.length - 1) continued = true;
654
+ else word += text[++i];
655
+ word += "\\";
656
+ continue;
657
+ }
658
+ if (c === "$" && (text[i + 1] === "(" || text[i + 1] === "{" || text[i + 1] === "'")) {
659
+ stack.push(text[i + 1] === "(" ? "$(" : text[i + 1] === "{" ? "${" : "$'");
660
+ word += c + text[++i];
661
+ continue;
662
+ }
663
+ if (c === "'" || c === '"' || c === "`") { stack.push(c); word += c; continue; }
664
+ if (c === ")" && top === "$(") { stack.pop(); word += c; continue; }
665
+ if (";&|()".includes(c)) { if (endWord(true) && !stack.length) return code[k].line; continue; }
666
+ if (c === "<" || c === ">") { if (endWord(false) && !stack.length) return code[k].line; continue; }
667
+ word += c;
668
+ }
669
+ if (stack.length && stack.at(-1) !== "$(") continue;
670
+ if (endWord(continued ? undefined : true) && !stack.length) return code[k].line;
671
+ }
672
+ throw new CannotOutline(`no closing brace for ${name}() on line ${line}`);
673
+ }
674
+
675
+ /** The outline of a shell script: its functions, its titled banners and the comment that opens it. */
676
+ export function outlineSh(src) {
677
+ const srcLines = src.split("\n");
678
+ const lines = src.endsWith("\n") ? srcLines.length - 1 : srcLines.length;
679
+ const code = [];
680
+ for (let i = 0; i < lines; i++) {
681
+ code.push({ line: i + 1, text: srcLines[i] });
682
+ if (/^\s*#/.test(srcLines[i])) continue;
683
+ // Each body runs to its own delimiter's line, the next body starting after it.
684
+ let j = i;
685
+ for (const { strip, delimiter } of heredocs(srcLines[i], i + 1)) {
686
+ j++;
687
+ while (j < lines && (strip ? srcLines[j].replace(/^\t+/, "") : srcLines[j]) !== delimiter) j++;
688
+ if (j >= lines) throw new CannotOutline(`an unterminated here-document on line ${i + 1}`);
689
+ }
690
+ i = j;
691
+ }
692
+ const entries = [];
693
+ for (let k = 0; k < code.length; k++) {
694
+ const { line, text } = code[k];
695
+ const fn = text.match(SH_FUNCTION);
696
+ if (!fn || /^\s*#/.test(text)) continue;
697
+ const name = fn[2] ?? fn[3];
698
+ let open = fn[4] ?? "";
699
+ let at = k;
700
+ if (!open && code[k + 1]?.text.trim().startsWith("{")) { at = k + 1; open = code[k + 1].text.trim(); }
701
+ if (!open) continue;
702
+ const end = functionEnd(code, at, code[at].text.length - open.length, name, line);
703
+ let start = line;
704
+ while (start > 1 && /^\s*#/.test(srcLines[start - 2]) && !srcLines[start - 2].startsWith("#!") && !isBanner(srcLines[start - 2])) start--;
705
+ entries.push({ start, end, text: cut(oneLine(`${name}()`)), name: [name], children: [] });
706
+ }
707
+ const comments = code.filter((c) => /^\s*#/.test(c.text) && !c.text.startsWith("#!"));
708
+ // The file's opening comment, up to its first line of code or the first span, names the file.
709
+ const firstCode = code.find((c) => c.text.trim() !== "" && !/^\s*#/.test(c.text))?.line ?? lines + 1;
710
+ const before = Math.min(firstCode, ...entries.map((e) => e.start));
711
+ const head = comments.filter((c) => c.line < before);
712
+ const text = head.map((c) => c.text.replace(/^\s*#\s?/, "").trim()).find((l) => l && !/^[-=]+$/.test(l));
713
+ if (text) entries.push({ start: head[0].line, end: head.at(-1).line, text: cut(`# ${text}`), name: null, children: [], header: true });
714
+ const numbered = comments.filter((c) => /^#\s+\d+[a-z]\.\s/.test(c.text)).map((c) => ({ line: c.line, title: c.text.replace(/^#\s+/, "") }));
715
+ const sections = banners(comments.map((c) => ({ line: c.line, text: c.text.replace(/^\s*#/, "") })), numbered);
716
+ return { lines, entries: place(order(entries), sections, lines, "#") };
717
+ }
718
+
719
+ // ===========================================================================================
720
+ // 4. Titled banners, the one structure both languages share
721
+ // ===========================================================================================
722
+
723
+ // A title on a rule of four or more `-` or `=`, or between two rules of eight or more; `sub` adds numbered parts (`4a.`).
724
+ function banners(commentLines, sub = []) {
725
+ const at = new Map(commentLines.map((c) => [c.line, c.text.trim()]));
726
+ const rule = (text) => /^[-=]{8,}$/.test(text ?? "");
727
+ const found = [];
728
+ for (const { line, text: raw } of commentLines) {
729
+ const text = raw.trim();
730
+ const inline = text.match(/^[-=]{4,}\s+(\S.*?)(?:\s+[-=]{4,})?$/) ?? text.match(/^(\S.*?)\s+[-=]{8,}$/);
731
+ if (inline) found.push({ line, title: inline[1], level: 0 });
732
+ else if (!rule(text) && text && rule(at.get(line - 1)) && rule(at.get(line + 1))) found.push({ line: line - 1, title: text, level: 0 });
733
+ }
734
+ return [...found, ...sub.map((s) => ({ ...s, level: 1 }))].sort((a, b) => a.line - b.line);
735
+ }
736
+
737
+ // A section nests in the innermost span holding it, and runs to the next section there or to that span's end.
738
+ function place(entries, sections, lines, mark) {
739
+ const holder = (list, line) => {
740
+ for (const e of list) if (!e.section && e.start < line && line <= e.end) return holder(e.children, line) ?? e;
741
+ return null;
742
+ };
743
+ const groups = new Map();
744
+ for (const section of sections) {
745
+ const h = holder(entries, section.line);
746
+ groups.set(h, [...(groups.get(h) ?? []), section]);
747
+ }
748
+ for (const [h, list] of groups) {
749
+ const target = h ? h.children : entries;
750
+ list.forEach((section, index) => {
751
+ const next = list.slice(index + 1).find((s) => s.level <= section.level);
752
+ target.push({ start: section.line, end: next ? next.line - 1 : h ? h.end : lines, text: cut(`${mark} § ${section.title}`), name: null, children: [], section: true });
753
+ });
754
+ order(target);
755
+ }
756
+ return entries;
757
+ }
758
+
759
+ function order(entries) {
760
+ return entries.sort((a, b) => a.start - b.start || (b.section ? 1 : 0) - (a.section ? 1 : 0) || b.end - a.end);
761
+ }
762
+
763
+ // ===========================================================================================
764
+ // 5. Markdown: headings, and the section under each
765
+ // ===========================================================================================
766
+
767
+ // Headings as CommonMark reads them, outside fences, HTML comments and frontmatter; a setext heading in a list item is missed.
768
+
769
+ /** A heading's text as it reads rendered: a code span's content kept, links and images their text, and emphasis, tags and escapes gone. */
770
+ function plainText(title) {
771
+ const inline = (text) =>
772
+ text
773
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
774
+ .replace(/!?\[([^\]]*)\]\[[^\]]*\]/g, "$1")
775
+ .replace(/<[^>]*>/g, "")
776
+ .replace(/\\([!-/:-@[-`{-~])/g, "$1")
777
+ .replace(/(^|[^\p{L}\p{N}*_])[*_]+|[*_]+(?=[^\p{L}\p{N}*_]|$)/gu, "$1");
778
+ let out = "";
779
+ let at = 0;
780
+ for (const span of title.matchAll(/(`+)(.+?)\1(?!`)/g)) {
781
+ out += inline(title.slice(at, span.index)) + span[2].replace(/^ (.*) $/, "$1");
782
+ at = span.index + span[0].length;
783
+ }
784
+ return out + inline(title.slice(at));
785
+ }
786
+
787
+ /** The anchor GitHub gives a heading. */
788
+ export function anchorOf(title) {
789
+ return plainText(title).toLowerCase().replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, "").replace(/ /g, "-");
790
+ }
791
+
792
+ const loose = (text) => plainText(text).replace(/\s+/g, " ").trim().toLowerCase();
793
+
794
+ // A line that opens a block other than a paragraph, so no `=` or `-` line under it makes it a heading.
795
+ const NOT_PARAGRAPH = /^(?: {4}|\t| {0,3}(?:>|[-*+](?:[ \t]|$)|\d{1,9}[.)](?:[ \t]|$)|<[A-Za-z/!?]|\|))/;
796
+ const RULE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/;
797
+
798
+ /** The outline of a Markdown file: `{lines, bytes, entries}`, an entry per heading, nested by level. */
799
+ export function outlineMd(src) {
800
+ const srcLines = src.split("\n");
801
+ const lines = src.endsWith("\n") ? srcLines.length - 1 : srcLines.length;
802
+ const at = (i) => srcLines[i].replace(/\r$/, "");
803
+ const offsets = [0];
804
+ for (let i = 0; i < lines; i++) offsets.push(offsets[i] + Buffer.byteLength(srcLines[i]) + (i < srcLines.length - 1 ? 1 : 0));
805
+ const found = [];
806
+ let i = 0;
807
+ if (lines > 0 && /^---[ \t]*$/.test(at(0))) {
808
+ let close = 1;
809
+ while (close < lines && !/^(?:---|\.\.\.)[ \t]*$/.test(at(close))) close++;
810
+ if (close < lines) i = close + 1;
811
+ }
812
+ let fence = null;
813
+ let comment = false;
814
+ let para = null; // the line a paragraph opened at, at the margin, while it is open
815
+ let above = "start"; // what the line above was: start, blank, heading, rule, para or other
816
+ for (; i < lines; i++) {
817
+ const line = at(i);
818
+ if (fence) {
819
+ if (fence.test(line)) {
820
+ fence = null;
821
+ above = "rule";
822
+ }
823
+ continue;
824
+ }
825
+ if (comment) {
826
+ if (line.includes("-->")) {
827
+ comment = false;
828
+ above = "rule";
829
+ }
830
+ continue;
831
+ }
832
+ const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
833
+ if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
834
+ fence = new RegExp(`^ {0,3}${open[1][0]}{${open[1].length},}[ \\t]*$`);
835
+ para = null;
836
+ continue;
837
+ }
838
+ if (/^ {0,3}<!--/.test(line)) {
839
+ comment = !line.slice(line.indexOf("<!--") + 4).includes("-->");
840
+ para = null;
841
+ above = "rule";
842
+ continue;
843
+ }
844
+ const atx = /^ {0,3}(#{1,6})(?:[ \t]+|$)(.*)$/.exec(line);
845
+ if (atx) {
846
+ found.push({ start: i + 1, level: atx[1].length, title: atx[2].replace(/(?:^|[ \t]+)#+[ \t]*$/, "").trim() });
847
+ para = null;
848
+ above = "heading";
849
+ continue;
850
+ }
851
+ const underline = /^ {0,3}(=+|-+)[ \t]*$/.exec(line);
852
+ if (underline && para !== null && above === "para") {
853
+ const title = srcLines.slice(para - 1, i).map((l) => l.trim()).join(" ");
854
+ found.push({ start: para, level: underline[1][0] === "=" ? 1 : 2, title });
855
+ para = null;
856
+ above = "heading";
857
+ continue;
858
+ }
859
+ if (line.trim() === "") {
860
+ para = null;
861
+ above = "blank";
862
+ } else if (RULE.test(line)) {
863
+ para = null;
864
+ above = "rule";
865
+ } else if (NOT_PARAGRAPH.test(line)) {
866
+ para = null;
867
+ above = "other";
868
+ } else if (above !== "para" && above !== "other") {
869
+ // A paragraph a setext line may underline opens at the margin; one indented may sit in a list.
870
+ para = /^\S/.test(line) ? i + 1 : null;
871
+ above = para === null ? "other" : "para";
872
+ }
873
+ }
874
+ const taken = new Map();
875
+ const unique = (slug) => {
876
+ let result = slug;
877
+ while (taken.has(result)) {
878
+ taken.set(slug, taken.get(slug) + 1);
879
+ result = `${slug}-${taken.get(slug)}`;
880
+ }
881
+ taken.set(result, 0);
882
+ return result;
883
+ };
884
+ const entries = [];
885
+ const stack = [];
886
+ found.forEach((h, k) => {
887
+ const next = found.slice(k + 1).find((n) => n.level <= h.level);
888
+ const end = next ? next.start - 1 : lines;
889
+ const entry = {
890
+ start: h.start,
891
+ end,
892
+ level: h.level,
893
+ title: h.title,
894
+ anchor: unique(anchorOf(h.title)),
895
+ bytes: offsets[end] - offsets[h.start - 1],
896
+ text: cut(`${"#".repeat(h.level)} ${h.title}`),
897
+ name: null,
898
+ children: [],
899
+ };
900
+ while (stack.length && stack.at(-1).level >= h.level) stack.pop();
901
+ (stack.length ? stack.at(-1).children : entries).push(entry);
902
+ stack.push(entry);
903
+ });
904
+ return { lines, bytes: offsets[lines], entries };
905
+ }
906
+
907
+ /** No heading answers a fragment: what `--find` finding no definition is to code, exit 1. */
908
+ export class NoSection extends CannotOutline {}
909
+
910
+ /** A fragment is a heading's anchor or its text, `Parent > Child` for a repeated one; `NoSection` when none answers. */
911
+ export function sectionOf(src, fragment) {
912
+ const { entries } = outlineMd(src);
913
+ const all = [];
914
+ const walk = (list, chain) => {
915
+ for (const e of list) {
916
+ all.push({ e, chain });
917
+ walk(e.children, [...chain, e]);
918
+ }
919
+ };
920
+ walk(entries, []);
921
+ let want = fragment.trim();
922
+ try {
923
+ want = decodeURIComponent(want);
924
+ } catch {
925
+ // not percent-encoded, so read as written
926
+ }
927
+ let hits = all.filter(({ e }) => e.anchor === want);
928
+ if (hits.length === 0) hits = all.filter(({ e }) => loose(e.title) === loose(want));
929
+ if (hits.length === 0 && want.includes(">")) {
930
+ const parts = want.split(/\s*>\s*/).map(loose);
931
+ hits = all.filter(({ e, chain }) => {
932
+ if (loose(e.title) !== parts.at(-1)) return false;
933
+ let k = 0;
934
+ for (const a of chain) if (k < parts.length - 1 && loose(a.title) === parts[k]) k++;
935
+ return k === parts.length - 1;
936
+ });
937
+ }
938
+ if (hits.length === 0) throw new NoSection(`no heading answers #${fragment}`);
939
+ if (hits.length > 1) throw new CannotOutline(`#${fragment} names ${hits.length} headings: ${hits.map(({ e }) => `#${e.anchor} (line ${e.start})`).join(", ")}`);
940
+ const { e } = hits[0];
941
+ const text = src.split("\n").slice(e.start - 1, e.end).join("\n");
942
+ return { start: e.start, end: e.end, level: e.level, title: e.title, anchor: e.anchor, bytes: e.bytes, text };
943
+ }
944
+
945
+ // ===========================================================================================
946
+ // 6. Files, lookups, and the command line
947
+ // ===========================================================================================
948
+
949
+ /** The outline of one file, or `CannotOutline` saying why there is none; `shown` is the name it gives. */
950
+ export function outlineFile(file, shown = file, src = null) {
951
+ try {
952
+ src ??= fs.readFileSync(file, "utf8");
953
+ } catch (error) {
954
+ throw new CannotOutline(`cannot read ${shown}: ${error.code ?? error.message}`);
955
+ }
956
+ const language = languageOf(file, src.split("\n", 1)[0]);
957
+ if (!language) throw new CannotOutline(`${shown} is not JavaScript, shell or Markdown; read it whole`);
958
+ try {
959
+ const outline = language === "js" ? outlineJs(src) : language === "sh" ? outlineSh(src) : outlineMd(src);
960
+ check(outline);
961
+ return outline;
962
+ } catch (error) {
963
+ if (error instanceof CannotOutline) throw new CannotOutline(`${shown}: ${error.message}; read it as usual`);
964
+ throw error;
965
+ }
966
+ }
967
+
968
+ // Spans nest in their parent, open before they close and never overlap; an outline breaking one is refused.
969
+ function check({ lines, entries }) {
970
+ const walk = (list, lo, hi) => {
971
+ let previous = null;
972
+ for (const e of list) {
973
+ if (!(e.start >= lo && e.start <= e.end && e.end <= hi)) throw new CannotOutline(`a span ${e.start}-${e.end} outside ${lo}-${hi}`);
974
+ if (!e.section && previous && e.start <= previous.end) throw new CannotOutline(`spans ${previous.start}-${previous.end} and ${e.start}-${e.end} overlap`);
975
+ if (!e.section) previous = e;
976
+ walk(e.children, e.start, e.end);
977
+ }
978
+ };
979
+ walk(entries, 1, Math.max(lines, 1));
980
+ }
981
+
982
+ const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
983
+
984
+ /** A heading's line carries its anchor, which names it in `<file>#<anchor>` where its text repeats. */
985
+ export function render(file, { lines, bytes, entries }) {
986
+ const out = [`${file}: ${lines} line${lines === 1 ? "" : "s"}${bytes === undefined ? "" : `, ${grouped(bytes)} B`}`];
987
+ const walk = (list, indent) => {
988
+ for (const e of list) {
989
+ const anchor = e.anchor === undefined ? "" : ` #${e.anchor}`;
990
+ const size = e.bytes === undefined ? "" : ` (${grouped(e.bytes)} B)`;
991
+ out.push(`${indent}${e.start === e.end ? e.start : `${e.start}-${e.end}`} ${e.text}${anchor}${size}`);
992
+ walk(e.children, `${indent} `);
993
+ }
994
+ };
995
+ walk(entries, "");
996
+ return out;
997
+ }
998
+
999
+ /** Tracked JavaScript and shell files, by extension or `#!`, as paths relative to `cwd`. */
1000
+ export function trackedCode(cwd) {
1001
+ let listed;
1002
+ try {
1003
+ listed = execFileSync("git", ["ls-files", "-z"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
1004
+ } catch {
1005
+ throw new CannotOutline("--find reads the tracked code, and this is not a git work tree");
1006
+ }
1007
+ return listed.split("\0").filter(Boolean).filter((file) => {
1008
+ const language = languageOf(file);
1009
+ if (language) return language !== "md";
1010
+ if (path.extname(file) !== "") return false;
1011
+ let fd;
1012
+ try {
1013
+ fd = fs.openSync(path.join(cwd, file), "r");
1014
+ const head = Buffer.alloc(80);
1015
+ const n = fs.readSync(fd, head, 0, 80, 0);
1016
+ return languageOf(file, head.subarray(0, n).toString("utf8").split("\n")[0]) !== null;
1017
+ } catch (error) {
1018
+ if (error.code === "ENOENT" || error.code === "EISDIR") return false;
1019
+ throw new CannotOutline(`cannot read ${file}: ${error.code ?? error.message}`);
1020
+ } finally {
1021
+ if (fd !== undefined) fs.closeSync(fd);
1022
+ }
1023
+ });
1024
+ }
1025
+
1026
+ /** Where a name is defined in the tracked code: `path:start-end text` lines, members as `Class.member` too. */
1027
+ export function find(cwd, name) {
1028
+ const hits = [];
1029
+ for (const file of trackedCode(cwd)) {
1030
+ const full = path.join(cwd, file);
1031
+ let text;
1032
+ try {
1033
+ text = fs.readFileSync(full, "utf8");
1034
+ } catch (error) {
1035
+ // A path gone from the work tree holds no definition; one that cannot be read might, so it is refused.
1036
+ if (error.code === "ENOENT" || error.code === "EISDIR") continue;
1037
+ throw new CannotOutline(`cannot read ${file}: ${error.code ?? error.message}`);
1038
+ }
1039
+ if (!text.includes(name.split(".").at(-1))) continue;
1040
+ const { entries } = outlineFile(full, file, text);
1041
+ const walk = (list, owner) => {
1042
+ for (const e of list) {
1043
+ const names = Array.isArray(e.name) ? e.name : e.name ? [e.name] : [];
1044
+ if (names.includes(name) || (owner && names.some((n) => `${owner}.${n}` === name))) {
1045
+ hits.push(`${file}:${e.start === e.end ? e.start : `${e.start}-${e.end}`} ${e.text}`);
1046
+ }
1047
+ walk(e.children, names[0] ?? null);
1048
+ }
1049
+ };
1050
+ walk(entries, null);
1051
+ }
1052
+ return hits;
1053
+ }
1054
+
1055
+ /** Null for an argument naming a file; a path may hold `#`, so the file is the shortest prefix before a `#` that names one. */
1056
+ function sectionArg(cwd, arg) {
1057
+ const isFile = (rel) => fs.statSync(path.resolve(cwd, rel), { throwIfNoEntry: false })?.isFile() ?? false;
1058
+ if (!arg.includes("#") || isFile(arg)) return null;
1059
+ let at = arg.indexOf("#");
1060
+ while (at !== -1 && !isFile(arg.slice(0, at))) at = arg.indexOf("#", at + 1);
1061
+ const file = at === -1 ? arg.slice(0, arg.indexOf("#")) : arg.slice(0, at);
1062
+ if (at === -1) throw new CannotOutline(`cannot read ${file}: ENOENT`);
1063
+ if (languageOf(file) !== "md") throw new CannotOutline(`${file} is not Markdown, and only a Markdown file is read by #heading`);
1064
+ let src;
1065
+ try {
1066
+ src = fs.readFileSync(path.resolve(cwd, file), "utf8");
1067
+ } catch (error) {
1068
+ throw new CannotOutline(`cannot read ${file}: ${error.code ?? error.message}`);
1069
+ }
1070
+ return { file, src, fragment: arg.slice(at + 1) };
1071
+ }
1072
+
1073
+ export function main(argv, stdout, stderr, cwd = process.cwd()) {
1074
+ const args = argv.slice(2);
1075
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
1076
+ (args.length ? stdout : stderr).write(`symbols: ${USAGE}\n`);
1077
+ return args.length ? 0 : 2;
1078
+ }
1079
+ try {
1080
+ if (args[0] === "--find") {
1081
+ const names = args.slice(1);
1082
+ if (names.length === 0 || names.some((name) => name.startsWith("-"))) {
1083
+ stderr.write(`symbols: ${USAGE}\n`);
1084
+ return 2;
1085
+ }
1086
+ let missing = 0;
1087
+ for (const name of names) {
1088
+ const hits = find(cwd, name);
1089
+ if (hits.length === 0) {
1090
+ stderr.write(`symbols: no definition of ${name} in the tracked code\n`);
1091
+ missing++;
1092
+ }
1093
+ for (const hit of hits) stdout.write(`${hit}\n`);
1094
+ }
1095
+ return missing ? 1 : 0;
1096
+ }
1097
+ const option = args.find((arg) => arg.startsWith("-"));
1098
+ if (option) {
1099
+ stderr.write(`symbols: unknown option ${option}; ${USAGE}\n`);
1100
+ return 2;
1101
+ }
1102
+ const printed = [];
1103
+ let missing = 0;
1104
+ for (const arg of args) {
1105
+ const read = sectionArg(cwd, arg);
1106
+ if (read === null) {
1107
+ printed.push(render(arg, outlineFile(path.resolve(cwd, arg), arg)).join("\n"));
1108
+ continue;
1109
+ }
1110
+ try {
1111
+ const { start, end, bytes, text } = sectionOf(read.src, read.fragment);
1112
+ printed.push(`${read.file}:${start}-${end} (${grouped(bytes)} B)\n${text.replace(/\n$/, "")}`);
1113
+ } catch (error) {
1114
+ if (!(error instanceof NoSection)) throw new CannotOutline(`${read.file}: ${error.message}`);
1115
+ stderr.write(`symbols: ${read.file}: ${error.message}; outline it to see its headings\n`);
1116
+ missing++;
1117
+ }
1118
+ }
1119
+ if (printed.length) stdout.write(`${printed.join("\n\n")}\n`);
1120
+ return missing ? 1 : 0;
1121
+ } catch (error) {
1122
+ // An unexpected throw exits 2, never 1, which would send a session looking for code that exists.
1123
+ const why = error instanceof CannotOutline ? error.message : `unexpected ${error?.name ?? "error"}: ${String(error?.message ?? error).split("\n")[0]}`;
1124
+ stderr.write(`symbols: could not outline — ${why}\n`);
1125
+ return 2;
1126
+ }
1127
+ }
1128
+
1129
+ // Node realpaths `import.meta.url` but not `process.argv[1]`, so run through a symlink this guard does not fire.
1130
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
1131
+ process.exitCode = main(process.argv, process.stdout, process.stderr);
1132
+ }