@raresportan/marram 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +4 -2
- package/bin/marram.mjs +19 -0
- package/package.json +51 -4
- package/src/core/boundaries.mjs +96 -0
- package/src/core/check-lint.mjs +107 -0
- package/src/core/commit-lint.mjs +53 -0
- package/src/core/config.mjs +23 -0
- package/src/core/gate-state.mjs +86 -0
- package/src/core/gate.mjs +90 -0
- package/src/core/hook.mjs +114 -0
- package/src/core/no-comments.mjs +30 -0
- package/src/core/ratchet.mjs +72 -0
- package/src/core/until.ts +59 -0
- package/src/electron/feature-map.ts +93 -0
- package/src/electron/frames.ts +91 -0
- package/src/electron/geometry.ts +101 -0
- package/src/electron/journey-lint.mjs +45 -0
- package/src/electron/run.ts +83 -0
- package/src/electron/session.ts +187 -0
- package/src/node/background.ts +17 -0
- package/src/node/promise-lint.mjs +117 -0
- package/src/node/resource-lint.mjs +84 -0
- package/src/node/scope.ts +63 -0
- package/src/react/effect-lint.mjs +60 -0
- package/src/react/keyed.ts +19 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Runs the gate where work is declared done, so nothing finishes or ships while it's red. Usage:
|
|
2
|
+
// marram hook pre-push git's pre-push hook: what's pushed must have passed.
|
|
3
|
+
// marram hook claude-turn Claude Code's UserPromptSubmit: notes the code a turn starts from.
|
|
4
|
+
// marram hook claude-stop Claude Code's Stop: a turn that changed code ends only once the gate passes, sent back at
|
|
5
|
+
// most 3 times a turn.
|
|
6
|
+
// marram hook finish An agent's own done-check: a failure exits 1 with what failed, so the
|
|
7
|
+
// agent is sent back as many times as its hook allows.
|
|
8
|
+
// Code the gate passed isn't checked again, and a failure is reused for 15 minutes; see gate-state.mjs.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { git, treeOf, updateState, verdictOf, readState } from "./gate-state.mjs";
|
|
13
|
+
|
|
14
|
+
const MAX_SENT_BACK = 3;
|
|
15
|
+
const MARRAM = join(import.meta.dirname, "..", "..", "bin", "marram.mjs");
|
|
16
|
+
const stdin = () => readFileSync(0, "utf8");
|
|
17
|
+
|
|
18
|
+
/** The gate's verdict on `tree`, running the default gate when there is none. `show` sends its progress to the terminal. */
|
|
19
|
+
async function judge(root, tree, show) {
|
|
20
|
+
const known = verdictOf(root, tree);
|
|
21
|
+
if (known !== undefined) return { ...known, reused: true };
|
|
22
|
+
const gate = spawn(process.execPath, [MARRAM, "gate"], { cwd: root, stdio: ["ignore", show ? "inherit" : "ignore", "inherit"] });
|
|
23
|
+
// A hook that's killed (its timeout) passes it on, so the gate can stop what it started.
|
|
24
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => gate.kill(signal));
|
|
25
|
+
await new Promise((done) => gate.once("close", done));
|
|
26
|
+
// A gate that changed files as it ran records no pass; it counts as failed with no stage.
|
|
27
|
+
return verdictOf(root, tree) ?? { passed: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What an agent is told when the gate fails: where it failed, and the end of that stage's log. */
|
|
31
|
+
function failure(verdict) {
|
|
32
|
+
if (verdict.stage === undefined) return "The gate didn't pass: files changed while it ran. Run `marram gate` and fix what fails.";
|
|
33
|
+
// From the first failure line when there is one (a test runner's "not ok", a check's ✗), else the end of the log.
|
|
34
|
+
let tail = "";
|
|
35
|
+
try {
|
|
36
|
+
const lines = readFileSync(verdict.log, "utf8").trimEnd().split("\n");
|
|
37
|
+
const first = lines.findIndex((line) => /^\s*(not ok|✗)/.test(line));
|
|
38
|
+
tail = (first === -1 ? lines.slice(-60) : lines.slice(first, first + 60)).join("\n");
|
|
39
|
+
} catch {}
|
|
40
|
+
return [
|
|
41
|
+
`The gate failed at ${verdict.stage}${verdict.reused ? " (the verdict on this exact code, from a run in the last 15 minutes)" : ""}, so this isn't done.`,
|
|
42
|
+
"Fix the cause, not the check, then finish again. `marram gate` reruns it.",
|
|
43
|
+
`Log: ${verdict.log}`,
|
|
44
|
+
...(tail === "" ? [] : ["", tail]),
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function prePush(root) {
|
|
49
|
+
// stdin: one line per ref pushed, "<local ref> <local sha> <remote ref> <remote sha>".
|
|
50
|
+
for (const line of stdin().split("\n").filter(Boolean)) {
|
|
51
|
+
const [ref, sha] = line.split(" ");
|
|
52
|
+
if (/^0+$/.test(sha)) continue; // a deletion
|
|
53
|
+
const tree = git(root, ["rev-parse", `${sha}^{tree}`]);
|
|
54
|
+
if (verdictOf(root, tree)?.passed) continue;
|
|
55
|
+
if (treeOf(root) !== tree) {
|
|
56
|
+
console.error(`${ref} hasn't passed the gate, and the working tree isn't that commit, so it can't be checked here.\nCommit or stash your changes, check it out and push again (or \`git push --no-verify\` to skip the gate).`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
console.error(`Gating ${ref} before it's pushed (\`git push --no-verify\` skips this).`);
|
|
60
|
+
const verdict = await judge(root, tree, true);
|
|
61
|
+
if (!verdict.passed) {
|
|
62
|
+
console.error(failure(verdict));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function claudeTurn(root) {
|
|
69
|
+
const { session_id: session } = JSON.parse(stdin());
|
|
70
|
+
const tree = treeOf(root);
|
|
71
|
+
updateState(root, (state) => { state.sessions[session] = { start: tree, sentBack: 0, at: Date.now() }; });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function claudeStop(root) {
|
|
75
|
+
const { session_id: session } = JSON.parse(stdin());
|
|
76
|
+
const tree = treeOf(root);
|
|
77
|
+
const turn = readState(root).sessions[session];
|
|
78
|
+
// A turn that changed no code (a question, a plan) ends without the gate.
|
|
79
|
+
if (turn?.start === tree) process.exit(0);
|
|
80
|
+
const verdict = await judge(root, tree, false);
|
|
81
|
+
if (verdict.passed) process.exit(0);
|
|
82
|
+
// Counted under the lock, from the state as it is now: the gate may have run for minutes since `turn` was read.
|
|
83
|
+
const sendBack = updateState(root, (state) => {
|
|
84
|
+
const now = state.sessions[session];
|
|
85
|
+
if ((now?.sentBack ?? 0) >= MAX_SENT_BACK) return false;
|
|
86
|
+
state.sessions[session] = { start: now?.start, sentBack: (now?.sentBack ?? 0) + 1, at: Date.now() };
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
if (!sendBack) {
|
|
90
|
+
// Let the turn end so a person can look; say so rather than end quietly.
|
|
91
|
+
console.log(JSON.stringify({ systemMessage: `The gate is still red after ${MAX_SENT_BACK} tries at ${verdict.stage ?? "an unknown stage"}: ${verdict.log ?? "run marram gate"}` }));
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
console.error(failure(verdict));
|
|
95
|
+
process.exit(2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function finish(root) {
|
|
99
|
+
const verdict = await judge(root, treeOf(root), false);
|
|
100
|
+
if (!verdict.passed) {
|
|
101
|
+
console.log(failure(verdict));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const modes = { "pre-push": prePush, "claude-turn": claudeTurn, "claude-stop": claudeStop, finish };
|
|
107
|
+
|
|
108
|
+
export async function hook(root, _config, [mode]) {
|
|
109
|
+
if (!(mode in modes)) {
|
|
110
|
+
console.error(`Usage: marram hook ${Object.keys(modes).join(" | ")}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
await modes[mode](root);
|
|
114
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A comment explaining a workaround reads as permission to work around: agents copy it, and it spreads. So a project can
|
|
2
|
+
// ban comments, as a finder for `restrict`. What stays is what no code can say: a doc comment on a declaration, a license
|
|
3
|
+
// header at the top of a file, and a comment with a link to the outside constraint it explains.
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
const isDoc = (text) => text.startsWith("/**");
|
|
7
|
+
const hasLink = (text) => /https?:\/\//.test(text);
|
|
8
|
+
const isLicense = (text, pos) => pos === 0 && /\b(license|copyright|spdx)\b/i.test(text);
|
|
9
|
+
|
|
10
|
+
/** The comments in `source` that aren't allowed, as `{ line, what }`, for `restrict`. */
|
|
11
|
+
export function comments(source) {
|
|
12
|
+
const found = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const look = (ranges) => {
|
|
15
|
+
for (const { pos, end } of ranges ?? []) {
|
|
16
|
+
if (seen.has(pos)) continue;
|
|
17
|
+
seen.add(pos);
|
|
18
|
+
const text = source.text.slice(pos, end);
|
|
19
|
+
if (isDoc(text) || hasLink(text) || isLicense(text, pos)) continue;
|
|
20
|
+
found.push({ line: source.getLineAndCharacterOfPosition(pos).line + 1, what: "has a comment" });
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const visit = (node) => {
|
|
24
|
+
look(ts.getLeadingCommentRanges(source.text, node.getFullStart()));
|
|
25
|
+
look(ts.getTrailingCommentRanges(source.text, node.getEnd()));
|
|
26
|
+
ts.forEachChild(node, visit);
|
|
27
|
+
};
|
|
28
|
+
visit(source);
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A ratchet lint: something is done in one owning place, and anywhere else only when listed with why. A fix written
|
|
2
|
+
// as a lint rule stops the bleeding before the cleanup; the list holds what was there already, and only shrinks:
|
|
3
|
+
// - a site not on the list fails, with what to do instead (the rule's `fix`);
|
|
4
|
+
// - an entry with no reason fails: the reason is what a reviewer judges;
|
|
5
|
+
// - an entry that no longer matches (its file is gone, or no longer does it) fails until it's removed, so a cleanup
|
|
6
|
+
// can't leave room for the next site to slip in under an old excuse.
|
|
7
|
+
//
|
|
8
|
+
// `ratchet` is the list's rules for any kind of site; `restrict` is the common case, sites found per file in a folder.
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join, relative } from "node:path";
|
|
11
|
+
import ts from "typescript";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Problems with `found` sites (`{ key, at, what }`: the entry that would allow it, where it is, what it does) against
|
|
15
|
+
* the `allowed` list (`{ key: reason }`, kept in `list`). `stale` says why a listed key no longer matches: a phrase, or a
|
|
16
|
+
* function of the key.
|
|
17
|
+
*/
|
|
18
|
+
export function ratchet({ list, allowed, found, fix, stale }) {
|
|
19
|
+
const problems = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const why = typeof stale === "function" ? stale : () => stale;
|
|
22
|
+
for (const site of found) {
|
|
23
|
+
if (Object.hasOwn(allowed, site.key)) seen.add(site.key);
|
|
24
|
+
else problems.push(`${site.at} ${site.what}: ${fix}, or list it in ${list} with why`);
|
|
25
|
+
}
|
|
26
|
+
for (const [key, reason] of Object.entries(allowed)) {
|
|
27
|
+
if (typeof reason !== "string" || reason.trim() === "") problems.push(`${key} is listed in ${list} without a reason: say why it's safe`);
|
|
28
|
+
if (!seen.has(key)) problems.push(`${key} ${why(key)}: remove it from ${list}`);
|
|
29
|
+
}
|
|
30
|
+
return problems;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The TypeScript files under `folder`, declaration files aside. */
|
|
34
|
+
export function sourceFiles(folder) {
|
|
35
|
+
return readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
|
|
36
|
+
const path = join(folder, entry.name);
|
|
37
|
+
if (entry.isDirectory()) return sourceFiles(path);
|
|
38
|
+
return /\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts") ? [path] : [];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const parse = (file) => ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
43
|
+
|
|
44
|
+
export const lineOf = (source, node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The ratchet over each file under `root`/`folder`: `find(source)` returns the file's sites (`{ line, what }`), and a
|
|
48
|
+
* file with any is allowed only when `allowed` lists it (by path from the folder), or it's one of `owners`, where the
|
|
49
|
+
* thing is meant to be done. `stale` says what a listed file no longer does.
|
|
50
|
+
*/
|
|
51
|
+
export function restrict({ root, folder, owners = [], find, allowed, list, fix, stale }) {
|
|
52
|
+
const base = join(root, folder);
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const file of sourceFiles(base)) {
|
|
55
|
+
const key = relative(base, file);
|
|
56
|
+
if (owners.includes(key)) continue;
|
|
57
|
+
const sites = find(parse(file));
|
|
58
|
+
if (sites.length === 0) continue;
|
|
59
|
+
const what = [...new Set(sites.map((site) => site.what))].join(", ");
|
|
60
|
+
found.push({ key, at: `${folder}/${key}:${sites[0].line}`, what });
|
|
61
|
+
}
|
|
62
|
+
return ratchet({ list, allowed, found, fix, stale: (key) => (existsSync(join(base, key)) ? stale : "is gone") });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Prints the problems and fails, or prints `ok`. */
|
|
66
|
+
export function finish(lint, problems, ok) {
|
|
67
|
+
if (problems.length > 0) {
|
|
68
|
+
console.error(`${lint}: ${problems.length} problem(s)\n${problems.map((problem) => ` ✗ ${problem}`).join("\n")}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
console.log(`ok: ${ok}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Waiting in checks. A check with its own `until` has its own bound (10 s to 60 s, or a count of tries) and its own
|
|
2
|
+
// failure ("timed out"), so a wait that fails under the gate's load says neither how long it waited nor what it saw. This one waits a generous minute for everything, and says both.
|
|
3
|
+
//
|
|
4
|
+
// Time bounds the other way, "this ended quickly", are `sooner`: the bound is half the time the broken behaviour takes,
|
|
5
|
+
// so it depends on the check's stand-ins rather than on how loaded the machine is. How work grows with its input is
|
|
6
|
+
// `scales`: a ratio of two timings taken together, which load slows alike.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
|
+
|
|
10
|
+
/** How long a wait lasts before it fails. Generous, as it only decides how long a failing check takes to fail. */
|
|
11
|
+
const LIMIT_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
const describe = (value: unknown) => (typeof value === "string" ? value : JSON.stringify(value));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Waits until `check` holds, failing after a minute with `what` it waited for and, given `seen`, what it last saw:
|
|
17
|
+
* pass `seen` whenever the state behind `check` would explain a failure. A minute outlasts many stand-ins (`sleep 30`):
|
|
18
|
+
* waiting for one to be stopped, follow with `sooner` against its lifetime, or its own exit passes for the stop.
|
|
19
|
+
*/
|
|
20
|
+
export async function until(check: () => boolean | Promise<boolean>, what: string, seen?: () => unknown): Promise<void> {
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
while (!(await check())) {
|
|
23
|
+
if (Date.now() - started > LIMIT_MS) {
|
|
24
|
+
throw new Error(`Timed out after ${LIMIT_MS / 1000} s waiting for ${what}${seen === undefined ? "" : `; saw ${describe(seen())}`}`);
|
|
25
|
+
}
|
|
26
|
+
await sleep(25);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Asserts that what began at `started` (a `Date.now()`) ended in under half of `brokenMs`: how long it takes when the
|
|
32
|
+
* behaviour under test is broken, such as a stand-in that hangs for 30 s or a server that answers after 10 s. So the
|
|
33
|
+
* bound holds on a loaded machine yet fails when the behaviour is broken.
|
|
34
|
+
*/
|
|
35
|
+
export function sooner(started: number, brokenMs: number, what: string): void {
|
|
36
|
+
const elapsed = Date.now() - started;
|
|
37
|
+
assert(elapsed < brokenMs / 2, `${what}: took ${elapsed} ms, when broken it takes ${brokenMs} ms`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Asserts that the work `setup(size)` returns grows no faster than `size ** exponent`: it times the work at `from` and
|
|
42
|
+
* at `to` (the best of several runs each) and compares. Linear work grows by about `to / from`, quadratic by its
|
|
43
|
+
* square; an exponent between them tells the two apart on any machine, as both timings share its load.
|
|
44
|
+
*/
|
|
45
|
+
export function scales(what: string, setup: (size: number) => () => void, { from, to, exponent }: { from: number; to: number; exponent: number }): void {
|
|
46
|
+
const best = (size: number) => {
|
|
47
|
+
const work = setup(size);
|
|
48
|
+
let fastest = Infinity;
|
|
49
|
+
for (let run = 0; run < 5; run++) {
|
|
50
|
+
const started = performance.now();
|
|
51
|
+
work();
|
|
52
|
+
fastest = Math.min(fastest, performance.now() - started);
|
|
53
|
+
}
|
|
54
|
+
return Math.max(fastest, 0.01);
|
|
55
|
+
};
|
|
56
|
+
const small = best(from), large = best(to);
|
|
57
|
+
const grown = Math.log(large / small) / Math.log(to / from);
|
|
58
|
+
assert(grown < exponent, `${what}: from ${from} to ${to} it took ${small.toFixed(2)} ms then ${large.toFixed(2)} ms, growing as size^${grown.toFixed(2)}, above size^${exponent}`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A feature map is "materialized memory" (Lauren Tan's term): every feature the app has, how a user reaches it, and
|
|
2
|
+
// what verifies it, so an agent can read a vague bug report against it. It stays honest only if checked: every check it
|
|
3
|
+
// cites must exist, and every feature must say how it's verified.
|
|
4
|
+
//
|
|
5
|
+
// Each feature is a `- **Name**` bullet followed by status lines: `✅ <journey> › "<check name>"`, `✅ <journey>` (the
|
|
6
|
+
// whole journey), a project's own citations (`✅ server › <script>.ts`), or `❌ <why it isn't verified>`.
|
|
7
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import type { JourneySession } from "./session.ts";
|
|
10
|
+
|
|
11
|
+
type Journeys = Record<string, { readonly run: (session: never) => Promise<void> }>;
|
|
12
|
+
|
|
13
|
+
/** Another kind of citation: what it looks like, and what's wrong with one (undefined when what it names exists). */
|
|
14
|
+
export interface Citation {
|
|
15
|
+
readonly pattern: RegExp;
|
|
16
|
+
readonly broken: (match: RegExpExecArray) => string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Check names per function in the journey sources (`<session>.check("…")`); `${…}` in a name matches anything. */
|
|
20
|
+
function checksByFunction(folder: string, session: string): Map<string, RegExp[]> {
|
|
21
|
+
const byFunction = new Map<string, RegExp[]>();
|
|
22
|
+
const call = new RegExp(`${session}\\.check\\(\\s*(["\`])([\\s\\S]*?)\\1`, "g");
|
|
23
|
+
for (const file of readdirSync(folder).filter((name) => name.endsWith(".ts"))) {
|
|
24
|
+
const source = readFileSync(join(folder, file), "utf8");
|
|
25
|
+
const starts = [...source.matchAll(/^(?:export )?async function (\w+)\(/gm)];
|
|
26
|
+
starts.forEach((start, index) => {
|
|
27
|
+
const body = source.slice(start.index, starts[index + 1]?.index ?? source.length);
|
|
28
|
+
const names = [...body.matchAll(call)].map(([, , name]) => {
|
|
29
|
+
const pattern = name!.split(/\$\{[^}]*\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".+");
|
|
30
|
+
return new RegExp(`^${pattern}$`);
|
|
31
|
+
});
|
|
32
|
+
byFunction.set(start[1]!, names);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return byFunction;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What's wrong with a `✅` line's citation, or undefined when it names something that exists. */
|
|
39
|
+
function brokenCitation(cited: string, journeys: Journeys, checksOf: (journey: string) => RegExp[], citations: readonly Citation[]): string | undefined {
|
|
40
|
+
for (const { pattern, broken } of citations) {
|
|
41
|
+
const match = pattern.exec(cited);
|
|
42
|
+
if (match !== null) return broken(match);
|
|
43
|
+
}
|
|
44
|
+
const [, journey, check] = /^([\w-]+)(?: › "(.+)")?$/.exec(cited) ?? [];
|
|
45
|
+
if (journey === undefined || journeys[journey] === undefined) return `no journey in "${cited}"`;
|
|
46
|
+
if (check !== undefined && !checksOf(journey).some((pattern) => pattern.test(check))) return `${journey} has no check "${check}"`;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The map's problems, and how many features it has and how many have gaps. `folder` holds the journey sources, whose
|
|
52
|
+
* functions not named by a journey are helpers, so their checks count for every journey.
|
|
53
|
+
*/
|
|
54
|
+
export function readFeatureMap({ map, folder, journeys, citations = [], session = "session" }: {
|
|
55
|
+
map: string;
|
|
56
|
+
folder: string;
|
|
57
|
+
journeys: Journeys;
|
|
58
|
+
citations?: readonly Citation[];
|
|
59
|
+
session?: string;
|
|
60
|
+
}): { broken: string[]; unmarked: string[]; features: number; gaps: number } {
|
|
61
|
+
const byFunction = checksByFunction(folder, session);
|
|
62
|
+
const isJourney = (name: string) => Object.values(journeys).some((journey) => journey.run.name === name);
|
|
63
|
+
const helpers = [...byFunction].filter(([name]) => !isJourney(name)).flatMap(([, names]) => names);
|
|
64
|
+
const checksOf = (journey: string) => [...(byFunction.get(journeys[journey]!.run.name) ?? []), ...helpers];
|
|
65
|
+
const broken: string[] = [];
|
|
66
|
+
const features: { name: string; statuses: string[] }[] = [];
|
|
67
|
+
for (const [index, line] of readFileSync(map, "utf8").split("\n").entries()) {
|
|
68
|
+
const feature = /^\s*- \*\*(.+?)\*\*/.exec(line);
|
|
69
|
+
const status = /^\s*- (✅|❌) (.*)$/.exec(line);
|
|
70
|
+
if (feature !== null) features.push({ name: feature[1]!, statuses: [] });
|
|
71
|
+
else if (status === null) continue;
|
|
72
|
+
else if (features.length === 0) broken.push(`line ${index + 1}: a status outside any feature`);
|
|
73
|
+
else {
|
|
74
|
+
features.at(-1)!.statuses.push(status[1]!);
|
|
75
|
+
const problem = status[1] === "✅" ? brokenCitation(status[2]!, journeys, checksOf, citations) : undefined;
|
|
76
|
+
if (problem !== undefined) broken.push(`line ${index + 1}: ${problem}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
broken,
|
|
81
|
+
unmarked: features.filter((feature) => feature.statuses.length === 0).map((feature) => feature.name),
|
|
82
|
+
features: features.length,
|
|
83
|
+
gaps: features.filter((feature) => feature.statuses.includes("❌")).length,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The feature-map journey's checks: every citation resolves, and every feature says how it's verified. */
|
|
88
|
+
export function checkFeatureMap(session: JourneySession, options: Parameters<typeof readFeatureMap>[0]): void {
|
|
89
|
+
const { broken, unmarked, features, gaps } = readFeatureMap(options);
|
|
90
|
+
session.check("Every check the feature map cites exists", broken.length === 0, broken.slice(0, 10).join("; "));
|
|
91
|
+
session.check("Every feature in the map says how it is verified", unmarked.length === 0, unmarked.slice(0, 10).join(", "));
|
|
92
|
+
console.log(` ${features} features: ${features - gaps} verified, ${gaps} with gaps (❌ in ${relative(process.cwd(), options.map)})`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Frame rates: whatever the app shows or does, it keeps drawing at 60 frames per second or more. A scenario samples
|
|
2
|
+
// every frame the renderer draws (requestAnimationFrame) while it runs, plus the long animation frames Chromium
|
|
3
|
+
// reports, and checks the average rate and the longest frame. Displays faster than 60 Hz pass as long as no frame takes
|
|
4
|
+
// longer than two 60 Hz frames. A slow frame's details say where its time went: tasks, callbacks, or style and layout.
|
|
5
|
+
import type { JourneySession } from "./session.ts";
|
|
6
|
+
|
|
7
|
+
/** At least this many frames per second on average; a little under 60 for timer jitter. */
|
|
8
|
+
const MIN_FPS = 58;
|
|
9
|
+
/** No frame longer than this: one dropped frame at 60 Hz is a hitch nobody sees, two in a row are. */
|
|
10
|
+
const MAX_FRAME_MS = 35;
|
|
11
|
+
/** Sample at least this long, so a short action still says something about the rate. */
|
|
12
|
+
const MIN_SAMPLE_MS = 1000;
|
|
13
|
+
|
|
14
|
+
// Plain script text, so tsx's helpers don't leak into the page.
|
|
15
|
+
export const FRAME_SAMPLER = `globalThis.__fps = {
|
|
16
|
+
start() {
|
|
17
|
+
const state = { times: [], slow: [], running: true };
|
|
18
|
+
const tick = (time) => {
|
|
19
|
+
if (!state.running) return;
|
|
20
|
+
state.times.push(time);
|
|
21
|
+
requestAnimationFrame(tick);
|
|
22
|
+
};
|
|
23
|
+
requestAnimationFrame(tick);
|
|
24
|
+
state.observer = new PerformanceObserver((list) => {
|
|
25
|
+
for (const entry of list.getEntries()) {
|
|
26
|
+
const end = entry.startTime + entry.duration;
|
|
27
|
+
state.slow.push({
|
|
28
|
+
duration: Math.round(entry.duration),
|
|
29
|
+
// Where the frame went: tasks before rendering, rAF and observer callbacks, then style and layout.
|
|
30
|
+
tasks: Math.round((entry.renderStart || end) - entry.startTime),
|
|
31
|
+
callbacks: Math.round(entry.renderStart ? entry.styleAndLayoutStart - entry.renderStart : 0),
|
|
32
|
+
layout: Math.round(entry.styleAndLayoutStart ? end - entry.styleAndLayoutStart : 0),
|
|
33
|
+
scripts: (entry.scripts ?? []).slice(0, 3).map((script) =>
|
|
34
|
+
\`\${script.invoker || script.invokerType} \${Math.round(script.duration)}ms \${(script.sourceURL ?? "").split("/").pop()}\`),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
try { state.observer.observe({ type: "long-animation-frame" }); } catch {}
|
|
39
|
+
this.state = state;
|
|
40
|
+
},
|
|
41
|
+
stop() {
|
|
42
|
+
const state = this.state;
|
|
43
|
+
state.running = false;
|
|
44
|
+
state.observer.disconnect();
|
|
45
|
+
return { times: state.times, slow: state.slow };
|
|
46
|
+
},
|
|
47
|
+
};`;
|
|
48
|
+
|
|
49
|
+
export interface Sample {
|
|
50
|
+
readonly scenario: string;
|
|
51
|
+
readonly fps: number;
|
|
52
|
+
readonly longestMs: number;
|
|
53
|
+
readonly p99Ms: number;
|
|
54
|
+
readonly frames: number;
|
|
55
|
+
readonly seconds: number;
|
|
56
|
+
readonly slowFrames: { duration: number; tasks: number; callbacks: number; layout: number; scripts: string[] }[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run `action` while sampling frames (the page has run `FRAME_SAMPLER`), record the numbers in `samples`, and check them. */
|
|
60
|
+
export async function measureFrames(session: JourneySession, samples: Sample[], scenario: string, action: () => Promise<unknown>): Promise<void> {
|
|
61
|
+
const { page } = session;
|
|
62
|
+
await page.evaluate("globalThis.__fps.start()");
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
await action();
|
|
65
|
+
const left = MIN_SAMPLE_MS - (Date.now() - started);
|
|
66
|
+
if (left > 0) await session.wait(left);
|
|
67
|
+
const { times, slow } = (await page.evaluate("globalThis.__fps.stop()")) as { times: number[]; slow: Sample["slowFrames"] };
|
|
68
|
+
const intervals = times.slice(1).map((time, index) => time - times[index]!);
|
|
69
|
+
const sorted = [...intervals].sort((a, b) => a - b);
|
|
70
|
+
const seconds = (times.at(-1)! - times[0]!) / 1000;
|
|
71
|
+
const sample: Sample = {
|
|
72
|
+
scenario,
|
|
73
|
+
fps: Math.round((intervals.length / seconds) * 10) / 10,
|
|
74
|
+
longestMs: Math.round(sorted.at(-1) ?? Infinity),
|
|
75
|
+
p99Ms: Math.round(sorted[Math.floor(sorted.length * 0.99)] ?? Infinity),
|
|
76
|
+
frames: intervals.length,
|
|
77
|
+
seconds: Math.round(seconds * 10) / 10,
|
|
78
|
+
slowFrames: slow,
|
|
79
|
+
};
|
|
80
|
+
samples.push(sample);
|
|
81
|
+
const culprit = slow.toSorted((a, b) => b.duration - a.duration)[0];
|
|
82
|
+
session.check(
|
|
83
|
+
`${scenario}: at least 60 FPS`,
|
|
84
|
+
sample.fps >= MIN_FPS && sample.longestMs <= MAX_FRAME_MS,
|
|
85
|
+
`${sample.fps} fps, longest frame ${sample.longestMs}ms, p99 ${sample.p99Ms}ms over ${sample.seconds}s` +
|
|
86
|
+
(culprit === undefined
|
|
87
|
+
? ""
|
|
88
|
+
: `; slowest: ${culprit.duration}ms (tasks ${culprit.tasks}ms, callbacks ${culprit.callbacks}ms, style and layout ${culprit.layout}ms)` +
|
|
89
|
+
(culprit.scripts.length === 0 ? "" : ` in ${culprit.scripts.join(", ")}`)),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Layout held as relationships between boxes rather than pixels: this edge meets that one, this box doesn't overlap
|
|
2
|
+
// that one, every control shown can be clicked where it's drawn. Screenshots change with live times, fonts and motion;
|
|
3
|
+
// these hold whatever the content.
|
|
4
|
+
import type { Locator, Page } from "playwright-core";
|
|
5
|
+
|
|
6
|
+
export interface Box {
|
|
7
|
+
readonly left: number;
|
|
8
|
+
readonly top: number;
|
|
9
|
+
readonly right: number;
|
|
10
|
+
readonly bottom: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Half a pixel, for subpixel layout. */
|
|
14
|
+
const SLACK = 0.5;
|
|
15
|
+
|
|
16
|
+
/** `locator`'s box in the window, or a failure naming `what` when it isn't shown. */
|
|
17
|
+
export async function box(locator: Locator, what: string): Promise<Box> {
|
|
18
|
+
const found = await locator.boundingBox();
|
|
19
|
+
if (found === null) throw new Error(`${what} isn't shown`);
|
|
20
|
+
return { left: found.x, top: found.y, right: found.x + found.width, bottom: found.y + found.height };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const inside = (inner: Box, outer: Box): boolean =>
|
|
24
|
+
inner.left >= outer.left - SLACK && inner.right <= outer.right + SLACK && inner.top >= outer.top - SLACK && inner.bottom <= outer.bottom + SLACK;
|
|
25
|
+
|
|
26
|
+
/** `upper` ends at least `gap` above where `lower` starts. */
|
|
27
|
+
export const above = (upper: Box, lower: Box, gap = 0): boolean => upper.bottom + gap <= lower.top + SLACK;
|
|
28
|
+
|
|
29
|
+
export const apart = (a: Box, b: Box): boolean => a.right <= b.left + SLACK || b.right <= a.left + SLACK || above(a, b) || above(b, a);
|
|
30
|
+
|
|
31
|
+
export const near = (a: number, b: number, within = 1): boolean => Math.abs(a - b) <= within;
|
|
32
|
+
|
|
33
|
+
/** The boxes, rounded, for a failure's details. */
|
|
34
|
+
export const shown = (boxes: Record<string, Box>): string =>
|
|
35
|
+
JSON.stringify(Object.fromEntries(Object.entries(boxes).map(([name, { left, top, right, bottom }]) => [name, [left, top, right, bottom].map(Math.round)])));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The controls under `root` that are shown but can't be clicked where they're drawn: cut off by a box that clips them
|
|
39
|
+
* (or by the window), covered by something else at their centre, or letting clicks through to what's beneath. A
|
|
40
|
+
* control scrolled out of view, or partly out of a box that scrolls that way, is fine; one cut off across the way its
|
|
41
|
+
* box scrolls (a row wider than a sidebar that only scrolls up and down) is not. Invisible ones (hover actions at
|
|
42
|
+
* opacity 0) aren't shown.
|
|
43
|
+
*/
|
|
44
|
+
export async function hiddenControls(page: Page, root: string): Promise<string[]> {
|
|
45
|
+
// tsx names the functions inside, with a helper the page doesn't have.
|
|
46
|
+
await page.evaluate("globalThis.__name ??= (target) => target;");
|
|
47
|
+
return page.evaluate((root) => {
|
|
48
|
+
const name = (element: Element) => {
|
|
49
|
+
const label = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.textContent?.trim().slice(0, 40);
|
|
50
|
+
return `${element.tagName.toLowerCase()} "${label ?? ""}"`;
|
|
51
|
+
};
|
|
52
|
+
const where = (element: Element) => `${element.tagName.toLowerCase()}.${[...element.classList].slice(0, 4).join(".")}`;
|
|
53
|
+
/** Drawn at all: some size, and neither hidden nor faded out (with its ancestors). */
|
|
54
|
+
const drawn = (control: Element, rect: DOMRect) => {
|
|
55
|
+
if (rect.width === 0 || rect.height === 0) return false;
|
|
56
|
+
let opacity = 1;
|
|
57
|
+
for (let node: Element | null = control; node !== null; node = node.parentElement) {
|
|
58
|
+
const style = getComputedStyle(node);
|
|
59
|
+
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
60
|
+
opacity *= Number(style.opacity);
|
|
61
|
+
}
|
|
62
|
+
return opacity >= 0.05;
|
|
63
|
+
};
|
|
64
|
+
/** The boxes that clip `control`, innermost first, each with whether it scrolls up and down; then the window. */
|
|
65
|
+
const clips = (control: Element) => {
|
|
66
|
+
const found: Array<{ clip: DOMRect; label: string; scrolls: boolean }> = [];
|
|
67
|
+
for (let node = control.parentElement; node !== null && node !== document.documentElement; node = node.parentElement) {
|
|
68
|
+
const style = getComputedStyle(node);
|
|
69
|
+
if (style.overflowX === "visible" && style.overflowY === "visible") continue;
|
|
70
|
+
found.push({ clip: node.getBoundingClientRect(), label: where(node), scrolls: style.overflowY === "auto" || style.overflowY === "scroll" });
|
|
71
|
+
}
|
|
72
|
+
found.push({ clip: new DOMRect(0, 0, window.innerWidth, window.innerHeight), label: "the window", scrolls: false });
|
|
73
|
+
return found;
|
|
74
|
+
};
|
|
75
|
+
/** "away" when `rect` is scrolled (wholly or partly) out of a box that scrolls, else the box cutting it off, if any. */
|
|
76
|
+
const cutOff = (rect: DOMRect, control: Element) => {
|
|
77
|
+
for (const { clip, label, scrolls } of clips(control)) {
|
|
78
|
+
if (rect.right <= clip.left || rect.left >= clip.right || rect.bottom <= clip.top || rect.top >= clip.bottom) return "away";
|
|
79
|
+
const across = rect.left < clip.left - 0.5 || rect.right > clip.right + 0.5;
|
|
80
|
+
const along = rect.top < clip.top - 0.5 || rect.bottom > clip.bottom + 0.5;
|
|
81
|
+
if (across || (along && !scrolls)) return label;
|
|
82
|
+
if (along) return "away";
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
};
|
|
86
|
+
const problem = (control: Element) => {
|
|
87
|
+
const rect = control.getBoundingClientRect();
|
|
88
|
+
if (!drawn(control, rect)) return [];
|
|
89
|
+
const cut = cutOff(rect, control);
|
|
90
|
+
if (cut === "away") return [];
|
|
91
|
+
if (cut !== undefined) return [`${name(control)} is cut off by ${cut}`];
|
|
92
|
+
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
93
|
+
if (hit === null || control.contains(hit)) return [];
|
|
94
|
+
// Under its own box, a click only reaches an ancestor when the control lets it through (pointer-events: none).
|
|
95
|
+
return [hit.contains(control) ? `${name(control)} lets clicks through to ${where(hit)}` : `${name(control)} is covered by ${where(hit)}`];
|
|
96
|
+
};
|
|
97
|
+
const scope = document.querySelector(root);
|
|
98
|
+
if (scope === null) return [`${root} isn't shown`];
|
|
99
|
+
return [...scope.querySelectorAll('button, a[href], input:not([type="hidden"]), textarea, select, [role="tab"], [role="menuitem"]')].flatMap(problem);
|
|
100
|
+
}, root);
|
|
101
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A journey's check that reads the app reads it through a function, which the check polls: an `await` inside a plain
|
|
2
|
+
// boolean reads once, racing whatever the app is still doing.
|
|
3
|
+
// A check given a function returns a promise, so it must be awaited, or the journey goes on, and may end, before it's
|
|
4
|
+
// recorded. Whether a check polls is its argument's type (a function, written in place or named), so the type checker
|
|
5
|
+
// decides it, not the syntax.
|
|
6
|
+
import { readdirSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { lineOf } from "../core/ratchet.mjs";
|
|
9
|
+
import ts from "typescript";
|
|
10
|
+
|
|
11
|
+
const isFunction = (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node);
|
|
12
|
+
/** Awaits something itself, not in a function it defines. */
|
|
13
|
+
const awaitsHere = (node) => ts.isAwaitExpression(node) || (!isFunction(node) && (ts.forEachChild(node, (child) => (awaitsHere(child) ? true : undefined)) ?? false));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The checks in `source` (calls `<session>.check(label, ok, detail?)`) that read once, or whose polling nobody waits
|
|
17
|
+
* for, with their lines.
|
|
18
|
+
*/
|
|
19
|
+
export function readOnce(source, checker, session = "session") {
|
|
20
|
+
const found = [];
|
|
21
|
+
const visit = (node) => {
|
|
22
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "check" && node.expression.expression.getText(source) === session) {
|
|
23
|
+
const [, ok, detail] = node.arguments;
|
|
24
|
+
const polled = ok !== undefined && (ts.isSpreadElement(ok) || checker.getTypeAtLocation(ok).getCallSignatures().length > 0);
|
|
25
|
+
const line = lineOf(source, node);
|
|
26
|
+
if (ok !== undefined && !polled && awaitsHere(ok)) found.push(`line ${line}: reads once, inside the check: pass a function, which the check polls until it holds`);
|
|
27
|
+
if (polled && detail !== undefined && !isFunction(detail) && awaitsHere(detail)) found.push(`line ${line}: reads its details before the check polls: pass them as a function, read on failure`);
|
|
28
|
+
if (polled && !ts.isAwaitExpression(node.parent)) found.push(`line ${line}: a polled check nobody awaits: the journey goes on, and may end, before it's recorded`);
|
|
29
|
+
}
|
|
30
|
+
ts.forEachChild(node, visit);
|
|
31
|
+
};
|
|
32
|
+
visit(source);
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Problems in the journeys (`.ts` files) of `root`/`folder`, type-checked with `tsconfig`'s options; and how many there were. */
|
|
37
|
+
export function lintJourneys({ root, folder, tsconfig, session = "session" }) {
|
|
38
|
+
const base = join(root, folder);
|
|
39
|
+
const files = readdirSync(base).filter((name) => name.endsWith(".ts")).map((name) => join(base, name));
|
|
40
|
+
const config = ts.getParsedCommandLineOfConfigFile(join(root, tsconfig), {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} });
|
|
41
|
+
if (config === undefined) throw new Error(`Can't read ${tsconfig}`);
|
|
42
|
+
const program = ts.createProgram(files, config.options);
|
|
43
|
+
const checker = program.getTypeChecker();
|
|
44
|
+
return { files: files.length, problems: files.flatMap((file) => readOnce(program.getSourceFile(file), checker, session).map((problem) => `${relative(root, file)}: ${problem}`)) };
|
|
45
|
+
}
|