stillhunter 0.0.1 → 0.2.1
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/.claude-plugin/plugin.json +10 -0
- package/README.md +102 -2
- package/agents/hunter.md +105 -0
- package/dist/accounting.mjs +99 -0
- package/dist/browser.mjs +76 -0
- package/dist/change.mjs +217 -0
- package/dist/common.mjs +110 -0
- package/dist/coverage.mjs +119 -0
- package/dist/finish.mjs +357 -0
- package/dist/flags.mjs +49 -0
- package/dist/hunt.mjs +193 -0
- package/dist/keep.mjs +195 -0
- package/dist/layout.mjs +143 -0
- package/dist/probe-child.mjs +456 -0
- package/dist/probe-host.mjs +206 -0
- package/dist/relay.mjs +235 -0
- package/dist/reset.mjs +87 -0
- package/dist/results.mjs +87 -0
- package/dist/target.mjs +121 -0
- package/hooks/guard.mjs +255 -0
- package/hooks/hooks.json +28 -0
- package/npm-shrinkwrap.json +447 -0
- package/package.json +41 -3
- package/skills/hunt/SKILL.md +25 -0
- package/skills/keep/SKILL.md +19 -0
- package/skills/replay/SKILL.md +14 -0
package/dist/change.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The change a hunt tests, printed whole by `start`: the diff of tracked files
|
|
2
|
+
// against the base plus the content of untracked files, CSS and templates
|
|
3
|
+
// included, so the hunter reads the same thing a reviewer would.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { appendFileSync, closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { FOLDER, git, GIT_FLAGS, secretFile } from './common.mjs';
|
|
8
|
+
export const CHANGE_CAP = 60 * 1024;
|
|
9
|
+
const UNTRACKED_READ_LIMIT = CHANGE_CAP + 1;
|
|
10
|
+
// diff --file prints a file whole, up to this.
|
|
11
|
+
export const FILE_CAP = 1024 * 1024;
|
|
12
|
+
export const SECRET_BODY = '(left out: a secret file, such as .env or a key, which the hunter does not read)\n';
|
|
13
|
+
function diffSections(root, base) {
|
|
14
|
+
const r = spawnSync('git', [...GIT_FLAGS, 'diff', '--no-color', '--no-ext-diff', base, '--', '.', `:(exclude)${FOLDER}`], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
15
|
+
if (r.status !== 0)
|
|
16
|
+
return [];
|
|
17
|
+
const sections = [];
|
|
18
|
+
for (const chunk of r.stdout.split(/^(?=diff --git )/m).filter((c) => c.startsWith('diff --git '))) {
|
|
19
|
+
const first = chunk.split('\n', 1)[0] ?? '';
|
|
20
|
+
const names = sectionNames(chunk);
|
|
21
|
+
const file = names.new ?? first;
|
|
22
|
+
// A section whose names cannot all be read, or that names a secret file on
|
|
23
|
+
// either side (a key renamed to notes.txt), keeps its header only.
|
|
24
|
+
const hidden = names.unread || names.all.some(secretFile);
|
|
25
|
+
sections.push({ file, header: first, body: hidden ? SECRET_BODY : chunk.slice(first.length + 1) });
|
|
26
|
+
}
|
|
27
|
+
return sections;
|
|
28
|
+
}
|
|
29
|
+
// git quotes a name holding a quote, a backslash or a control character, in C
|
|
30
|
+
// style, even with core.quotePath off.
|
|
31
|
+
const ESCAPES = { a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', '"': '"', '\\': '\\' };
|
|
32
|
+
const unquote = (s) => s.replace(/\\([0-7]{3}|.)/g, (_, e) => (e.length === 3 ? String.fromCharCode(parseInt(e, 8)) : ESCAPES[e] ?? e));
|
|
33
|
+
const QUOTED = '"((?:[^"\\\\]|\\\\.)*)"';
|
|
34
|
+
// Every path a diff section's header lines name: the new side, the old side,
|
|
35
|
+
// and a rename's or copy's source and destination.
|
|
36
|
+
export function sectionNames(chunk) {
|
|
37
|
+
const head = chunk.split('\n');
|
|
38
|
+
const end = head.findIndex((l) => l.startsWith('@@'));
|
|
39
|
+
const lines = end === -1 ? head : head.slice(0, end);
|
|
40
|
+
const all = [];
|
|
41
|
+
let unread = false;
|
|
42
|
+
let newName = null;
|
|
43
|
+
const take = (quoted, plain) => {
|
|
44
|
+
const name = quoted !== undefined ? unquote(quoted) : plain;
|
|
45
|
+
if (name === undefined)
|
|
46
|
+
unread = true;
|
|
47
|
+
else
|
|
48
|
+
all.push(name);
|
|
49
|
+
return name ?? null;
|
|
50
|
+
};
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
let m;
|
|
53
|
+
if ((m = new RegExp(`^diff --git .* (?:${QUOTED.replace('"(', '"b/(')}|b/(.+))$`).exec(line)))
|
|
54
|
+
newName = take(m[1], m[2]);
|
|
55
|
+
else if (line.startsWith('diff --git '))
|
|
56
|
+
unread = true;
|
|
57
|
+
else if ((m = new RegExp(`^(?:---|\\+\\+\\+) (?:${QUOTED.replace('"(', '"[ab]/(')}|[ab]/(.*?))\t?$`).exec(line)))
|
|
58
|
+
take(m[1], m[2]);
|
|
59
|
+
else if ((m = new RegExp(`^(?:rename|copy) (?:from|to) (?:${QUOTED}|(.+))$`).exec(line)))
|
|
60
|
+
take(m[1], m[2]);
|
|
61
|
+
}
|
|
62
|
+
return { new: newName, all, unread };
|
|
63
|
+
}
|
|
64
|
+
function readHead(file, limit) {
|
|
65
|
+
const fd = openSync(file, 'r');
|
|
66
|
+
try {
|
|
67
|
+
const buf = Buffer.alloc(limit);
|
|
68
|
+
const n = readSync(fd, buf, 0, limit, 0);
|
|
69
|
+
return buf.subarray(0, n);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function untrackedSection(root, file) {
|
|
76
|
+
const header = `=== untracked file: ${file} ===`;
|
|
77
|
+
if (secretFile(file))
|
|
78
|
+
return { file, header, body: SECRET_BODY };
|
|
79
|
+
const full = path.join(root, file);
|
|
80
|
+
let size = 0;
|
|
81
|
+
try {
|
|
82
|
+
// A link is shown as a link: its target may be outside the repository.
|
|
83
|
+
const st = lstatSync(full);
|
|
84
|
+
if (st.isSymbolicLink())
|
|
85
|
+
return { file, header, body: `(symbolic link to ${readlinkSync(full)})\n` };
|
|
86
|
+
size = st.size;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { file, header, body: '(could not be read)\n' };
|
|
90
|
+
}
|
|
91
|
+
const head = readHead(full, Math.min(size, UNTRACKED_READ_LIMIT));
|
|
92
|
+
if (head.subarray(0, 8000).includes(0))
|
|
93
|
+
return { file, header, body: `(binary file, ${size} bytes)\n` };
|
|
94
|
+
// Too large to print whole: the cap cuts it anyway, so its size is what counts.
|
|
95
|
+
const body = size > UNTRACKED_READ_LIMIT ? 'x'.repeat(UNTRACKED_READ_LIMIT) : head.toString('utf8');
|
|
96
|
+
return { file, header, body: body.endsWith('\n') ? body : `${body}\n` };
|
|
97
|
+
}
|
|
98
|
+
// The commit the change is measured from: where HEAD left the main branch.
|
|
99
|
+
export function baseRef(root) {
|
|
100
|
+
for (const ref of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
101
|
+
const mb = git(['merge-base', 'HEAD', ref], root);
|
|
102
|
+
if (mb)
|
|
103
|
+
return mb;
|
|
104
|
+
}
|
|
105
|
+
return 'HEAD';
|
|
106
|
+
}
|
|
107
|
+
export function collectChange(root, base) {
|
|
108
|
+
const files = (git(['diff', '--name-status', base, '--', '.', `:(exclude)${FOLDER}`], root) ?? '').split('\n').filter(Boolean);
|
|
109
|
+
const untracked = (git(['ls-files', '--others', '--exclude-standard'], root) ?? '').split('\n').filter((f) => f && !f.startsWith(`${FOLDER}/`));
|
|
110
|
+
const sections = [...diffSections(root, base), ...untracked.map((f) => untrackedSection(root, f))];
|
|
111
|
+
return { files: [...files, ...untracked.map((f) => `A (untracked)\t${f}`)], sections, empty: files.length === 0 && untracked.length === 0 };
|
|
112
|
+
}
|
|
113
|
+
const bytes = (s) => Buffer.byteLength(s, 'utf8');
|
|
114
|
+
const kb = (n) => `${Math.max(1, Math.round(n / 1024))} KB`;
|
|
115
|
+
// Fits the change under the cap by leaving out the bodies of the largest files
|
|
116
|
+
// first. A file's header always stays, so no changed file goes unnamed.
|
|
117
|
+
export function capSections(sections, cap = CHANGE_CAP) {
|
|
118
|
+
const total = sections.reduce((n, s) => n + bytes(s.header) + 1 + bytes(s.body), 0);
|
|
119
|
+
const kept = sections.map((s) => ({ ...s, size: bytes(s.body), cut: false }));
|
|
120
|
+
let size = total;
|
|
121
|
+
const byLargest = [...kept].sort((a, b) => b.size - a.size);
|
|
122
|
+
for (const s of byLargest) {
|
|
123
|
+
if (size <= cap)
|
|
124
|
+
break;
|
|
125
|
+
s.cut = true;
|
|
126
|
+
size -= s.size;
|
|
127
|
+
}
|
|
128
|
+
const cut = kept.filter((s) => s.cut).map((s) => ({ file: s.file, size: s.size }));
|
|
129
|
+
const text = kept.map((s) => (s.cut ? `${s.header}\n(left out: ${kb(s.size)}, see the note above)\n` : `${s.header}\n${s.body}`)).join('');
|
|
130
|
+
return { text, cut, total };
|
|
131
|
+
}
|
|
132
|
+
export function changeText(change, cap = CHANGE_CAP) {
|
|
133
|
+
if (change.sections.length === 0)
|
|
134
|
+
return ['(no change)'];
|
|
135
|
+
const { text, cut, total } = capSections(change.sections, cap);
|
|
136
|
+
const note = cut.length === 0 ? [] : [
|
|
137
|
+
`The change is ${kb(total)}; to fit ${kb(cap)} the bodies of the largest files were left out: ${cut.map((c) => `${c.file} (${kb(c.size)})`).join(', ')}.`,
|
|
138
|
+
'Read each with the runner\'s `diff --file <file>`.',
|
|
139
|
+
];
|
|
140
|
+
return [...note, text.replace(/\n$/, '')];
|
|
141
|
+
}
|
|
142
|
+
// Run folders hold a signed-in session, so they must never be committed.
|
|
143
|
+
export function ensureRunsIgnored(file) {
|
|
144
|
+
if (!existsSync(file)) {
|
|
145
|
+
writeFileSync(file, '/.env\n/.env.*\n/runs/\n');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const text = readFileSync(file, 'utf8');
|
|
149
|
+
const rules = text.split(/\r?\n/).map((l) => l.trim());
|
|
150
|
+
if (rules.some((r) => ['/runs/', '/runs', 'runs/', 'runs', '/runs/*', 'runs/*', '/runs/**'].includes(r)))
|
|
151
|
+
return;
|
|
152
|
+
appendFileSync(file, `${text === '' || text.endsWith('\n') ? '' : '\n'}/runs/\n`);
|
|
153
|
+
}
|
|
154
|
+
export function readSpecs(root) {
|
|
155
|
+
const dir = path.join(root, FOLDER, 'specs');
|
|
156
|
+
if (!existsSync(dir))
|
|
157
|
+
return [];
|
|
158
|
+
const limit = 20 * 1024;
|
|
159
|
+
return readdirSync(dir).filter((f) => /\.(txt|md)$/.test(f)).sort().map((name) => {
|
|
160
|
+
const raw = readFileSync(path.join(dir, name), 'utf8');
|
|
161
|
+
return { file: `${FOLDER}/specs/${name}`, text: raw.length > limit ? `${raw.slice(0, limit)}\n(cut at 20 KB)` : raw };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// What `diff` lists with no file: every changed and untracked file, with the
|
|
165
|
+
// size of what `diff --file` would print for it.
|
|
166
|
+
export function changeList(change) {
|
|
167
|
+
if (change.sections.length === 0)
|
|
168
|
+
return ['(no change)'];
|
|
169
|
+
const status = new Map(change.files.map((l) => { const [s, ...rest] = l.split('\t'); return [rest.at(-1) ?? '', s ?? '']; }));
|
|
170
|
+
return change.sections.map((s) => `${(status.get(s.file) ?? '?').padEnd(13)} ${s.file} (${kb(bytes(s.header) + 1 + bytes(s.body))})`);
|
|
171
|
+
}
|
|
172
|
+
// One file's whole diff against the base, or its whole content when it is
|
|
173
|
+
// untracked. Only a file the change holds, inside the repository and outside
|
|
174
|
+
// the runner's own folder, is printed; anything else is read with the Read
|
|
175
|
+
// tool, which the guard watches.
|
|
176
|
+
export function fileDiff(root, base, file, cwd = process.cwd()) {
|
|
177
|
+
const outside = { ok: false, message: `${file} is outside the repository.` };
|
|
178
|
+
let real;
|
|
179
|
+
let realRoot;
|
|
180
|
+
try {
|
|
181
|
+
realRoot = realpathSync.native(root);
|
|
182
|
+
const full = path.resolve(cwd, file);
|
|
183
|
+
real = existsSync(full) ? realpathSync.native(full) : path.join(realpathSync.native(path.dirname(full)), path.basename(full));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return { ok: false, message: `${file} is not in the change.` };
|
|
187
|
+
}
|
|
188
|
+
const rel = path.relative(realRoot, real);
|
|
189
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
190
|
+
return outside;
|
|
191
|
+
const posix = rel.split(path.sep).join('/');
|
|
192
|
+
if (posix.split('/').some((seg) => seg.toLowerCase() === FOLDER))
|
|
193
|
+
return { ok: false, message: `${FOLDER}/ is the runner's own folder; read its files with the Read tool.` };
|
|
194
|
+
if (secretFile(posix))
|
|
195
|
+
return { ok: false, message: `${posix} is a secret file (such as .env or a key), which the hunter does not read.` };
|
|
196
|
+
const changed = (git(['diff', '--name-only', base, '--', '.', `:(exclude)${FOLDER}`], root) ?? '').split('\n').filter(Boolean);
|
|
197
|
+
const untracked = (git(['ls-files', '--others', '--exclude-standard'], root) ?? '').split('\n').filter(Boolean);
|
|
198
|
+
if (changed.includes(posix)) {
|
|
199
|
+
const r = spawnSync('git', [...GIT_FLAGS, 'diff', '--no-color', '--no-ext-diff', base, '--', `:(literal)${posix}`], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
200
|
+
return r.status === 0 ? { ok: true, text: capFile(r.stdout) } : { ok: false, message: `git diff failed for ${posix}: ${r.stderr.trim()}` };
|
|
201
|
+
}
|
|
202
|
+
if (untracked.includes(posix)) {
|
|
203
|
+
const header = `=== untracked file: ${posix} ===`;
|
|
204
|
+
const size = lstatSync(real).size;
|
|
205
|
+
const head = readHead(real, Math.min(size, FILE_CAP + 1));
|
|
206
|
+
if (head.subarray(0, 8000).includes(0))
|
|
207
|
+
return { ok: true, text: `${header}\n(binary file, ${size} bytes)\n` };
|
|
208
|
+
return { ok: true, text: `${header}\n${capFile(head.toString('utf8'), size)}` };
|
|
209
|
+
}
|
|
210
|
+
return { ok: false, message: `${posix} is not in the change. Run diff with no --file to list the changed files, or read it with the Read tool.` };
|
|
211
|
+
}
|
|
212
|
+
// Up to FILE_CAP of a file's diff or content, with a line saying what was cut.
|
|
213
|
+
function capFile(text, size = bytes(text)) {
|
|
214
|
+
const body = size > FILE_CAP ? Buffer.from(text, 'utf8').subarray(0, FILE_CAP).toString('utf8') : text;
|
|
215
|
+
const ended = body.endsWith('\n') ? body : `${body}\n`;
|
|
216
|
+
return size > FILE_CAP ? `${ended}(cut at ${kb(FILE_CAP)} of ${kb(size)})\n` : ended;
|
|
217
|
+
}
|
package/dist/common.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// What every runner command shares: the run folder, its context, the config,
|
|
2
|
+
// the output block and the small helpers around git and the command line.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { nuxtCandidates, proveLock } from './target.mjs';
|
|
7
|
+
export const FOLDER = '.stillhunter';
|
|
8
|
+
export const HUNT_ID = /^\d{8}-\d{6}-hunt-[a-z0-9]{4}$/;
|
|
9
|
+
export const PROBE_NAME = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
10
|
+
export const START = '--- stillhunter (copy this block unchanged) ---';
|
|
11
|
+
export const END = '--- end of stillhunter ---';
|
|
12
|
+
export const SEVERITIES = ['high', 'medium', 'low'];
|
|
13
|
+
export const runsDir = (root) => path.join(root, FOLDER, 'runs');
|
|
14
|
+
export const testsDir = (root) => path.join(root, FOLDER, 'tests');
|
|
15
|
+
// Paths come back as typed, not quoted with octal escapes, so a name that is
|
|
16
|
+
// not ASCII (`café.pem`) is matched like any other.
|
|
17
|
+
export const GIT_FLAGS = ['-c', 'core.quotePath=false'];
|
|
18
|
+
export function git(args, cwd) {
|
|
19
|
+
const r = spawnSync('git', [...GIT_FLAGS, ...args], { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
20
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
21
|
+
}
|
|
22
|
+
export function block(lines) {
|
|
23
|
+
return [START, ...lines, END].join('\n') + '\n';
|
|
24
|
+
}
|
|
25
|
+
export function fail(message) {
|
|
26
|
+
process.stdout.write(block(['stillhunter | stopped', message]));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
export function readStdin() {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(0, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function flag(args, name) {
|
|
38
|
+
const i = args.indexOf(name);
|
|
39
|
+
return i === -1 ? null : (args[i + 1] ?? null);
|
|
40
|
+
}
|
|
41
|
+
export function stamp(d = new Date()) {
|
|
42
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
43
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
44
|
+
}
|
|
45
|
+
export function repoRoot() {
|
|
46
|
+
const root = git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
47
|
+
if (!root)
|
|
48
|
+
fail('Not inside a git repository.');
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
export function readConfig(root) {
|
|
52
|
+
const file = path.join(root, FOLDER, 'config.json');
|
|
53
|
+
if (!existsSync(file))
|
|
54
|
+
return {};
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
fail(`${FOLDER}/config.json is not valid JSON: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function loadRun(args) {
|
|
63
|
+
const root = repoRoot();
|
|
64
|
+
const id = flag(args, '--run');
|
|
65
|
+
if (!id || !HUNT_ID.test(id))
|
|
66
|
+
fail('Give the run id from `start` with --run.');
|
|
67
|
+
const dir = path.join(runsDir(root), id);
|
|
68
|
+
if (!existsSync(path.join(dir, 'context.json')))
|
|
69
|
+
fail(`No hunt run ${id}.`);
|
|
70
|
+
return { ctx: JSON.parse(readFileSync(path.join(dir, 'context.json'), 'utf8')), dir, root };
|
|
71
|
+
}
|
|
72
|
+
export const remaining = (ctx, now = Date.now()) => Math.max(0, Math.round((ctx.deadline - now) / 1000));
|
|
73
|
+
export async function findTargetUrl(root, config) {
|
|
74
|
+
if (config.target?.url)
|
|
75
|
+
return config.target.url;
|
|
76
|
+
for (const candidate of nuxtCandidates(root)) {
|
|
77
|
+
const url = await proveLock(candidate);
|
|
78
|
+
if (url)
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
// A path the hunter never reads: the same rule as secretPath() in
|
|
84
|
+
// hooks/guard.mjs (a test holds the two to the same answers), applied by the
|
|
85
|
+
// runner to what it prints of the change.
|
|
86
|
+
const SECRET_FOLDERS = new Set(['.ssh', '.aws', '.gnupg', '.git']);
|
|
87
|
+
const SECRET_FILES = new Set(['.npmrc', '.netrc', '.pgpass', 'session.json']);
|
|
88
|
+
export function secretFile(file) {
|
|
89
|
+
const segments = file.replace(/\\/g, '/').toLowerCase().split('/').filter(Boolean);
|
|
90
|
+
const name = segments.at(-1) ?? '';
|
|
91
|
+
if (segments.some((s) => SECRET_FOLDERS.has(s)) || SECRET_FILES.has(name) || name.startsWith('.env'))
|
|
92
|
+
return true;
|
|
93
|
+
return /\.(pem|key|p12|pfx)$/.test(name);
|
|
94
|
+
}
|
|
95
|
+
// Names only: the values never leave the environment they came from.
|
|
96
|
+
export function signInVariableNames(env = process.env) {
|
|
97
|
+
return Object.keys(env)
|
|
98
|
+
.filter((k) => /(EMAIL|USER(NAME)?|LOGIN|PASSWORD|PASS)$/.test(k) && !/^(npm_|HOMEBREW|LOGNAME$|USER$)/.test(k))
|
|
99
|
+
.sort();
|
|
100
|
+
}
|
|
101
|
+
export const severityOf = (s) => (SEVERITIES.includes(String(s ?? '').toLowerCase()) ? String(s).toLowerCase() : 'medium');
|
|
102
|
+
export const atOrAbove = (s, min) => SEVERITIES.indexOf(s) <= SEVERITIES.indexOf(min);
|
|
103
|
+
// An error on one line, short enough to read in a report.
|
|
104
|
+
export function brief(text, max = 300) {
|
|
105
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
106
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
107
|
+
}
|
|
108
|
+
export function minutesAndSeconds(seconds) {
|
|
109
|
+
return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const NARROW_WIDTH = 375;
|
|
2
|
+
const ID_SEGMENT = [
|
|
3
|
+
/^\d+$/,
|
|
4
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
5
|
+
/^(?=.*\d)[0-9a-f]{12,}$/i,
|
|
6
|
+
/^(?=.*\d)(?=.*[a-z])[A-Za-z0-9_-]{20,}$/,
|
|
7
|
+
];
|
|
8
|
+
const ASSET = /\.(m?js|css|map|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|eot|wasm|txt)$/i;
|
|
9
|
+
// The dev server's own requests (bundles, error overlays, devtools, hot reload
|
|
10
|
+
// and its sockets, the icon): no person's action makes them, and faulting
|
|
11
|
+
// them tests the dev server, not the app. Framework paths that carry the
|
|
12
|
+
// app's data, such as /_next/data/, /_next/image and /__nuxt_island/, count.
|
|
13
|
+
const DEV_PATH = /^\/(?:_nuxt\/|__nuxt_dev__\/|__nuxt_devtools__\/|_next\/static\/|_next\/webpack-hmr|__nextjs|__vite|@vite\/|@fs\/|@id\/|__webpack|sockjs-node|favicon\.ico|node_modules\/)/;
|
|
14
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
15
|
+
function parse(url, target) {
|
|
16
|
+
try {
|
|
17
|
+
return new URL(url, target);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// A request the loopback-only route let through, so it reached the app (the
|
|
24
|
+
// target origin or another loopback port). A page call to a tracker or
|
|
25
|
+
// analytics host was aborted before it reached anything, so it is not coverage
|
|
26
|
+
// of the app and must not feed "seen but never faulted".
|
|
27
|
+
function reachesApp(url, target) {
|
|
28
|
+
const u = parse(url, target);
|
|
29
|
+
return Boolean(u && (LOOPBACK.has(u.hostname) || u.hostname.endsWith('.localhost')));
|
|
30
|
+
}
|
|
31
|
+
export function foldPath(pathname) {
|
|
32
|
+
const segments = pathname.split('/').map((s) => {
|
|
33
|
+
let decoded = s;
|
|
34
|
+
try {
|
|
35
|
+
decoded = decodeURIComponent(s);
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
return ID_SEGMENT.some((p) => p.test(decoded)) ? ':id' : s;
|
|
39
|
+
});
|
|
40
|
+
const folded = segments.join('/');
|
|
41
|
+
return folded.length > 1 && folded.endsWith('/') ? folded.slice(0, -1) : folded || '/';
|
|
42
|
+
}
|
|
43
|
+
// The folded "METHOD /path" of an API request, or null for assets and the dev
|
|
44
|
+
// server's own requests. Another loopback origin keeps its host and port.
|
|
45
|
+
export function endpointOf(method, url, target) {
|
|
46
|
+
const u = parse(url, target);
|
|
47
|
+
if (!u || !/^https?:$/.test(u.protocol))
|
|
48
|
+
return null;
|
|
49
|
+
if (ASSET.test(u.pathname) || DEV_PATH.test(u.pathname))
|
|
50
|
+
return null;
|
|
51
|
+
const t = parse(target, target);
|
|
52
|
+
const origin = t && u.host === t.host ? '' : u.host;
|
|
53
|
+
return `${method.toUpperCase()} ${origin}${foldPath(u.pathname)}`;
|
|
54
|
+
}
|
|
55
|
+
export function pageOf(url, target) {
|
|
56
|
+
const u = parse(url, target);
|
|
57
|
+
const t = parse(target, target);
|
|
58
|
+
if (!u || !t || !/^https?:$/.test(u.protocol) || u.host !== t.host || DEV_PATH.test(u.pathname))
|
|
59
|
+
return null;
|
|
60
|
+
return foldPath(u.pathname);
|
|
61
|
+
}
|
|
62
|
+
// Signing in is how a hunt gets started, not what it tests: what a probe saw
|
|
63
|
+
// while it signed in (the sign-in page and the requests made before the page
|
|
64
|
+
// it landed on) is left out, unless another probe saw it too. The page it
|
|
65
|
+
// landed on, and everything after, counts.
|
|
66
|
+
export function coverage(results, target) {
|
|
67
|
+
const endpoints = new Map();
|
|
68
|
+
const pages = new Map();
|
|
69
|
+
const viewports = new Set();
|
|
70
|
+
for (const r of results) {
|
|
71
|
+
const flow = r.signInFlow;
|
|
72
|
+
(r.requests ?? []).forEach((req, i) => {
|
|
73
|
+
if ((flow && i < flow.requests) || req.via !== 'page' || !reachesApp(req.url, target))
|
|
74
|
+
return;
|
|
75
|
+
const e = endpointOf(req.method, req.url, target);
|
|
76
|
+
if (e && !endpoints.has(e))
|
|
77
|
+
endpoints.set(e, new Set());
|
|
78
|
+
});
|
|
79
|
+
for (const f of r.faults ?? []) {
|
|
80
|
+
if (!reachesApp(f.url, target))
|
|
81
|
+
continue;
|
|
82
|
+
const e = endpointOf(f.method, f.url, target);
|
|
83
|
+
if (!e)
|
|
84
|
+
continue;
|
|
85
|
+
const set = endpoints.get(e) ?? new Set();
|
|
86
|
+
set.add(f.fault);
|
|
87
|
+
endpoints.set(e, set);
|
|
88
|
+
}
|
|
89
|
+
for (const p of r.pages ?? []) {
|
|
90
|
+
if (p.viewport && (p.visited ?? []).length)
|
|
91
|
+
viewports.add(`${p.viewport.width}x${p.viewport.height}`);
|
|
92
|
+
for (const v of p.visited ?? []) {
|
|
93
|
+
const page = flow?.pages.includes(v.url) ? null : pageOf(v.url, target);
|
|
94
|
+
if (!page)
|
|
95
|
+
continue;
|
|
96
|
+
const set = pages.get(page) ?? new Set();
|
|
97
|
+
if (typeof v.width === 'number')
|
|
98
|
+
set.add(v.width);
|
|
99
|
+
pages.set(page, set);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const endpointList = [...endpoints].map(([endpoint, faults]) => ({ endpoint, faults: [...faults].sort() })).sort((a, b) => a.endpoint.localeCompare(b.endpoint));
|
|
104
|
+
const pageList = [...pages].map(([page, widths]) => ({ page, widths: [...widths].sort((a, b) => a - b) })).sort((a, b) => a.page.localeCompare(b.page));
|
|
105
|
+
return {
|
|
106
|
+
endpoints: endpointList,
|
|
107
|
+
neverFaulted: endpointList.filter((e) => e.faults.length === 0).map((e) => e.endpoint),
|
|
108
|
+
pages: pageList,
|
|
109
|
+
neverNarrow: pageList.filter((p) => !p.widths.some((w) => w <= NARROW_WIDTH)).map((p) => p.page),
|
|
110
|
+
viewports: [...viewports].sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function coverageLines(c) {
|
|
114
|
+
return [
|
|
115
|
+
`endpoints seen: ${c.endpoints.length} (${c.endpoints.length - c.neverFaulted.length} faulted); pages visited: ${c.pages.length} (${c.pages.length - c.neverNarrow.length} at ${NARROW_WIDTH} px); viewports: ${c.viewports.length ? c.viewports.join(', ') : 'none'}`,
|
|
116
|
+
`seen but never faulted: ${c.neverFaulted.length ? c.neverFaulted.join(', ') : 'none'}`,
|
|
117
|
+
`pages never seen at ${NARROW_WIDTH} px: ${c.neverNarrow.length ? c.neverNarrow.join(', ') : 'none'}`,
|
|
118
|
+
];
|
|
119
|
+
}
|