@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.
- package/README.md +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +882 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +82 -505
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +258 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +343 -27
- package/spec/workspace.schema.json +145 -4
package/cli/comments.mjs
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `comments` — the comments in a tree that record a change's history instead of what the code cannot show.
|
|
3
|
+
//
|
|
4
|
+
// node cli/comments.mjs [--root <dir>] [--limit <count>] [--bytes <count>] [--list] [--exclude <path prefix>]...
|
|
5
|
+
//
|
|
6
|
+
// Exit 0 within the limit and the rail given · 1 over one · 2 could not run.
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { ENGINE_LINE, GATES_LINE, LEADS_LINE } from "./compile.mjs";
|
|
15
|
+
import { NOTE_PERCENT, railFor } from "./context.mjs";
|
|
16
|
+
|
|
17
|
+
export class CommentsError extends Error {}
|
|
18
|
+
|
|
19
|
+
/** `.jsx` and `.tsx` are left unread: JSX text can hold `//` as prose, and no lexer here tells it from a comment. */
|
|
20
|
+
const EXTENSIONS = new Map([
|
|
21
|
+
...[".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsonc"].map((ext) => [ext, "js"]),
|
|
22
|
+
...[".sh", ".bash"].map((ext) => [ext, "shell"]),
|
|
23
|
+
...[".yml", ".yaml"].map((ext) => [ext, "yaml"]),
|
|
24
|
+
[".json", "json"],
|
|
25
|
+
[".md", "markdown"],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function languageOf(file, firstLine = "") {
|
|
29
|
+
const language = EXTENSIONS.get(path.extname(file).toLowerCase());
|
|
30
|
+
if (language) return language;
|
|
31
|
+
if (path.extname(file)) return null;
|
|
32
|
+
if (/^#!.*\b(ba|da|k|z)?sh\b/.test(firstLine)) return "shell";
|
|
33
|
+
if (/^#!.*\bnode\b/.test(firstLine)) return "js";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lineStarts(source) {
|
|
38
|
+
const starts = [0];
|
|
39
|
+
for (let i = source.indexOf("\n"); i !== -1; i = source.indexOf("\n", i + 1)) starts.push(i + 1);
|
|
40
|
+
return starts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lineAt(starts, offset) {
|
|
44
|
+
let low = 0;
|
|
45
|
+
let high = starts.length - 1;
|
|
46
|
+
while (low < high) {
|
|
47
|
+
const mid = (low + high + 1) >> 1;
|
|
48
|
+
if (starts[mid] <= offset) low = mid;
|
|
49
|
+
else high = mid - 1;
|
|
50
|
+
}
|
|
51
|
+
return low;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One entry per source line comments span: its line number, their text, and the bytes deleting them frees. */
|
|
55
|
+
function spansToComments(source, spans, markers) {
|
|
56
|
+
const starts = lineStarts(source);
|
|
57
|
+
const comments = new Map();
|
|
58
|
+
for (const [from, to, strip = markers] of spans) {
|
|
59
|
+
for (let index = lineAt(starts, from); index < starts.length && starts[index] < to; index++) {
|
|
60
|
+
const lineEnd = index + 1 < starts.length ? starts[index + 1] - 1 : source.length;
|
|
61
|
+
const start = Math.max(from, starts[index]);
|
|
62
|
+
const end = Math.min(to, lineEnd);
|
|
63
|
+
const raw = source.slice(start, end);
|
|
64
|
+
const alone = !source.slice(starts[index], start).trim() && !source.slice(end, lineEnd).trim();
|
|
65
|
+
const bytes = alone ? Buffer.byteLength(source.slice(starts[index], lineEnd + 1)) : Buffer.byteLength(raw);
|
|
66
|
+
const text = strip(raw).trim();
|
|
67
|
+
const seen = comments.get(index);
|
|
68
|
+
if (seen) comments.set(index, { ...seen, text: [seen.text, text].filter(Boolean).join(" "), bytes: seen.bytes + bytes });
|
|
69
|
+
else comments.set(index, { line: index + 1, text, bytes });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...comments.values()].sort((a, b) => a.line - b.line);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const JS_MARKERS = (raw) => raw.replace(/^\s*(\/\/+|\/\*+|\*(?!\/))/, "").replace(/\*+\/\s*$/, "");
|
|
76
|
+
const HASH_MARKERS = (raw) => raw.replace(/^\s*(#+|\/\/+)/, "");
|
|
77
|
+
const HTML_MARKERS = (raw) => raw.replace(/^\s*<!--/, "").replace(/-->\s*$/, "");
|
|
78
|
+
|
|
79
|
+
const REGEX_AFTER_WORD = new Set(["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"]);
|
|
80
|
+
const REGEX_AFTER_PUNCTUATOR = new Set([..."(,=:[!&|?{};+-*%<>~^}", "=>"]);
|
|
81
|
+
/** What a `{` follows when it opens an object literal, so the `}` closing it ends a value rather than a block. */
|
|
82
|
+
const OBJECT_AFTER = new Set([..."(,=:[!&|?+-*%<>~^", ...[...REGEX_AFTER_WORD].filter((word) => word !== "do" && word !== "else")]);
|
|
83
|
+
const WORD_START = /[\p{L}_$#]/u;
|
|
84
|
+
const WORD_PART = /[\p{L}\p{N}_$]/u;
|
|
85
|
+
|
|
86
|
+
function skipQuoted(source, i, quote) {
|
|
87
|
+
for (let j = i + 1; j < source.length; j++) {
|
|
88
|
+
if (source[j] === "\\") j++;
|
|
89
|
+
else if (source[j] === quote || source[j] === "\n") return j + 1;
|
|
90
|
+
}
|
|
91
|
+
return source.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function skipRegex(source, i) {
|
|
95
|
+
let inClass = false;
|
|
96
|
+
for (let j = i + 1; j < source.length; j++) {
|
|
97
|
+
const c = source[j];
|
|
98
|
+
if (c === "\\") j++;
|
|
99
|
+
else if (c === "\n") return j;
|
|
100
|
+
else if (c === "[") inClass = true;
|
|
101
|
+
else if (c === "]") inClass = false;
|
|
102
|
+
else if (c === "/" && !inClass) {
|
|
103
|
+
let k = j + 1;
|
|
104
|
+
while (k < source.length && /[a-z]/i.test(source[k])) k++;
|
|
105
|
+
return k;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return source.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** From inside a template literal: the offset after its closing backtick, or after the `${` that interrupts it. */
|
|
112
|
+
function skipTemplate(source, i) {
|
|
113
|
+
for (let j = i; j < source.length; j++) {
|
|
114
|
+
if (source[j] === "\\") j++;
|
|
115
|
+
else if (source[j] === "`") return { end: j + 1, open: false };
|
|
116
|
+
else if (source[j] === "$" && source[j + 1] === "{") return { end: j + 2, open: true };
|
|
117
|
+
}
|
|
118
|
+
return { end: source.length, open: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function jsSpans(source) {
|
|
122
|
+
const spans = [];
|
|
123
|
+
const resumeAt = [];
|
|
124
|
+
const objects = [];
|
|
125
|
+
let depth = 0;
|
|
126
|
+
let last = "";
|
|
127
|
+
let i = source.startsWith("#!") ? source.indexOf("\n") : 0;
|
|
128
|
+
if (i === -1) return [];
|
|
129
|
+
const enterTemplate = (from) => {
|
|
130
|
+
const { end, open } = skipTemplate(source, from);
|
|
131
|
+
if (open) {
|
|
132
|
+
resumeAt.push(depth);
|
|
133
|
+
depth++;
|
|
134
|
+
last = "{";
|
|
135
|
+
} else last = "value";
|
|
136
|
+
return end;
|
|
137
|
+
};
|
|
138
|
+
while (i < source.length) {
|
|
139
|
+
const c = source[i];
|
|
140
|
+
const next = source[i + 1];
|
|
141
|
+
if (/\s/.test(c)) i++;
|
|
142
|
+
else if (c === "/" && next === "/") {
|
|
143
|
+
const end = source.indexOf("\n", i);
|
|
144
|
+
spans.push([i, end === -1 ? source.length : end]);
|
|
145
|
+
i = end === -1 ? source.length : end;
|
|
146
|
+
} else if (c === "/" && next === "*") {
|
|
147
|
+
const close = source.indexOf("*/", i + 2);
|
|
148
|
+
const end = close === -1 ? source.length : close + 2;
|
|
149
|
+
spans.push([i, end]);
|
|
150
|
+
i = end;
|
|
151
|
+
} else if (c === "'" || c === '"') {
|
|
152
|
+
i = skipQuoted(source, i, c);
|
|
153
|
+
last = "value";
|
|
154
|
+
} else if (c === "`") i = enterTemplate(i + 1);
|
|
155
|
+
else if (c === "/") {
|
|
156
|
+
const regex = last === "" || REGEX_AFTER_PUNCTUATOR.has(last) || REGEX_AFTER_WORD.has(last);
|
|
157
|
+
i = regex ? skipRegex(source, i) : i + 1;
|
|
158
|
+
last = regex ? "value" : "/";
|
|
159
|
+
} else if (c === "{") {
|
|
160
|
+
objects.push(OBJECT_AFTER.has(last));
|
|
161
|
+
depth++;
|
|
162
|
+
last = c;
|
|
163
|
+
i++;
|
|
164
|
+
} else if (c === "}") {
|
|
165
|
+
depth--;
|
|
166
|
+
i++;
|
|
167
|
+
if (resumeAt.length && resumeAt.at(-1) === depth) {
|
|
168
|
+
resumeAt.pop();
|
|
169
|
+
i = enterTemplate(i);
|
|
170
|
+
} else last = objects.pop() ? "value" : c;
|
|
171
|
+
} else if (WORD_START.test(c) || /\d/.test(c)) {
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
while (j < source.length && (WORD_PART.test(source[j]) || (/\d/.test(c) && source[j] === "."))) j++;
|
|
174
|
+
const word = source.slice(i, j);
|
|
175
|
+
last = REGEX_AFTER_WORD.has(word) ? word : "value";
|
|
176
|
+
i = j;
|
|
177
|
+
} else if ((c === "+" || c === "-") && next === c) {
|
|
178
|
+
last = "value";
|
|
179
|
+
i += 2;
|
|
180
|
+
} else {
|
|
181
|
+
last = c === ")" || c === "]" ? "value" : c === ">" && source[i - 1] === "=" ? "=>" : c;
|
|
182
|
+
i++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return spans;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function jsComments(source) {
|
|
189
|
+
return spansToComments(source, jsSpans(source), JS_MARKERS);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const SHELL_WORD_BREAK = new Set([..." \t\n;&|()<>"]);
|
|
193
|
+
const WRAPPERS = new Set(["command", "exec", "env"]);
|
|
194
|
+
const RESERVED = new Set(["if", "then", "else", "elif", "do", "while", "until", "!", "{", "time"]);
|
|
195
|
+
const ASSIGNMENT = /^[A-Za-z_]\w*=/;
|
|
196
|
+
const HEREDOC = /^<<(-?)[ \t]*((?:'[^'\n]*'|"[^"\n]*"|\\.|[^\s;&|<>()'"\\])+)/;
|
|
197
|
+
|
|
198
|
+
const unquoted = (word) => word.replace(/["'\\]/g, "");
|
|
199
|
+
|
|
200
|
+
const endOfLine = (source, i) => (source.includes("\n", i) ? source.indexOf("\n", i) : source.length);
|
|
201
|
+
|
|
202
|
+
const AWK_WORD = /[\p{L}\p{N}_.]/u;
|
|
203
|
+
const AWK_REGEX_AFTER = new Set(["print", "printf", "return", "case", "do", "else"]);
|
|
204
|
+
|
|
205
|
+
/** awk's comments: `#` to the line's end outside strings and regular expressions, which cannot run across lines. */
|
|
206
|
+
function awkSpans(program) {
|
|
207
|
+
const spans = [];
|
|
208
|
+
let divides = false;
|
|
209
|
+
for (let i = 0; i < program.length; ) {
|
|
210
|
+
const c = program[i];
|
|
211
|
+
if (c === "#") {
|
|
212
|
+
spans.push([i, endOfLine(program, i)]);
|
|
213
|
+
i = endOfLine(program, i);
|
|
214
|
+
} else if (c === '"' || (c === "/" && !divides)) {
|
|
215
|
+
i = c === '"' ? skipQuoted(program, i, c) : skipRegex(program, i);
|
|
216
|
+
divides = true;
|
|
217
|
+
} else if (AWK_WORD.test(c)) {
|
|
218
|
+
const start = i;
|
|
219
|
+
while (i < program.length && AWK_WORD.test(program[i])) i++;
|
|
220
|
+
divides = !AWK_REGEX_AFTER.has(program.slice(start, i));
|
|
221
|
+
} else if ((c === "+" || c === "-") && program[i + 1] === c) i += 2;
|
|
222
|
+
else {
|
|
223
|
+
if (c !== " " && c !== "\t") divides = c === ")" || c === "]";
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return spans;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* sed's comments: `#` where a command may start or once one ends, outside its addresses, its `s` and `y`
|
|
232
|
+
* operands, and the text, labels and file names its commands take.
|
|
233
|
+
*/
|
|
234
|
+
function sedSpans(program) {
|
|
235
|
+
const spans = [];
|
|
236
|
+
const delimited = (at, count) => {
|
|
237
|
+
let j = at + 1;
|
|
238
|
+
for (; j < program.length && program[j] !== "\n"; j++) {
|
|
239
|
+
if (program[j] === "\\") j++;
|
|
240
|
+
else if (program[j] === program[at] && --count === 0) return j + 1;
|
|
241
|
+
}
|
|
242
|
+
return j;
|
|
243
|
+
};
|
|
244
|
+
for (let i = 0; i < program.length; ) {
|
|
245
|
+
const c = program[i];
|
|
246
|
+
if (c === "#") {
|
|
247
|
+
spans.push([i, endOfLine(program, i)]);
|
|
248
|
+
i = endOfLine(program, i);
|
|
249
|
+
} else if (c === "/") i = delimited(i, 1);
|
|
250
|
+
else if (c === "\\") i = delimited(i + 1, 1);
|
|
251
|
+
else if (c === "s" || c === "y") {
|
|
252
|
+
i = delimited(i + 1, 2);
|
|
253
|
+
while (c === "s" && /[gpPeimIM\d]/.test(program[i] ?? "")) i++;
|
|
254
|
+
if (c === "s" && program[i] === "w") i = endOfLine(program, i);
|
|
255
|
+
} else if ("aic".includes(c)) i += /^(?:\\[^]|[^\\\n])*/.exec(program.slice(i))[0].length;
|
|
256
|
+
else if ("rRwWe".includes(c)) i = endOfLine(program, i);
|
|
257
|
+
else if (":btT".includes(c)) {
|
|
258
|
+
const stop = program.slice(i).search(/[;\n]/);
|
|
259
|
+
i = stop === -1 ? program.length : i + stop;
|
|
260
|
+
} else i++;
|
|
261
|
+
}
|
|
262
|
+
return spans;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** jq's comments: `#` to the line's end outside strings, which may run across lines and interpolate `\( )`. */
|
|
266
|
+
function jqSpans(program) {
|
|
267
|
+
const spans = [];
|
|
268
|
+
const resume = [];
|
|
269
|
+
let depth = 0;
|
|
270
|
+
let quoted = false;
|
|
271
|
+
for (let i = 0; i < program.length; i++) {
|
|
272
|
+
const c = program[i];
|
|
273
|
+
if (quoted) {
|
|
274
|
+
if (c === "\\" && program[i + 1] === "(") {
|
|
275
|
+
resume.push(depth++);
|
|
276
|
+
quoted = false;
|
|
277
|
+
i++;
|
|
278
|
+
} else if (c === "\\") i++;
|
|
279
|
+
else if (c === '"') quoted = false;
|
|
280
|
+
} else if (c === '"') quoted = true;
|
|
281
|
+
else if (c === "(") depth++;
|
|
282
|
+
else if (c === ")" && --depth === resume.at(-1)) {
|
|
283
|
+
resume.pop();
|
|
284
|
+
quoted = true;
|
|
285
|
+
} else if (c === "#") {
|
|
286
|
+
const end = program.indexOf("\n", i);
|
|
287
|
+
spans.push([i, end === -1 ? program.length : end]);
|
|
288
|
+
i = end === -1 ? program.length : end;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return spans;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const AWKS = ["awk", "gawk", "mawk", "nawk"];
|
|
295
|
+
const SHELLS = ["bash", "sh", "zsh", "dash", "ksh"];
|
|
296
|
+
|
|
297
|
+
/** Python, Perl and Ruby programs stay data: their strings run across lines, and no lexer here follows them. */
|
|
298
|
+
const INTERPRETERS = new Map([
|
|
299
|
+
["node", { flags: ["-e", "-p", "--eval", "--print"], spans: (program) => jsSpans(program).map(([from, to]) => [from, to, JS_MARKERS]) }],
|
|
300
|
+
...SHELLS.map((shell) => [shell, { flags: ["-c"], spans: shellSpans }]),
|
|
301
|
+
...AWKS.map((awk) => [awk, { flags: ["-e", "--source"], operand: true, spans: awkSpans }]),
|
|
302
|
+
["sed", { flags: ["-e", "--expression"], operand: true, spans: sedSpans }],
|
|
303
|
+
["jq", { flags: [], operand: true, spans: jqSpans }],
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
const interpreterOf = (word) => INTERPRETERS.get(path.posix.basename(unquoted(word ?? "")));
|
|
307
|
+
|
|
308
|
+
/** The interpreter a quoted word is the program of: after a flag taking one, or as awk's, jq's or sed's operand. */
|
|
309
|
+
function programOf(words, before) {
|
|
310
|
+
const flag = unquoted(before);
|
|
311
|
+
if (flag.startsWith("-")) {
|
|
312
|
+
const interpreter = interpreterOf((before === words.at(-1) ? words.slice(0, -1) : words).findLast((word) => !word.startsWith("-")));
|
|
313
|
+
if (interpreter?.flags.includes(flag)) return interpreter;
|
|
314
|
+
}
|
|
315
|
+
let at = 0;
|
|
316
|
+
while (ASSIGNMENT.test(words[at] ?? "")) at++;
|
|
317
|
+
while (WRAPPERS.has(words[at])) {
|
|
318
|
+
at++;
|
|
319
|
+
while (/^-|^[A-Za-z_]\w*=/.test(words[at] ?? "")) at++;
|
|
320
|
+
}
|
|
321
|
+
const command = at < words.length ? interpreterOf(words[at]) : undefined;
|
|
322
|
+
return command?.operand ? command : null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** From `((`, the offset after the `))` closing it on the same line: `<<` inside is a shift, not a heredoc. */
|
|
326
|
+
function arithmeticEnd(source, i) {
|
|
327
|
+
let depth = 0;
|
|
328
|
+
for (let j = i; j < source.length && source[j] !== "\n"; j++) {
|
|
329
|
+
if (source[j] === "(") depth++;
|
|
330
|
+
else if (source[j] === ")" && --depth === 0) return j + 1;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function shellSpans(source) {
|
|
336
|
+
const spans = [];
|
|
337
|
+
const heredocs = [];
|
|
338
|
+
const skipHeredocBodies = (from) => {
|
|
339
|
+
let at = from;
|
|
340
|
+
for (const { word, tabs } of heredocs.splice(0)) {
|
|
341
|
+
while (at < source.length) {
|
|
342
|
+
const end = source.indexOf("\n", at);
|
|
343
|
+
const lineEnd = end === -1 ? source.length : end;
|
|
344
|
+
const text = source.slice(at, lineEnd);
|
|
345
|
+
at = lineEnd + 1;
|
|
346
|
+
if ((tabs ? text.replace(/^\t+/, "") : text) === word) break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return at;
|
|
350
|
+
};
|
|
351
|
+
/** A double-quoted word: `$( )` and backticks in it are shell, and a program it holds is read as the shell passes it on. */
|
|
352
|
+
const doubleQuoted = (i, program) => {
|
|
353
|
+
const text = [];
|
|
354
|
+
const at = [];
|
|
355
|
+
const keep = (from, to, blank) => {
|
|
356
|
+
for (let k = from; k < to; k++) {
|
|
357
|
+
text.push(blank && source[k] !== "\n" ? " " : source[k]);
|
|
358
|
+
at.push(k);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
let j = i + 1;
|
|
362
|
+
while (j < source.length && source[j] !== '"') {
|
|
363
|
+
if (source[j] === "\\") {
|
|
364
|
+
keep('$`"\\\n'.includes(source[j + 1]) ? j + 1 : j, Math.min(j + 2, source.length), false);
|
|
365
|
+
j += 2;
|
|
366
|
+
} else if (source[j] === "`" || (source[j] === "$" && source[j + 1] === "(" && source[j + 2] !== "(")) {
|
|
367
|
+
const from = j;
|
|
368
|
+
j = source[j] === "`" ? lex(j + 1, "`") : lex(j + 2, ")");
|
|
369
|
+
keep(from, j, true);
|
|
370
|
+
} else keep(j, ++j, false);
|
|
371
|
+
}
|
|
372
|
+
if (program) spans.push(...program.spans(text.join("")).map(([a, b, strip]) => [at[a], at[b - 1] + 1, strip]));
|
|
373
|
+
return j + 1;
|
|
374
|
+
};
|
|
375
|
+
/** Shell from `from` to its closing `)` or backtick, or the end: the offset after the close. */
|
|
376
|
+
function lex(from, closer) {
|
|
377
|
+
const words = [];
|
|
378
|
+
let start = -1;
|
|
379
|
+
let depth = 0;
|
|
380
|
+
const endWord = (at) => {
|
|
381
|
+
if (start !== -1 && (words.length || !RESERVED.has(source.slice(start, at)))) words.push(source.slice(start, at));
|
|
382
|
+
start = -1;
|
|
383
|
+
};
|
|
384
|
+
let i = from;
|
|
385
|
+
while (i < source.length) {
|
|
386
|
+
const c = source[i];
|
|
387
|
+
const arithmetic = c === "(" && source[i + 1] === "(" ? arithmeticEnd(source, i) : null;
|
|
388
|
+
if (c === closer && (closer === "`" || depth === 0)) return i + 1;
|
|
389
|
+
if (c === "\n") {
|
|
390
|
+
endWord(i);
|
|
391
|
+
words.length = 0;
|
|
392
|
+
i = heredocs.length ? skipHeredocBodies(i + 1) : i + 1;
|
|
393
|
+
} else if (c === "\\") {
|
|
394
|
+
if (source[i + 1] !== "\n" && start === -1) start = i;
|
|
395
|
+
i += 2;
|
|
396
|
+
} else if (c === "#" && (i === from || SHELL_WORD_BREAK.has(source[i - 1]))) {
|
|
397
|
+
const end = source.indexOf("\n", i);
|
|
398
|
+
spans.push([i, end === -1 ? source.length : end]);
|
|
399
|
+
i = end === -1 ? source.length : end;
|
|
400
|
+
} else if (c === "'") {
|
|
401
|
+
const close = source.indexOf("'", i + 1);
|
|
402
|
+
const end = close === -1 ? source.length : close;
|
|
403
|
+
const before = start === -1 ? (words.at(-1) ?? "") : source.slice(start, i).replace(/=$/, "");
|
|
404
|
+
const program = programOf(words, before);
|
|
405
|
+
if (program) spans.push(...program.spans(source.slice(i + 1, end)).map(([a, b, strip]) => [a + i + 1, b + i + 1, strip]));
|
|
406
|
+
if (start === -1) start = i;
|
|
407
|
+
i = end + 1;
|
|
408
|
+
} else if (arithmetic !== null) i = arithmetic;
|
|
409
|
+
else if (source.startsWith("<<<", i)) {
|
|
410
|
+
endWord(i);
|
|
411
|
+
i += 3;
|
|
412
|
+
} else if (source.startsWith("<<", i)) {
|
|
413
|
+
endWord(i);
|
|
414
|
+
const lineEnd = source.indexOf("\n", i);
|
|
415
|
+
const heredoc = HEREDOC.exec(source.slice(i, lineEnd === -1 ? source.length : lineEnd));
|
|
416
|
+
if (heredoc) {
|
|
417
|
+
const word = heredoc[2].replace(/'([^']*)'|"([^"]*)"|\\(.)/g, (_, single, double, escaped) => single ?? double ?? escaped);
|
|
418
|
+
heredocs.push({ word, tabs: heredoc[1] === "-" });
|
|
419
|
+
i += heredoc[0].length;
|
|
420
|
+
} else i += 2;
|
|
421
|
+
} else if (SHELL_WORD_BREAK.has(c)) {
|
|
422
|
+
endWord(i);
|
|
423
|
+
if (c === "(") depth++;
|
|
424
|
+
else if (c === ")" && depth > 0) depth--;
|
|
425
|
+
const redirect = c === "&" && (/[<>]/.test(source[i - 1]) || source[i + 1] === ">");
|
|
426
|
+
if (!" \t<>".includes(c) && !redirect) words.length = 0;
|
|
427
|
+
i++;
|
|
428
|
+
} else {
|
|
429
|
+
if (start === -1) start = i;
|
|
430
|
+
if (c === '"') i = doubleQuoted(i, programOf(words, start === i ? (words.at(-1) ?? "") : source.slice(start, i).replace(/=$/, "")));
|
|
431
|
+
else if (c === "$" && source[i + 1] === "(" && source[i + 2] !== "(") i = lex(i + 2, ")");
|
|
432
|
+
else if (c === "`" || (c === "$" && source[i + 1] === "'")) {
|
|
433
|
+
const quote = c === "$" ? "'" : c;
|
|
434
|
+
let j = c === "$" ? i + 2 : i + 1;
|
|
435
|
+
while (j < source.length && source[j] !== quote) j += source[j] === "\\" ? 2 : 1;
|
|
436
|
+
i = j + 1;
|
|
437
|
+
} else i++;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return source.length;
|
|
441
|
+
}
|
|
442
|
+
const from = source.startsWith("#!") ? source.indexOf("\n") : 0;
|
|
443
|
+
if (from === -1) return [];
|
|
444
|
+
lex(from, null);
|
|
445
|
+
return spans;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function shellComments(source) {
|
|
449
|
+
return spansToComments(source, shellSpans(source), HASH_MARKERS);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const BLOCK_SCALAR = /^(\s*)(?:-\s+)?(?:([\w.-]+)\s*:\s*)?[|>][-+0-9]*\s*(?:#.*)?$/;
|
|
453
|
+
|
|
454
|
+
/** A YAML file's comments, and the shell comments of each `run:` block, by their lines in the file. */
|
|
455
|
+
export function yamlComments(source) {
|
|
456
|
+
const lines = source.split("\n");
|
|
457
|
+
const comments = [];
|
|
458
|
+
let quote = null;
|
|
459
|
+
for (let index = 0; index < lines.length; index++) {
|
|
460
|
+
const text = lines[index];
|
|
461
|
+
const inScalar = quote !== null;
|
|
462
|
+
const { hash, open } = yamlCommentAt(text, quote);
|
|
463
|
+
quote = open;
|
|
464
|
+
if (hash !== -1) {
|
|
465
|
+
const raw = text.slice(hash);
|
|
466
|
+
const alone = !text.slice(0, hash).trim();
|
|
467
|
+
comments.push({ line: index + 1, text: HASH_MARKERS(raw).trim(), bytes: Buffer.byteLength(alone ? `${text}${index < lines.length - 1 ? "\n" : ""}` : raw) });
|
|
468
|
+
}
|
|
469
|
+
if (inScalar) continue;
|
|
470
|
+
const block = BLOCK_SCALAR.exec(text);
|
|
471
|
+
if (!block) continue;
|
|
472
|
+
const indent = block[1].length + (block[2] && /^\s*-\s/.test(text) ? text.slice(block[1].length).search(/[^-\s]/) : 0);
|
|
473
|
+
let end = index + 1;
|
|
474
|
+
while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > indent)) end++;
|
|
475
|
+
if (block[2] === "run") {
|
|
476
|
+
const body = lines.slice(index + 1, end);
|
|
477
|
+
const margin = Math.min(...body.filter((l) => l.trim()).map((l) => l.search(/\S/)));
|
|
478
|
+
const shift = Number.isFinite(margin) ? margin : 0;
|
|
479
|
+
const script = body.map((l) => l.slice(shift));
|
|
480
|
+
const ended = (line) => (line < script.length || end < lines.length ? "\n" : "");
|
|
481
|
+
for (const comment of shellComments(`${script.join("\n")}${ended(script.length)}`)) {
|
|
482
|
+
const alone = comment.bytes === Buffer.byteLength(`${script[comment.line - 1]}${ended(comment.line)}`);
|
|
483
|
+
comments.push({ ...comment, line: comment.line + index + 1, bytes: comment.bytes + (alone ? shift : 0) });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
index = end - 1;
|
|
487
|
+
}
|
|
488
|
+
return comments;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Where a quoted scalar opens that can run on to later lines: a quote inside a plain scalar cannot. */
|
|
492
|
+
const SCALAR_START = /(?:^\s*(?:-\s+)*|:\s+)$/;
|
|
493
|
+
|
|
494
|
+
function yamlCommentAt(text, carried) {
|
|
495
|
+
let quote = carried;
|
|
496
|
+
let carries = carried !== null;
|
|
497
|
+
for (let i = 0; i < text.length; i++) {
|
|
498
|
+
const c = text[i];
|
|
499
|
+
if (quote) {
|
|
500
|
+
if (c === "\\" && quote === '"') i++;
|
|
501
|
+
else if (c === "'" && quote === "'" && text[i + 1] === "'") i++;
|
|
502
|
+
else if (c === quote) quote = null;
|
|
503
|
+
} else if (c === "'" || c === '"') {
|
|
504
|
+
if (i === 0 || /[\s:[{,-]/.test(text[i - 1])) {
|
|
505
|
+
quote = c;
|
|
506
|
+
carries = SCALAR_START.test(text.slice(0, i));
|
|
507
|
+
}
|
|
508
|
+
} else if (c === "#" && (i === 0 || /\s/.test(text[i - 1]))) return { hash: i, open: null };
|
|
509
|
+
}
|
|
510
|
+
return { hash: -1, open: quote !== null && carries ? quote : null };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const COMMENT_KEY = /^(\/\/|\$comment|_(?:\w+_)?(?:comments?|notes?))$/;
|
|
514
|
+
|
|
515
|
+
function keyOf(quoted) {
|
|
516
|
+
try {
|
|
517
|
+
return JSON.parse(quoted);
|
|
518
|
+
} catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The strings a JSON file keeps under a comment key, such as `$comment` or `_comment`, one entry per line. */
|
|
524
|
+
export function jsonComments(source) {
|
|
525
|
+
const spans = [];
|
|
526
|
+
const containers = [];
|
|
527
|
+
let key = null;
|
|
528
|
+
let expectingKey = false;
|
|
529
|
+
let inComment = 0;
|
|
530
|
+
for (let i = 0; i < source.length; i++) {
|
|
531
|
+
const c = source[i];
|
|
532
|
+
if (c === '"') {
|
|
533
|
+
const end = skipQuoted(source, i, '"');
|
|
534
|
+
if (expectingKey) {
|
|
535
|
+
key = keyOf(source.slice(i, end));
|
|
536
|
+
expectingKey = false;
|
|
537
|
+
} else if (inComment) spans.push([i + 1, end - 1]);
|
|
538
|
+
i = end - 1;
|
|
539
|
+
} else if (c === "{" || c === "[") {
|
|
540
|
+
const comment = inComment > 0 || (containers.at(-1) === "{" && COMMENT_KEY.test(key ?? ""));
|
|
541
|
+
containers.push(c);
|
|
542
|
+
if (comment) inComment++;
|
|
543
|
+
expectingKey = c === "{";
|
|
544
|
+
} else if (c === "}" || c === "]") {
|
|
545
|
+
containers.pop();
|
|
546
|
+
if (inComment) inComment--;
|
|
547
|
+
} else if (c === ",") expectingKey = containers.at(-1) === "{";
|
|
548
|
+
else if (c === ":" && containers.at(-1) === "{" && COMMENT_KEY.test(key ?? "") && !inComment) {
|
|
549
|
+
const value = /^\s*"/.exec(source.slice(i + 1));
|
|
550
|
+
if (value) {
|
|
551
|
+
const start = i + 1 + value[0].length - 1;
|
|
552
|
+
const end = skipQuoted(source, start, '"');
|
|
553
|
+
spans.push([start + 1, end - 1]);
|
|
554
|
+
i = end - 1;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return spansToComments(source, spans, (raw) => raw);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A line a tool reads: any `<!-- portulan: … -->`, or one `compile` expands, as `<!-- leads: ../dod.md -->`. */
|
|
562
|
+
const PORTULAN_LINE = /^\s*<!--\s*portulan:\s[^\n]*?\s*-->\s*$/;
|
|
563
|
+
const toolReads = (line) => PORTULAN_LINE.test(line) || [LEADS_LINE, ENGINE_LINE, GATES_LINE].some((form) => form.test(line.replace(/\r$/, "")));
|
|
564
|
+
|
|
565
|
+
const FENCE_OPEN = /^[ \t]*(`{3,}(?!.*`)|~{3,})/;
|
|
566
|
+
const LIST_ITEM = /^[ \t]*([-+*]|\d{1,9}[.)])([ \t]+|\r?$)/;
|
|
567
|
+
const HEADING = /^[ \t]*#{1,6}(?:[ \t]|\r?$)/;
|
|
568
|
+
|
|
569
|
+
function indentOf(line) {
|
|
570
|
+
let columns = 0;
|
|
571
|
+
for (const c of line) {
|
|
572
|
+
if (c === " ") columns++;
|
|
573
|
+
else if (c === "\t") columns += 4 - (columns % 4);
|
|
574
|
+
else break;
|
|
575
|
+
}
|
|
576
|
+
return columns;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function backtickString(text, from, length) {
|
|
580
|
+
for (let at = text.indexOf("`", from); at !== -1; at = text.indexOf("`", at)) {
|
|
581
|
+
const start = at;
|
|
582
|
+
while (text[at] === "`") at++;
|
|
583
|
+
if (at - start === length) return start;
|
|
584
|
+
}
|
|
585
|
+
return -1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** A line with its code spans blanked, and the backtick string of one it leaves open to a later line. */
|
|
589
|
+
function blankCodeSpans(line, open, later) {
|
|
590
|
+
let visible = line;
|
|
591
|
+
let from = 0;
|
|
592
|
+
const blank = (start, end) => (visible = visible.slice(0, start) + " ".repeat(end - start) + visible.slice(end));
|
|
593
|
+
if (open) {
|
|
594
|
+
const close = backtickString(line, 0, open.length);
|
|
595
|
+
if (close === -1) return { visible: " ".repeat(line.length), open };
|
|
596
|
+
from = close + open.length;
|
|
597
|
+
blank(0, from);
|
|
598
|
+
}
|
|
599
|
+
for (let at = line.indexOf("`", from); at !== -1; at = line.indexOf("`", from)) {
|
|
600
|
+
let end = at;
|
|
601
|
+
while (line[end] === "`") end++;
|
|
602
|
+
const close = backtickString(line, end, end - at);
|
|
603
|
+
if (close !== -1) {
|
|
604
|
+
from = close + end - at;
|
|
605
|
+
blank(at, from);
|
|
606
|
+
} else if (later().some((text) => backtickString(text, 0, end - at) !== -1)) {
|
|
607
|
+
blank(at, line.length);
|
|
608
|
+
return { visible, open: line.slice(at, end) };
|
|
609
|
+
} else from = end;
|
|
610
|
+
}
|
|
611
|
+
return { visible, open: null };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A Markdown file's HTML comments, outside fenced and indented code and code spans, and other than a tool's
|
|
616
|
+
* directives. A list item's content, and a block quote's, is indented from its own margin.
|
|
617
|
+
*/
|
|
618
|
+
export function markdownComments(source) {
|
|
619
|
+
const spans = [];
|
|
620
|
+
const lines = source.split("\n");
|
|
621
|
+
const paragraphAfter = (index) => {
|
|
622
|
+
let end = index + 1;
|
|
623
|
+
while (end < lines.length && lines[end].trim() && !(indentOf(lines[end]) <= 3 && FENCE_OPEN.test(lines[end]))) end++;
|
|
624
|
+
return lines.slice(index + 1, end);
|
|
625
|
+
};
|
|
626
|
+
const items = [];
|
|
627
|
+
let paragraph = false;
|
|
628
|
+
let fence = null;
|
|
629
|
+
let open = -1;
|
|
630
|
+
let code = null;
|
|
631
|
+
let offset = 0;
|
|
632
|
+
for (const [index, line] of lines.entries()) {
|
|
633
|
+
const lineStart = offset;
|
|
634
|
+
offset += line.length + 1;
|
|
635
|
+
if (open === -1) {
|
|
636
|
+
const body = line.replace(/^(?: {0,3}> ?)+/, "");
|
|
637
|
+
if (fence) {
|
|
638
|
+
if (fence.close.test(fence.quoted ? body : line)) fence = null;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (!body.trim()) {
|
|
642
|
+
paragraph = false;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const columns = indentOf(body);
|
|
646
|
+
const item = LIST_ITEM.exec(body);
|
|
647
|
+
if (item || !paragraph) while (items.at(-1) > columns) items.pop();
|
|
648
|
+
const block = columns - (items.at(-1) ?? 0) <= 3;
|
|
649
|
+
if (!paragraph && !block) continue;
|
|
650
|
+
const opening = block && FENCE_OPEN.exec(body)?.[1];
|
|
651
|
+
if (opening) {
|
|
652
|
+
fence = { close: new RegExp(`^[ \\t]*${opening[0]}{${opening.length},}[ \\t\\r]*$`), quoted: body !== line };
|
|
653
|
+
code = null;
|
|
654
|
+
paragraph = false;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const gap = item?.[2].replace("\r", "").length;
|
|
658
|
+
if (item && block) items.push(columns + item[1].length + (gap >= 1 && gap <= 4 ? gap : 1));
|
|
659
|
+
paragraph = !(block && (HEADING.test(body) || /^[ \t]*<!--/.test(body)));
|
|
660
|
+
}
|
|
661
|
+
let visible = line;
|
|
662
|
+
if (open === -1) ({ visible, open: code } = blankCodeSpans(line, code, () => paragraphAfter(index)));
|
|
663
|
+
else code = null;
|
|
664
|
+
let from = 0;
|
|
665
|
+
while (from <= visible.length) {
|
|
666
|
+
if (open === -1) {
|
|
667
|
+
const start = visible.indexOf("<!--", from);
|
|
668
|
+
if (start === -1) break;
|
|
669
|
+
open = lineStart + start;
|
|
670
|
+
from = start + 4;
|
|
671
|
+
}
|
|
672
|
+
const close = visible.indexOf("-->", from);
|
|
673
|
+
if (close === -1) break;
|
|
674
|
+
if (!toolReads(line)) spans.push([open, lineStart + close + 3]);
|
|
675
|
+
open = -1;
|
|
676
|
+
from = close + 3;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (open !== -1) spans.push([open, source.length]);
|
|
680
|
+
return spansToComments(source, spans, HTML_MARKERS);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export const LEXERS = { js: jsComments, shell: shellComments, yaml: yamlComments, json: jsonComments, markdown: markdownComments };
|
|
684
|
+
|
|
685
|
+
export function commentsOf(source, language) {
|
|
686
|
+
return LEXERS[language](source);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const NUMBER_WORD = "(?:\\d+|one|two|three|four|five|six|seven|eight|nine|ten)";
|
|
690
|
+
const CREDITED = "(?:[Rr]aised|[Ff]ound|[Cc]aught|[Ff]lagged|[Rr]eported|[Ss]potted|[Ss]urfaced)";
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* What marks a comment as a change's history, by kind. A `prose` pattern skips text in backticks, which
|
|
694
|
+
* quotes a value rather than dating or citing the code: a date there is an example, as in `2026-02-30`.
|
|
695
|
+
*/
|
|
696
|
+
export const KINDS = [
|
|
697
|
+
{
|
|
698
|
+
kind: "date",
|
|
699
|
+
prose: /(?<!\b(?:[Vv]ersion|api-version|compatibility_date)["'\s:=]{0,4})\b(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:\b|(?=T\d))/gu,
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
kind: "plan",
|
|
703
|
+
raw: /\b(?:[Pp]roposals?\s+`?\d{2,4}\b|[Mm]ilestones?[\s-]+\d+\b|rows?\s+\d+\s+clause\b|clause\s+\([a-z]\))/gu,
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
kind: "review",
|
|
707
|
+
raw: new RegExp(
|
|
708
|
+
[
|
|
709
|
+
`\\bCopilot(?:'s)?\\b[^.;:]{0,40}?\\brounds?\\s+${NUMBER_WORD}\\b`,
|
|
710
|
+
`\\brounds?\\s+\\d+\\s+(?:on|of)\\s+#\\d{2,}`,
|
|
711
|
+
`#\\d{2,},?\\s+rounds?\\s+${NUMBER_WORD}\\b`,
|
|
712
|
+
`\\b${CREDITED}\\s+(?:by|in)\\s+(?:a\\s+|the\\s+)?(?:Copilot|review)\\b`,
|
|
713
|
+
`\\bCopilot(?:'s)?\\s+(?:${CREDITED.toLowerCase()}|round|rounds|note|notes|finding|findings|named|promoted|proposed|pointed)\\b`,
|
|
714
|
+
`\\(Copilot\\b[^)]{0,40}?\\bround\\b|\\bCopilot,\\s+(?:on\\s+)?the\\s+round|\\bCopilot's\\s+\\w+\\s+notes?\\b`,
|
|
715
|
+
`\\b[Rr]ounds?\\s+\\d+(?:'s\\b|\\s+(?:found|fixed|raised|removed|made|said|then|had)\\b)`,
|
|
716
|
+
`\\bsession\\s+\\d+'s\\s+round\\s+\\d+\\b`,
|
|
717
|
+
].join("|"),
|
|
718
|
+
"gu",
|
|
719
|
+
),
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
kind: "reference",
|
|
723
|
+
prose: /(?<![\w$&{#/])#\d{2,}\b|\b(?:PR|[Pp]ull [Rr]equest|[Ii]ssue)s?\s+(?:#\d+|\d{2,})\b|github\.com\/[\w.-]+\/[\w.-]+\/(?:pull|issues)\/\d+\b/gu,
|
|
724
|
+
raw: /`#\d{2,}`/gu,
|
|
725
|
+
},
|
|
726
|
+
];
|
|
727
|
+
|
|
728
|
+
const blankCode = (text) => text.replace(/(`+)[^`]*?\1/g, (span) => " ".repeat(span.length));
|
|
729
|
+
|
|
730
|
+
/** Comments grouped into blocks of consecutive lines, so a citation wrapped across two lines is still one. */
|
|
731
|
+
function blocksOf(comments) {
|
|
732
|
+
const blocks = [];
|
|
733
|
+
for (const comment of comments) {
|
|
734
|
+
const last = blocks.at(-1);
|
|
735
|
+
if (last && last.at(-1).line === comment.line - 1) last.push(comment);
|
|
736
|
+
else blocks.push([comment]);
|
|
737
|
+
}
|
|
738
|
+
return blocks;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** The comment lines that record history, each with the kinds that mark it. */
|
|
742
|
+
export function historyOf(comments) {
|
|
743
|
+
const kinds = new Map();
|
|
744
|
+
for (const block of blocksOf(comments)) {
|
|
745
|
+
const starts = [];
|
|
746
|
+
let raw = "";
|
|
747
|
+
for (const comment of block) {
|
|
748
|
+
starts.push(raw.length);
|
|
749
|
+
raw += `${comment.text} `;
|
|
750
|
+
}
|
|
751
|
+
const texts = { prose: blankCode(raw), raw };
|
|
752
|
+
for (const { kind, ...patterns } of KINDS) {
|
|
753
|
+
for (const [reads, pattern] of Object.entries(patterns)) {
|
|
754
|
+
for (const match of texts[reads].matchAll(pattern)) {
|
|
755
|
+
const last = lineAt(starts, match.index + match[0].length - 1);
|
|
756
|
+
for (let at = lineAt(starts, match.index); at <= last; at++) {
|
|
757
|
+
if (!kinds.has(block[at])) kinds.set(block[at], new Set());
|
|
758
|
+
kinds.get(block[at]).add(kind);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return comments.filter((comment) => kinds.has(comment)).map((comment) => ({ ...comment, kinds: [...kinds.get(comment)] }));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function trackedFiles(root) {
|
|
768
|
+
try {
|
|
769
|
+
const out = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
770
|
+
cwd: root,
|
|
771
|
+
encoding: "utf8",
|
|
772
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
773
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
774
|
+
});
|
|
775
|
+
return [...new Set(out.split("\0").filter(Boolean))].sort();
|
|
776
|
+
} catch (error) {
|
|
777
|
+
throw new CommentsError(`git could not list the files in ${root}: ${String(error.stderr || error.message).trim()}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function underLink(root, file, linked) {
|
|
782
|
+
for (let at = file.indexOf("/"); at !== -1; at = file.indexOf("/", at + 1)) {
|
|
783
|
+
const dir = file.slice(0, at);
|
|
784
|
+
if (!linked.has(dir)) linked.set(dir, fs.lstatSync(path.join(root, dir)).isSymbolicLink());
|
|
785
|
+
if (linked.get(dir)) return true;
|
|
786
|
+
}
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function readSource(root, file, linked) {
|
|
791
|
+
const full = path.join(root, file);
|
|
792
|
+
try {
|
|
793
|
+
if (underLink(root, file, linked) || !fs.lstatSync(full).isFile()) return null;
|
|
794
|
+
return fs.readFileSync(full, "utf8");
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (error.code === "ENOENT") return null;
|
|
797
|
+
throw new CommentsError(`${file} could not be read: ${error.code ?? error.message}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Every file the tree holds in a language this reads, with its comments and the ones recording history. */
|
|
802
|
+
export function scan(root, { exclude = [] } = {}) {
|
|
803
|
+
const files = trackedFiles(root);
|
|
804
|
+
const unused = exclude.filter((prefix) => !files.some((file) => file.startsWith(prefix)));
|
|
805
|
+
if (unused.length) throw new CommentsError(`--exclude ${unused.join(", ")} names no file in the tree: remove it`);
|
|
806
|
+
const read = [];
|
|
807
|
+
const linked = new Map();
|
|
808
|
+
for (const file of files) {
|
|
809
|
+
if (exclude.some((prefix) => file.startsWith(prefix))) continue;
|
|
810
|
+
const guess = languageOf(file);
|
|
811
|
+
if (guess === null && path.extname(file)) continue;
|
|
812
|
+
const source = readSource(root, file, linked);
|
|
813
|
+
if (source === null) continue;
|
|
814
|
+
const language = guess ?? languageOf(file, source.slice(0, source.indexOf("\n") >>> 0));
|
|
815
|
+
if (!language) continue;
|
|
816
|
+
const comments = commentsOf(source, language);
|
|
817
|
+
read.push({ file, language, comments, history: historyOf(comments) });
|
|
818
|
+
}
|
|
819
|
+
return read;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const sum = (items, pick) => items.reduce((total, item) => total + pick(item), 0);
|
|
823
|
+
const figure = (n) => n.toLocaleString("en-US");
|
|
824
|
+
|
|
825
|
+
export const historyCount = (root) => sum(scan(root), (entry) => entry.history.length);
|
|
826
|
+
|
|
827
|
+
function directoryTable(read) {
|
|
828
|
+
const directories = new Map();
|
|
829
|
+
for (const entry of read) {
|
|
830
|
+
if (!entry.comments.length) continue;
|
|
831
|
+
const dir = path.posix.dirname(entry.file);
|
|
832
|
+
const row = directories.get(dir) ?? { comments: 0, bytes: 0, history: 0, historyBytes: 0 };
|
|
833
|
+
row.comments += entry.comments.length;
|
|
834
|
+
row.bytes += sum(entry.comments, (c) => c.bytes);
|
|
835
|
+
row.history += entry.history.length;
|
|
836
|
+
row.historyBytes += sum(entry.history, (c) => c.bytes);
|
|
837
|
+
directories.set(dir, row);
|
|
838
|
+
}
|
|
839
|
+
return [
|
|
840
|
+
" by directory: comment lines recording history of all comment lines, and their bytes",
|
|
841
|
+
...[...directories]
|
|
842
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
843
|
+
.map(([dir, row]) => ` ${dir}/ ${figure(row.history)} of ${figure(row.comments)} lines · ${figure(row.historyBytes)} of ${figure(row.bytes)} B`),
|
|
844
|
+
];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const RULE_AT = "the rule: core/operating/context.md, on every boot card";
|
|
848
|
+
|
|
849
|
+
function verdicts({ count, limit, bytes, byteRail }) {
|
|
850
|
+
const lines = [];
|
|
851
|
+
let code = 0;
|
|
852
|
+
if (limit !== null) {
|
|
853
|
+
if (count > limit) {
|
|
854
|
+
code = 1;
|
|
855
|
+
lines.push(`RED: ${figure(count)} is over the limit of ${figure(limit)}: take the history out of the comments listed, into the commit message; ${RULE_AT}.`);
|
|
856
|
+
} else {
|
|
857
|
+
const lower = count < limit ? `; lower the limit to ${figure(count)}, so none comes back` : "";
|
|
858
|
+
lines.push(`green: ${figure(count)} is within the limit of ${figure(limit)}${lower}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (byteRail !== null) {
|
|
862
|
+
if (bytes > byteRail) {
|
|
863
|
+
code = 1;
|
|
864
|
+
lines.push(`RED: ${figure(bytes)} comment bytes are over the rail of ${figure(byteRail)}: cut the comments that are not truly needed, or raise the rail with its reason; ${RULE_AT}.`);
|
|
865
|
+
} else {
|
|
866
|
+
const lower = (byteRail - bytes) * 100 > byteRail * NOTE_PERCENT ? `; more than ${NOTE_PERCENT}% under it, so lower the rail to ${figure(railFor(bytes))}` : "";
|
|
867
|
+
lines.push(`green: ${figure(bytes)} comment bytes are within the rail of ${figure(byteRail)}${lower}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return { code, lines };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function report(read, { limit = null, bytes: byteRail = null, list = false } = {}) {
|
|
874
|
+
const all = read.flatMap((entry) => entry.comments);
|
|
875
|
+
const history = read.flatMap((entry) => entry.history);
|
|
876
|
+
const count = history.length;
|
|
877
|
+
const bytes = sum(all, (c) => c.bytes);
|
|
878
|
+
const out = [
|
|
879
|
+
`comments: ${figure(count)} of ${figure(all.length)} comment lines in the ${figure(read.length)} files read record a change's history ` +
|
|
880
|
+
`(${figure(sum(history, (c) => c.bytes))} of ${figure(bytes)} comment bytes)`,
|
|
881
|
+
];
|
|
882
|
+
if (list || (limit !== null && count > limit)) {
|
|
883
|
+
for (const entry of read) {
|
|
884
|
+
if (entry.history.length) out.push(` ${entry.file}: ${entry.history.map((c) => `${c.line} ${c.kinds.join("+")}`).join(" · ")}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
out.push(` by kind: ${KINDS.map(({ kind }) => `${kind} ${figure(history.filter((c) => c.kinds.includes(kind)).length)}`).join(" · ")}`);
|
|
888
|
+
if (list) out.push(...directoryTable(read));
|
|
889
|
+
const { code, lines } = verdicts({ count, limit, bytes, byteRail });
|
|
890
|
+
return { code, text: [...out, ...lines].join("\n") };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const USAGE = "usage: node cli/comments.mjs [--root <dir>] [--limit <count>] [--bytes <count>] [--list] [--exclude <path prefix>]...";
|
|
894
|
+
|
|
895
|
+
export function parseArgs(argv) {
|
|
896
|
+
const options = { root: process.cwd(), limit: null, bytes: null, list: false, exclude: [] };
|
|
897
|
+
for (let i = 0; i < argv.length; i++) {
|
|
898
|
+
const arg = argv[i];
|
|
899
|
+
const count = /^\d+$/.test(argv[i + 1] ?? "");
|
|
900
|
+
if (arg === "--list") options.list = true;
|
|
901
|
+
else if (arg === "--root" && argv[i + 1] !== undefined) options.root = argv[++i];
|
|
902
|
+
else if (arg === "--exclude" && argv[i + 1]) options.exclude.push(argv[++i]);
|
|
903
|
+
else if (arg === "--limit" && count) options.limit = Number(argv[++i]);
|
|
904
|
+
else if (arg === "--bytes" && count) options.bytes = Number(argv[++i]);
|
|
905
|
+
else throw new CommentsError(`${USAGE} (not ${JSON.stringify(arg)})`);
|
|
906
|
+
}
|
|
907
|
+
return options;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export function run(argv, stdout = process.stdout, stderr = process.stderr) {
|
|
911
|
+
try {
|
|
912
|
+
const options = parseArgs(argv);
|
|
913
|
+
const { code, text } = report(scan(options.root, options), options);
|
|
914
|
+
stdout.write(`${text}\n`);
|
|
915
|
+
return code;
|
|
916
|
+
} catch (error) {
|
|
917
|
+
stderr.write(`comments: could not run: ${error.message}\n`);
|
|
918
|
+
return 2;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function isMain() {
|
|
923
|
+
const invoked = process.argv[1];
|
|
924
|
+
if (!invoked) return false;
|
|
925
|
+
try {
|
|
926
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
|
|
927
|
+
} catch {
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (isMain()) process.exitCode = run(process.argv.slice(2));
|