done-done-axi 0.3.1 → 0.3.2

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/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
4
4
  import { accessSync, appendFileSync, closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { homedir, tmpdir, userInfo } from 'node:os';
6
6
  import { basename, dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
8
  import { createInterface } from 'node:readline/promises';
9
9
  import { parseArgs } from 'node:util';
10
10
  import { encode } from '@toon-format/toon';
@@ -17,7 +17,8 @@ import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWo
17
17
  import { cellEnv, cellName, cellSuffix, hasMatrix, matrixManifest, splitMatrix } from './matrix.js';
18
18
  import { design, KINDS } from './design.js';
19
19
  import { checkMedia } from './media.js';
20
- import { domIssues, isDomRecord, SQUASHED_TEXT } from './dom.js';
20
+ import { domIssues, domRecord, isDomRecord, lookIssues, SQUASHED_TEXT } from './dom.js';
21
+ import { look } from './look.js';
21
22
  import { scanSecrets } from './secrets.js';
22
23
  const PASS = 0, FAIL = 1, USAGE = 2, INCONCLUSIVE = 3, WAITING = 4;
23
24
  // The shape of a journey, so a new user or agent can write one without opening the reference.
@@ -71,7 +72,7 @@ const DOCTOR_TEXT = `doctor changes nothing except the --probe call record (the
71
72
  optional: verify_skill (.agents/skills/verify-<app>/), agent (the agent the user set and its model; missing when none is
72
73
  set, with the command that saves the user's choice), agent_settings (agent, model, effort,
73
74
  args, path and agent_timeout, each with the file it came from: the global config, .done-done.yaml or default), judge (the judge route is ready: for claude-code an
74
- executable claude binary, a sign-in folder and the judge kit, for answerer:<agent> that agent's CLI and the kit, for openai:key
75
+ executable claude binary and the judge kit (its sign-in is checked only when a capture is judged), for answerer:<agent> that agent's CLI and the kit, for openai:key
75
76
  OPENAI_API_KEY and the kit; doctor does not call the model; missing when none is set, with the command that saves the user's choice),
76
77
  judge_choice (the judge, and how far it is from the author: another agent, another model, or session-only), ffprobe (video evidence only).
77
78
  --probe also asks the agent one JSON-schema question (one call at the agent's model: its cheap default where it has one, else its own; set agent_config.<agent>.model to choose; its record goes to
@@ -80,8 +81,8 @@ const DOCTOR_TEXT = `doctor changes nothing except the --probe call record (the
80
81
  const CONFIG_TEXT = `config: ~/.done-done/config.yaml (global; DONE_DONE_HOME moves it) and .done-done.yaml (the repo), repo over global.
81
82
  agent (either file): claude, codex, opencode, pi, gemini or acp:<target>, set by the user (done-done init --agent); done-done never picks one.
82
83
  global only: agent_config.<agent>.{model,effort} (effort: minimal, low, medium, high, xhigh, max), agent_args_override.<agent>
83
- (extra flags), agent_path_override.<agent> (binary), acpx_path, agent_timeout (one agent call, default 30m),
84
- evidence.local_root, judge.{route,model,allow_same,claude_code.config_dir} (the judge, set by the user with
84
+ (extra flags), agent_path_override.<agent> (binary), acpx_path, agent_timeout (one agent call, default 30m; a streaming agent that prints nothing for 5m is stopped early),
85
+ evidence.local_root, judge.{route,model,allow_same} (the judge, set by the user with
85
86
  init --judge; never picked). done-done doctor shows where each agent setting came from.`;
86
87
  // One table drives `--help` and every `<command> --help`: each command's usage lines and the flags it takes.
87
88
  const FLAGS = {
@@ -109,6 +110,7 @@ const FLAGS = {
109
110
  '--action <approve|skip|fix>': 'approve: the captures do show the outcome; skip: go on without a judge pass; both end the run overridden; fix: end it fail, then fix the product or the verify skill (done-done maintain checks it against the source)',
110
111
  '--reason "<why>"': 'why (required for approve and skip); kept in the run and its receipt with who answered',
111
112
  '--limit <n>': 'how many past runs to list (default all)',
113
+ '--out <file.png>': 'where look writes the screenshot; its page-check record goes next to it as <file>.dom.json (default: look.png in $DONE_DONE_EVIDENCE_ROOT inside a journey, else in the current folder)',
112
114
  '--agent <agent>': 'the coding agent done-done starts (claude, codex, opencode, pi, gemini or acp:<target>), saved as agent in ~/.done-done/config.yaml; without it, init asks in a terminal',
113
115
  '--model <model>': 'with --agent: that agent\'s model, saved as agent_config.<agent>.model (opencode: provider/model)',
114
116
  '--judge <agent>': `the judge done-done starts (${JUDGES.join(' or ')}), saved as judge.route in ~/.done-done/config.yaml; never the agent and model that did the work; without it, init asks in a terminal`,
@@ -187,6 +189,14 @@ const COMMANDS = {
187
189
  more: [`design: one judge request per screen (the judge the user set, never the author); it fails a screen on any defect it names:
188
190
  ${Object.keys(KINDS).join(', ')}; capture each variant (dark, small width, large font) as its own screen; exit 0 every screen
189
191
  passed, 1 a screen failed, 3 none failed but one is unsure`],
192
+ },
193
+ look: {
194
+ usage: [['done-done look <url> [--out <file.png>]', 'open a page in headless Chrome at a phone viewport, capture it with its page-check record, and fail on any squashed, clipped or cut-off UI']],
195
+ flags: ['--out <file.png>'],
196
+ more: [`look: one deterministic step a journey calls as its command or inside its script; no judge, no model. It measures horizontal
197
+ overflow, squashed text, text clipped by its container and elements cut off by the left, right or top edge, writes the
198
+ screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none. A hit always
199
+ fails: never wrap it in || true. Needs Chrome or Chromium (DONE_DONE_CHROME names the binary)`],
190
200
  },
191
201
  status: {
192
202
  usage: [['done-done status [--run <run-id>]', "show a run's steps, result, findings and next step"]],
@@ -349,7 +359,14 @@ function openRun(repo, cfg, journey, command, id) {
349
359
  mkdirSync(runDir(cfg, runId));
350
360
  return { schema: 1, run_id: runId, journey, command, ...h, started_at: new Date().toISOString(), ended_at: null, exit: null, files: [] };
351
361
  }
352
- function closeRun(repo, cfg, rec) {
362
+ // The first line of a run's log that names a failure, trimmed: what a non-zero exit is about. Empty when none does.
363
+ function firstFailure(log) {
364
+ if (!existsSync(log))
365
+ return '';
366
+ const line = readFileSync(log, 'utf8').split('\n').map((l) => l.replace(/\x1b\[[\d;]*m/g, '').trim()).find((l) => /\b(FAIL|FAILED|Error|failed|timed out)\b/.test(l));
367
+ return line ? line.slice(0, 200) : '';
368
+ }
369
+ function closeRun(repo, cfg, rec, log) {
353
370
  const now = head(repo);
354
371
  rec.files = captured(cfg, rec.run_id)
355
372
  .sort()
@@ -365,8 +382,10 @@ function closeRun(repo, cfg, rec) {
365
382
  let reason = '';
366
383
  if (now.commit !== rec.commit)
367
384
  reason = `HEAD moved from ${rec.commit} to ${now.commit} during the run; evidence is not tied to one commit`;
368
- else if (rec.exit !== null && rec.exit !== 0)
369
- reason = `the run command exited ${rec.exit}`;
385
+ else if (rec.exit !== null && rec.exit !== 0) {
386
+ const failure = log ? firstFailure(log) : '';
387
+ reason = `the run command exited ${rec.exit}${failure ? `: ${failure}` : ''}`;
388
+ }
370
389
  else if (!rec.files.length)
371
390
  reason = `the run wrote no files into its folder ${shown(repo, runDir(cfg, rec.run_id))}; the command gets it as $DONE_DONE_EVIDENCE_ROOT and its run id as $DONE_DONE_RUN_ID (or \${RUN_ID} in the command)`;
372
391
  const evidence = FULL ? rec.files.map((f) => ({ path: rel(f.path), sha256: f.sha256, bytes: f.bytes })) : rec.files.map((f) => rel(f.path));
@@ -481,14 +500,20 @@ async function cmdRun(argv) {
481
500
  const matrix = hasMatrix(cfg.matrix) ? splitMatrix(cfg.matrix) : null;
482
501
  const failed = [];
483
502
  rec.exit = 0;
503
+ // The run's log lives with its run store, never in the evidence folder the judge reads: a failure names its first failing line.
504
+ const logFile = join(runsRoot(cfg.app), rec.run_id, 'logs', 'run.log');
505
+ mkdirSync(dirname(logFile), { recursive: true });
484
506
  for (const cell of matrix?.required ?? [{}]) {
485
507
  // The journey's own stdout goes to our stderr, never our stdout: stdout carries only done-done's
486
508
  // result document (TOON or --json), so a noisy journey cannot break `run --json | JSON.parse`.
487
509
  const r = await new Promise((done) => {
488
- spawn(expanded, {
489
- cwd: repo, shell: true, stdio: ['inherit', process.stderr, 'inherit'],
510
+ const child = spawn(expanded, {
511
+ cwd: repo, shell: true, stdio: ['inherit', 'pipe', 'pipe'],
490
512
  env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: runDir(cfg, rec.run_id), ...inst?.env, ...(matrix ? cellEnv(cell) : {}) },
491
- }).on('error', () => done(null)).on('close', done);
513
+ });
514
+ for (const out of [child.stdout, child.stderr])
515
+ out?.on('data', (b) => { process.stderr.write(b); appendFileSync(logFile, b); });
516
+ child.on('error', () => done(null)).on('close', done);
492
517
  });
493
518
  const code = r ?? 1;
494
519
  if (code !== 0) {
@@ -498,7 +523,7 @@ async function cmdRun(argv) {
498
523
  }
499
524
  }
500
525
  reap();
501
- const sealed = closeRun(repo, cfg, rec);
526
+ const sealed = closeRun(repo, cfg, rec, logFile);
502
527
  if (matrix && failed.length)
503
528
  return emitted({ code: FAIL, result: { ...sealed.result, reason: `the run command failed for cell ${failed.join('; ')}` } });
504
529
  return emitted(sealed);
@@ -623,8 +648,21 @@ async function runCheck(argv, run) {
623
648
  };
624
649
  const befores = new Map();
625
650
  const heads = [];
651
+ // Citations are the paths `run` printed: repo-relative inside the repo, absolute outside it.
652
+ // Resolve a relative citation against the repo root first, so a check from a subdirectory
653
+ // finds the same evidence (a worker cwd once false-failed real proof as missing/ENOENT),
654
+ // then against the caller's cwd, so a shell-relative path typed for this shell still works.
655
+ const cite = (name) => {
656
+ if (isAbsolute(name))
657
+ return name;
658
+ const atRoot = resolve(repo, name);
659
+ if (existsSync(atRoot))
660
+ return atRoot;
661
+ const atCwd = resolve(name);
662
+ return existsSync(atCwd) ? atCwd : atRoot;
663
+ };
626
664
  for (const name of files) {
627
- const path = resolve(name);
665
+ const path = cite(name);
628
666
  let bytes;
629
667
  try {
630
668
  if (!statSync(path).isFile())
@@ -632,7 +670,9 @@ async function runCheck(argv, run) {
632
670
  bytes = readFileSync(path);
633
671
  }
634
672
  catch (e) {
635
- findings.push({ level: 'fail', why: `${name}: missing (${e.code ?? e.message})` });
673
+ const atCwd = !isAbsolute(name) && resolve(name) !== path ? ` or ${resolve(name)}` : '';
674
+ const missing = e.code !== 'ENOENT' ? undefined : under(cfg.evidence.root, path) === null ? 'checkout' : 'run';
675
+ findings.push({ level: 'fail', why: `${name}: no such file (looked for ${path}${atCwd}${e.code ? `, ${e.code}` : ''})`, missing });
636
676
  continue;
637
677
  }
638
678
  const v = checkMedia(path, bytes);
@@ -687,7 +727,7 @@ async function runCheck(argv, run) {
687
727
  if (stale)
688
728
  findings.push({ level: 'fail', why: `${name}: ${stale} (run ${rec.run_id})` });
689
729
  if (rec.dirty && !cfg.evidence.allow_dirty)
690
- findings.push({ level: 'fail', why: `${name}: captured from a tree with uncommitted changes (run ${rec.run_id})` });
730
+ findings.push({ level: 'fail', why: `${name}: captured from a tree with uncommitted changes (run ${rec.run_id}); commit them, then capture again` });
691
731
  if (rec.exit !== null && rec.exit !== 0)
692
732
  findings.push({ level: 'fail', why: `${name}: its run command exited ${rec.exit} (run ${rec.run_id})` });
693
733
  }
@@ -697,16 +737,16 @@ async function runCheck(argv, run) {
697
737
  findings.push({ level: 'inconclusive', why: `${c.name}: no base capture to compare with: the drive at base_ref ${rec.base.ref} saved no ${join(BEFORE, inRoot ? relative(rec.run_id, inRoot) : c.name)}${rec.base.error ? ` (${rec.base.error})` : ''}`, file: c.name });
698
738
  // The capture matrix: every required cell needs a capture named with its suffix, never silently missing.
699
739
  if (hasMatrix(cfg.matrix))
700
- for (const c of matrixManifest(cfg.matrix, files.filter((f) => (!cfg.dom || !isDomRecord(f)) && !pairOf(under(cfg.evidence.root, resolve(f))))).cells.filter((c) => !c.files.length))
740
+ for (const c of matrixManifest(cfg.matrix, files.filter((f) => (!cfg.dom || !isDomRecord(f)) && !pairOf(under(cfg.evidence.root, cite(f))))).cells.filter((c) => !c.files.length))
701
741
  findings.push({ level: 'fail', why: `cell ${c.cell} has no capture: name one file *${c.suffix}* (journeys name captures with DONE_DONE_CELL)` });
702
742
  // Reuse: fail only when every cited file was already used for a different claim id. The ledger lives in the
703
743
  // evidence root next to the run records it guards: one per app (repo), shared by every checkout of it, never in git.
704
744
  const ledger = join(cfg.evidence.root, '.done-done', 'ledger');
705
745
  const given = values['claim-id']?.replace(/\s+/g, ' ').trim();
706
- const claimId = given || prBranch || git(repo, 'branch', '--show-current');
707
- const idFrom = given ? '--claim-id' : prBranch ? 'the PR branch' : 'the current branch';
708
- if (!claimId && claim)
709
- throw new UsageError('HEAD is detached, so no branch names this claim; pass --claim-id <id>', 'Re-run with --claim-id <the branch or task this claim is for>');
746
+ // A detached HEAD (a worktree, a PR checkout) has no branch, so its claim is the commit it checks.
747
+ const branch = git(repo, 'branch', '--show-current');
748
+ const claimId = given || prBranch || branch || (claim ? now.commit : '');
749
+ const idFrom = given ? '--claim-id' : prBranch ? 'the PR branch' : branch ? 'the current branch' : 'the detached HEAD commit';
710
750
  const used = new Map();
711
751
  if (existsSync(ledger))
712
752
  for (const line of readFileSync(ledger, 'utf8').split('\n')) {
@@ -746,7 +786,7 @@ async function runCheck(argv, run) {
746
786
  const { choice } = j;
747
787
  judged = { ...(choice && { route: choice.route, model: choice.model, independence: choice.independence, author: `${choice.author.agent} (model ${choice.author.model || 'none'})` }), judged: rows.items.map(({ sha256, ...r }) => r) };
748
788
  if (choice && j.judged.length) {
749
- const byName = new Map(captures.map((c) => [c.name, receiptPath(repo, cfg, resolve(c.name))]));
789
+ const byName = new Map(captures.map((c) => [c.name, receiptPath(repo, cfg, cite(c.name))]));
750
790
  judgeBlock = { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author, verdicts: j.judged.map((r) => ({ path: byName.get(r.path), sha256: r.sha256, verdict: r.verdict })) };
751
791
  }
752
792
  if (rows.omitted)
@@ -839,8 +879,8 @@ function verifyReceipt(repo, cfg, headArg) {
839
879
  try {
840
880
  bytes = readFileSync(f.path.startsWith(IN_EVIDENCE) ? join(cfg.evidence.root, f.path.slice(IN_EVIDENCE.length)) : resolve(repo, f.path));
841
881
  }
842
- catch {
843
- bad.push(`${f.path}: missing`);
882
+ catch (e) {
883
+ bad.push(`${f.path}: no such file (looked for ${f.path.startsWith(IN_EVIDENCE) ? join(cfg.evidence.root, f.path.slice(IN_EVIDENCE.length)) : resolve(repo, f.path)}${e.code ? `, ${e.code}` : ''})`);
844
884
  continue;
845
885
  }
846
886
  if (sha256(bytes) !== f.sha256) {
@@ -906,7 +946,7 @@ function staleness(repo, cfg, from, to) {
906
946
  }
907
947
  // Quote an argument only when a shell would split it.
908
948
  const arg = (a) => (/^[\w./@:+-]+$/.test(a) ? a : `'${a.replaceAll("'", `'\\''`)}'`);
909
- function checkHelp(code, files, { cfg, repo, claim, source, citationBase, notProven, judgeLevel }) {
949
+ function checkHelp(code, findings, files, { cfg, repo, claim, source, citationBase, notProven, judgeLevel }) {
910
950
  if (notProven)
911
951
  return ['Capture real proof and replace "not proven:", or set checks.not_proven: allow and re-check from a trusted ref'];
912
952
  if (judgeLevel === 'fail' && code === FAIL)
@@ -924,7 +964,18 @@ function checkHelp(code, files, { cfg, repo, claim, source, citationBase, notPro
924
964
  const capture = captureHint(cfg);
925
965
  if (code === INCONCLUSIVE)
926
966
  return [`Capture this proof through a run so it is tied to a commit, then re-run this check: ${capture}`];
927
- return [`Commit your change, re-capture with ${capture}, then check the new files`];
967
+ // A named file outside this checkout is not lost: point at the folder that holds it first. Re-capture is for proof the run should have written here.
968
+ const help = [];
969
+ const absent = findings.filter((f) => f.missing === 'checkout').length;
970
+ if (absent) {
971
+ const form = source ? `done-done check --from ${arg(source)} --citation-base <dir>` : 'done-done check --outcome <claim> <files...> --citation-base <dir>';
972
+ help.push(citationBase === undefined
973
+ ? `${absent} named file${absent > 1 ? 's are' : ' is'} not in this checkout. If the evidence is in another folder, check against it: ${form}`
974
+ : `${absent} named file${absent > 1 ? 's are' : ' is'} not under --citation-base ${arg(citationBase)} either: check the paths in the Checks block`);
975
+ }
976
+ if (findings.some((f) => f.level === 'fail' && f.missing !== 'checkout'))
977
+ help.push(`Commit your change, re-capture with ${capture}, then check the new files`);
978
+ return help;
928
979
  }
929
980
  function report(findings, files, ctx, note = '') {
930
981
  const code = findings.some((f) => f.level === 'fail') ? FAIL : findings.length ? INCONCLUSIVE : PASS;
@@ -952,7 +1003,7 @@ function report(findings, files, ctx, note = '') {
952
1003
  result.findings = rows.items;
953
1004
  if (rows.omitted)
954
1005
  result.findings_omitted = rows.omitted;
955
- result.help = checkHelp(code, files, ctx);
1006
+ result.help = checkHelp(code, findings, files, ctx);
956
1007
  return { code, result };
957
1008
  }
958
1009
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -1378,7 +1429,7 @@ async function runSteps(dir) {
1378
1429
  if (skillFeatures(at, cfg).includes(picked))
1379
1430
  run.feature ??= picked;
1380
1431
  log('drive', { agent: author.agent, model: author.model, feature: run.feature ?? 'none', ...(a.ok ? { captures: answer?.captures, blocker } : { error: a.error }), record: a.record ?? a.recordError });
1381
- const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}`
1432
+ const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}${a.record ? `; call record ${a.record}` : ''}`
1382
1433
  : blocker ? `${author.agent} could not drive the feature: ${blocker}`
1383
1434
  : changed ? `${author.agent} changed tracked files in the repo during the drive (see git status), so its captures are not of the committed code`
1384
1435
  : sealed.code !== PASS ? sealed.reason : '';
@@ -1976,11 +2027,33 @@ async function cmdMaintain(argv) {
1976
2027
  logEvent(dir, { status: 'pending' });
1977
2028
  return launch(dir, cfg, 'maintain', values);
1978
2029
  }
2030
+ // `run` writes an evidence record, not a verify run: status shows it too, so the run id that `run` printed resolves here.
2031
+ function showRecord(repo, cfg, rec) {
2032
+ const rel = (p) => citable(repo, join(cfg.evidence.root, p));
2033
+ const evidence = FULL ? rec.files.map((f) => ({ path: rel(f.path), sha256: f.sha256, bytes: f.bytes })) : rec.files.map((f) => rel(f.path));
2034
+ const rows = cap(evidence);
2035
+ emit({
2036
+ command: 'status',
2037
+ run_id: rec.run_id,
2038
+ commit: rec.commit,
2039
+ dirty: rec.dirty,
2040
+ files: rec.files.length,
2041
+ record: shown(repo, join(recordsDir(cfg), `${rec.run_id}.json`)),
2042
+ evidence: rows.items,
2043
+ ...(rows.omitted && { evidence_omitted: rows.omitted }),
2044
+ help: [rec.files.length ? `Check this proof: done-done check --outcome "<what you proved>" ${rel(rec.files[0].path)}` : `Read the run's folder: ${shown(repo, runDir(cfg, rec.run_id))}`],
2045
+ });
2046
+ return PASS;
2047
+ }
1979
2048
  function cmdStatus(argv) {
1980
2049
  const { values } = parseArgs({ args: argv, options: { run: { type: 'string' } } });
1981
2050
  const repo = repoRoot();
1982
2051
  const cfg = loadConfig(repo);
1983
2052
  const root = runsRoot(cfg.app);
2053
+ const verify = values.run ? /^[\w-]+$/.test(values.run) && existsSync(join(root, values.run, 'run.json')) : runsIn(root).some((r) => r.repo === repo);
2054
+ const rec = verify ? undefined : loadRecords(cfg).filter((r) => !values.run || r.run_id === values.run).sort((a, b) => a.started_at.localeCompare(b.started_at)).pop();
2055
+ if (rec)
2056
+ return showRecord(repo, cfg, rec);
1984
2057
  return showRun(join(root, pickId(root, repo, 'status', cfg, values.run)), cfg, 'status');
1985
2058
  }
1986
2059
  async function cmdAttach(argv) {
@@ -2360,7 +2433,7 @@ function ensureIgnored(repo, rel) {
2360
2433
  return true;
2361
2434
  }
2362
2435
  // `init` also scaffolds the project's verify skill from the repo's own files: a port of the
2363
- // fleet create-verification-skill interview (surface, run, drive, observe, isolate).
2436
+ // the create-verification-skill interview (surface, run, drive, observe, isolate).
2364
2437
  // Every line is an action: a detected handle gets concrete drive and capture steps, and a blank detection cannot
2365
2438
  // fill gets the steps to find it and record it there, never an invented command. An existing skill is never
2366
2439
  // overwritten, so re-run is idempotent.
@@ -2905,6 +2978,9 @@ function checkJudge(cfg, agent, model) {
2905
2978
  if (cfg.agent)
2906
2979
  selectJudge({ ...cfg, judge: { ...cfg.judge, route: routeOf(agent), model } }, selectAgent(cfg));
2907
2980
  }
2981
+ const commitFirst = (skill) => `.done-done.yaml and ${skill} before your first capture: check refuses proof from a tree with uncommitted changes`;
2982
+ // An absolute path inside the repo in a recipe: the recipe is committed, so it would name this checkout only.
2983
+ const absoluteInside = (command, repo) => [...command.matchAll(/(?:^|[\s"'=])(\/[^\s"'`;|&<>]*)/g)].map((m) => m[1]).find((p) => p === repo || p.startsWith(`${repo}/`));
2908
2984
  async function cmdInit(argv) {
2909
2985
  const dash = argv.indexOf('--');
2910
2986
  const { values } = parseArgs({
@@ -2973,6 +3049,9 @@ async function cmdInit(argv) {
2973
3049
  // Record the capture recipe in the repo so any checkout can re-run the proof.
2974
3050
  // The command is built once, the same way `run --` builds it, so the recipe re-runs exactly; ${RUN_ID} in it becomes the run id.
2975
3051
  const command = commandOf(parts);
3052
+ const inside = absoluteInside(command, repo);
3053
+ if (inside)
3054
+ throw new UsageError(`init --journey ${journey}: ${inside} is an absolute path inside this repo, and the recipe is committed to .done-done.yaml, so other checkouts cannot run it`, `Name it relative to the repo, e.g.: done-done init --journey ${journey} -- ${command.replaceAll(inside, relative(repo, inside) || '.')}`);
2976
3055
  const doc = existed ? parseDocument(readFileSync(file, 'utf8')) : new Document({ app: cfg.app });
2977
3056
  const top = (doc.toJS() ?? {});
2978
3057
  if (typeof top !== 'object' || Array.isArray(top))
@@ -3008,7 +3087,7 @@ async function cmdInit(argv) {
3008
3087
  gitignore: added ? `added ${rel.replace(/\/+$/, '')}/` : 'unchanged',
3009
3088
  verify_skill: verify,
3010
3089
  ...agentHook.out,
3011
- help: [`Capture proof: done-done run ${journey}, then: ${check}`, ...agentHook.help, ...ag.help, ...jg.help],
3090
+ help: [`Capture proof: done-done run ${journey}, then: ${check}`, ...agentHook.help, ...ag.help, ...jg.help, `Commit ${commitFirst(fresh.skill)}`],
3012
3091
  });
3013
3092
  return ag.code;
3014
3093
  }
@@ -3053,6 +3132,7 @@ app: ${JSON.stringify(cfg.app)}
3053
3132
  ...agentHook.help,
3054
3133
  ...ag.help,
3055
3134
  ...jg.help,
3135
+ `Commit ${commitFirst(cfg.skill)}`,
3056
3136
  ],
3057
3137
  });
3058
3138
  return ag.code;
@@ -3094,6 +3174,8 @@ function writableDir(p) {
3094
3174
  }
3095
3175
  }
3096
3176
  const PROBE_SCHEMA = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false };
3177
+ // A tool-less probe starts in seconds; an agent silent for this long is stuck, so doctor says why instead of waiting the agent_timeout.
3178
+ const PROBE_IDLE_MS = 30_000;
3097
3179
  const unknownRow = (check, detail = 'unknown without a valid config', fix = '') => ({ check, status: 'missing', detail, fix });
3098
3180
  // Each effective setting of the agent and the file it came from: the global config, the repo's .done-done.yaml, or the built-in default.
3099
3181
  function agentSettings(cfg, agent) {
@@ -3265,7 +3347,7 @@ async function cmdDoctor(argv) {
3265
3347
  check: 'judge_choice',
3266
3348
  status: apartOk ? 'ok' : 'warning',
3267
3349
  detail: `${choice.route} (model ${choice.model}; from config) judges the author ${choice.author.agent} (model ${choice.author.model || 'none'}): ${apart}${accepted}`,
3268
- fix: apartOk ? '' : `Ask the user whether to judge with another agent (${JUDGES.join(' or ')}) and save it: done-done init --judge <agent> --judge-model <model>`,
3350
+ fix: apartOk ? '' : `Ask the user whether to judge with another agent or model (${JUDGES.join(' or ')}) and save it: done-done init --judge <agent> --judge-model <model>`,
3269
3351
  });
3270
3352
  }
3271
3353
  if (values.probe && !agent)
@@ -3276,10 +3358,10 @@ async function cmdDoctor(argv) {
3276
3358
  else if (values.probe && agent) {
3277
3359
  const record = join(home(), 'agents', `probe-${new Date().toISOString().replace(/[:.]/g, '-')}-${agent.agent}.json`);
3278
3360
  const probe = !agent.path ? { ok: false, error: notFound, fix: installFix }
3279
- : await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record });
3361
+ : await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record, idleMs: PROBE_IDLE_MS });
3280
3362
  checks.push(probe.ok && probe.answer?.ok === true
3281
3363
  ? { check: 'probe', status: 'ok', detail: `${agent.agent} (${modelPhrase(agent.agent, agent.model)}) answered the schema question; ${probe.recordError ? `the call record could not be written: ${probe.recordError}` : `record ${record}`}`, fix: '' }
3282
- : { check: 'probe', status: 'missing', detail: `${agent.agent} did not answer: ${probe.error ?? `answer ${JSON.stringify(probe.answer)}`}${probe.record ? `; record ${record}` : ''}${probe.recordError ? `; the call record could not be written: ${probe.recordError}` : ''}`, fix: probe.fix ?? `Check that ${agent.agent} is signed in${agent.model ? ` and can use model ${agent.model}` : ''}, or set agent_config.${agent.agent}.model` });
3364
+ : { check: 'probe', status: 'missing', detail: `${agent.agent} did not answer: ${probe.error ?? `answer ${JSON.stringify(probe.answer)}`}${probe.record ? `; record ${record}` : ''}${probe.recordError ? `; the call record could not be written: ${probe.recordError}` : ''}`, fix: probe.fix ?? `${probe.stderr ? `${agent.agent} stderr: ${probe.stderr}. ` : ''}Check that ${agent.agent} is signed in${agent.model ? ` and can use model ${agent.model}` : ''}, or set agent_config.${agent.agent}.model` });
3283
3365
  }
3284
3366
  if (agent)
3285
3367
  checks.push({ check: 'agent_settings', status: 'ok', detail: agentSettings(agentCfg, agent.agent), fix: '' });
@@ -3304,6 +3386,32 @@ async function cmdDoctor(argv) {
3304
3386
  return missingRequired.length ? USAGE : PASS;
3305
3387
  }
3306
3388
  // The design check: every named screen, and each --mock screen, judged for visual defects.
3389
+ async function cmdLook(argv) {
3390
+ const { values, positionals } = parseArgs({ args: argv, options: { out: { type: 'string' } }, allowPositionals: true });
3391
+ if (positionals.length !== 1)
3392
+ throw new UsageError('look needs one page URL', 'Re-run with the page to check: done-done look <url>');
3393
+ // A path to a page on disk opens as its file:// URL.
3394
+ const url = !/^[a-z][\w+.-]*:/i.test(positionals[0]) && existsSync(positionals[0]) ? pathToFileURL(resolve(positionals[0])).href : positionals[0];
3395
+ const capture = resolve(values.out ?? join(process.env.DONE_DONE_EVIDENCE_ROOT || '.', 'look.png'));
3396
+ if (!capture.endsWith('.png'))
3397
+ throw new UsageError(`--out ${values.out} must be a .png file`);
3398
+ mkdirSync(dirname(capture), { recursive: true });
3399
+ const record = await look(url, capture, 390, 844);
3400
+ const hits = lookIssues(record);
3401
+ const shown = cap(hits);
3402
+ emit({
3403
+ command: 'look',
3404
+ result: hits.length ? 'fail' : 'pass',
3405
+ url,
3406
+ viewport: '390x844',
3407
+ capture,
3408
+ record: domRecord(capture),
3409
+ hits: shown.items,
3410
+ ...(shown.omitted && { hits_omitted: shown.omitted }),
3411
+ help: hits.length ? [`Fix each element named above so it fits the 390 px wide screen, then re-run: done-done look ${quote(positionals[0])}${values.out ? ` --out ${quote(values.out)}` : ''}`] : ['No squashed, clipped or cut-off UI on this page; look at the capture to check the rest'],
3412
+ });
3413
+ return hits.length ? FAIL : PASS;
3414
+ }
3307
3415
  async function cmdDesign(argv) {
3308
3416
  const { values, positionals } = parseArgs({ args: argv, options: { mock: { type: 'string', multiple: true } }, allowPositionals: true });
3309
3417
  const mocks = new Map();
@@ -3425,6 +3533,8 @@ async function main(argv) {
3425
3533
  return await cmdMaintain(rest);
3426
3534
  if (cmd === 'design')
3427
3535
  return await cmdDesign(rest);
3536
+ if (cmd === 'look')
3537
+ return await cmdLook(rest);
3428
3538
  if (cmd === 'status')
3429
3539
  return cmdStatus(rest);
3430
3540
  if (cmd === 'attach')
package/dist/config.js CHANGED
@@ -70,11 +70,10 @@ const KEYS = {
70
70
  'judge.enabled': { scope: 'repo', type: 'boolean' },
71
71
  'judge.model': { scope: 'global', type: 'string' },
72
72
  'judge.route': { scope: 'global', type: 'route' },
73
- 'judge.claude_code': { scope: 'global', type: 'claude_code' },
74
73
  'judge.allow_same': { scope: 'global', type: 'boolean' },
75
74
  };
76
75
  const SECTIONS = new Set(['evidence', 'secrets', 'checks', 'judge']);
77
- const MAPS = new Set(['auto_fix', 'agent_config', 'agent_args_override', 'agent_path_override', 'judge.claude_code']);
76
+ const MAPS = new Set(['auto_fix', 'agent_config', 'agent_args_override', 'agent_path_override']);
78
77
  export const home = () => process.env.DONE_DONE_HOME || join(homedir(), '.done-done');
79
78
  // A duration such as 90s, 30m or 1h, in milliseconds; null when the text is not one or is not a delay a timer can run.
80
79
  export const parseDuration = (text) => {
@@ -118,6 +117,7 @@ const isRegex = (v) => {
118
117
  return false;
119
118
  }
120
119
  };
120
+ const leftoverNoticed = new Set();
121
121
  function apply(target, text, file, scope) {
122
122
  if (text === undefined)
123
123
  return;
@@ -129,6 +129,14 @@ function apply(target, text, file, scope) {
129
129
  throw new UsageError(`${file}: ${e.message.split('\n')[0]}`);
130
130
  }
131
131
  for (const [key, value] of flatten(doc, file)) {
132
+ // The claude judge runs the user's own claude sign-in now, so the folder key of older configs is only a leftover.
133
+ if (key === 'judge.claude_code') {
134
+ if (!leftoverNoticed.has(file)) {
135
+ leftoverNoticed.add(file);
136
+ process.stderr.write(`judge.claude_code is no longer used (the judge uses your normal claude sign-in); delete it from ${file}\n`);
137
+ }
138
+ continue;
139
+ }
132
140
  const spec = KEYS[key];
133
141
  if (!spec)
134
142
  throw new UsageError(`${file}: unknown key ${key}`);
@@ -140,16 +148,15 @@ function apply(target, text, file, scope) {
140
148
  : spec.type === 'inputs' ? Array.isArray(value) && value.every((v) => typeof v === 'string' && inputPattern(v) !== '' && !isAbsolute(v) && !v.split('/').includes('..'))
141
149
  : spec.type === 'agent' ? isAgent(value)
142
150
  : spec.type === 'route' ? typeof value === 'string' && (value === 'claude-code' || Object.hasOwn(KEY_ROUTES, value) || ANSWERER_JUDGES.some((a) => value === `answerer:${a}`))
143
- : spec.type === 'claude_code' ? isMap(value) && Object.entries(value).every(([k, v]) => k === 'config_dir' && typeof v === 'string')
144
- : spec.type === 'agent_config' ? isMap(value) && Object.entries(value).every(([a, c]) => isAgent(a) && isMap(c) && Object.entries(c).every(([k, m]) => ['model', 'effort'].includes(k) && typeof m === 'string'))
145
- : spec.type === 'args' ? isMap(value) && Object.entries(value).every(([a, v]) => AGENTS.includes(a) && Array.isArray(v) && v.every((x) => typeof x === 'string'))
146
- : spec.type === 'paths' ? isMap(value) && Object.entries(value).every(([a, v]) => AGENTS.includes(a) && isBin(v))
147
- : spec.type === 'bin' ? isBin(value)
148
- : spec.type === 'duration' ? typeof value === 'string' && parseDuration(value) !== null
149
- : spec.type === 'auto_fix' ? isMap(value) && Object.entries(value).every(([k, n]) => AUTO_FIX_STEPS.includes(k) && Number.isInteger(n) && n >= 0)
150
- : spec.type === 'matrix' ? !checkMatrix(value)
151
- : spec.type === 'regexes' ? (key === 'secrets.allow' ? Array.isArray(value) : isMap(value)) && Object.values(value).every(isRegex)
152
- : isMap(value) && Object.values(value).every((j) => j && typeof j.command === 'string' && (j.spends === undefined || (typeof j.spends === 'string' && j.spends.trim() !== '')));
151
+ : spec.type === 'agent_config' ? isMap(value) && Object.entries(value).every(([a, c]) => isAgent(a) && isMap(c) && Object.entries(c).every(([k, m]) => ['model', 'effort'].includes(k) && typeof m === 'string'))
152
+ : spec.type === 'args' ? isMap(value) && Object.entries(value).every(([a, v]) => AGENTS.includes(a) && Array.isArray(v) && v.every((x) => typeof x === 'string'))
153
+ : spec.type === 'paths' ? isMap(value) && Object.entries(value).every(([a, v]) => AGENTS.includes(a) && isBin(v))
154
+ : spec.type === 'bin' ? isBin(value)
155
+ : spec.type === 'duration' ? typeof value === 'string' && parseDuration(value) !== null
156
+ : spec.type === 'auto_fix' ? isMap(value) && Object.entries(value).every(([k, n]) => AUTO_FIX_STEPS.includes(k) && Number.isInteger(n) && n >= 0)
157
+ : spec.type === 'matrix' ? !checkMatrix(value)
158
+ : spec.type === 'regexes' ? (key === 'secrets.allow' ? Array.isArray(value) : isMap(value)) && Object.values(value).every(isRegex)
159
+ : isMap(value) && Object.values(value).every((j) => j && typeof j.command === 'string' && (j.spends === undefined || (typeof j.spends === 'string' && j.spends.trim() !== '')));
153
160
  const want = {
154
161
  journeys: 'a map of name: {command: <string>, spends: <what a run spends>}',
155
162
  instance: '{command: <string>, ready: {url: <http url>} or {command: <shell command>}, ready_timeout: <duration>}',
@@ -163,7 +170,6 @@ function apply(target, text, file, scope) {
163
170
  regexes: key === 'secrets.allow' ? 'a list of valid regexes' : 'a map of <name>: <valid regex>',
164
171
  auto_fix: `a map of <step>: <attempts, 0 or more> for ${AUTO_FIX_STEPS.join(', ')}`,
165
172
  route: `claude-code (for claude), answerer:<agent> for one of ${ANSWERER_JUDGES.join(', ')}, or a key route (${Object.keys(KEY_ROUTES).join(', ')}); an acp:<target> agent cannot judge, since done-done cannot send it an image`,
166
- claude_code: 'a map with config_dir, a string (the claude binary is agent_path_override.claude)',
167
173
  inputs: 'a list of repo-relative paths or globs, not absolute and with no .. segment',
168
174
  };
169
175
  if (key === 'judge.route' && (value === 'auto' || value === ''))
@@ -262,7 +268,7 @@ function defaultConfig(repo) {
262
268
  evidence: { local_root: join(home(), 'evidence'), root: '', allow_dirty: false, ignore_paths: [] },
263
269
  secrets: { patterns: {}, allow: [] },
264
270
  checks: { not_proven: 'inconclusive', fix_title: '^(fix|bugfix|hotfix)\\b' },
265
- judge: { route: '', model: '', allow_same: false, claude_code: { config_dir: join(home(), 'claude') } },
271
+ judge: { route: '', model: '', allow_same: false },
266
272
  trusted: false,
267
273
  from: {},
268
274
  };
@@ -280,7 +286,6 @@ export function loadConfig(repo, projectText) {
280
286
  cfg.acpx_path = tilde(cfg.acpx_path);
281
287
  for (const a of Object.keys(cfg.agent_path_override))
282
288
  cfg.agent_path_override[a] = tilde(cfg.agent_path_override[a]);
283
- cfg.judge.claude_code.config_dir = tilde(cfg.judge.claude_code.config_dir);
284
289
  // A relative root is under the repo (and dies with the checkout); an absolute one is used as-is.
285
290
  cfg.evidence.root = cfg.evidence.root ? resolve(repo, cfg.evidence.root) : join(local, cfg.app);
286
291
  return cfg;
@@ -345,8 +350,5 @@ judge:
345
350
  model: ""
346
351
  # Accept a judge that is the author's own agent and model in a fresh session.
347
352
  allow_same: false
348
- claude_code:
349
- # The claude-code judge's own Claude sign-in folder, never your ~/.claude.
350
- config_dir: ${short(join(home(), 'claude'))}
351
353
  `;
352
354
  }
package/dist/dom.js CHANGED
@@ -3,8 +3,10 @@
3
3
  // (home.dark.390w.png -> home.dark.390w.dom.json), so a matrix cell's record carries its DONE_DONE_CELL suffix:
4
4
  // {"url": "<page url>", "overflow": <true when content is wider than the viewport>, "overflowing": ["<element>", ...],
5
5
  // "console_errors": ["<message>", ...], "failed_requests": [{"url": "<request url>", "status": <HTTP status, 0 when none>}],
6
- // "squashed_text": [{"element": "<element>", "text": "<its text, ~40 chars>", "chars": <width>, "lines": <n>, "words": <n>}]}
7
- // squashed_text is optional, so a record written before it stays valid.
6
+ // "squashed_text": [{"element": "<element>", "text": "<its text, ~40 chars>", "chars": <width>, "lines": <n>, "words": <n>}],
7
+ // "clipped_text": [{"element": "<element>", "text": "<its text>", "by": "<the container that cuts it>"}],
8
+ // "cut_off": [{"element": "<element>", "text": "<its text>", "edges": ["left" | "right" | "top", ...]}]}
9
+ // squashed_text, clipped_text and cut_off are optional, so a record written before them stays valid.
8
10
  import { readFileSync } from 'node:fs';
9
11
  import { basename } from 'node:path';
10
12
  // The measurement the recipe passes to its browser tool's eval, so no agent invents one: every visible element with its
@@ -27,8 +29,48 @@ export const SQUASHED_TEXT = `() => {
27
29
  }
28
30
  return out;
29
31
  }`.replace(/\s*\n\s*/g, ' ');
32
+ // The rest of the look measurement, run in the page by `done-done look` next to SQUASHED_TEXT. A part of an element only
33
+ // shows inside every ancestor that clips it (overflow other than visible; html and body clip as the viewport, so they are
34
+ // the edges, not containers). Text is clipped when part of it shows and part runs past a hidden or clip container on an
35
+ // axis without text-overflow: ellipsis or a line clamp; an element is cut off when part of what shows lies past the
36
+ // left, right or top edge of the viewport (the page is at its top, so nothing above it can be scrolled to). Something
37
+ // entirely outside its container or the viewport is hidden on purpose (a closed drawer, a visually hidden label), not cut;
38
+ // a cut element's children are not listed again.
39
+ // ponytail: the main frame only, and a transform or clip-path that hides part of a box is not seen; measure each frame
40
+ // and clip-path when those show up.
41
+ export const LOOK = `() => {
42
+ const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const out = { clipped_text: [], cut_off: [] }; const cut = new Set();
43
+ const name = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.classList[0] ? '.' + e.classList[0] : '');
44
+ const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3 && n.data.trim()).map((n) => n.data).join(' ').replace(/\\s+/g, ' ').trim().slice(0, 40);
45
+ const clips = (e) => { const out = []; for (let a = e; a && a !== document.body && a !== document.documentElement; a = a.parentElement) { const s = getComputedStyle(a); if (s.overflowX !== 'visible' || s.overflowY !== 'visible') { const r = a.getBoundingClientRect(); out.push({ a, s, l: r.left + a.clientLeft, t: r.top + a.clientTop, r: r.left + a.clientLeft + a.clientWidth, b: r.top + a.clientTop + a.clientHeight }); } } return out; };
46
+ const shows = (b) => b.r - b.l > 1 && b.b - b.t > 1;
47
+ for (const e of document.querySelectorAll('body *')) {
48
+ const s = getComputedStyle(e); const rect = e.getBoundingClientRect();
49
+ if (!e.getClientRects().length || s.visibility !== 'visible' || rect.width < 1 || rect.height < 1) continue;
50
+ const boxes = clips(e.parentElement);
51
+ const box = boxes.reduce((v, c) => ({ l: Math.max(v.l, c.l), t: Math.max(v.t, c.t), r: Math.min(v.r, c.r), b: Math.min(v.b, c.b) }), { l: rect.left, t: rect.top, r: rect.right, b: rect.bottom });
52
+ if (!shows(box)) continue;
53
+ const text = own(e);
54
+ const parent = [...cut].some((c) => c.contains(e));
55
+ const edges = [box.l < -1 && 'left', box.r > vw + 1 && 'right', box.t < -1 && 'top'].filter(Boolean);
56
+ if (edges.length && box.r > 0 && box.l < vw && box.b > 0 && box.t < vh && !parent) { cut.add(e); out.cut_off.push({ element: name(e), text, edges }); }
57
+ if (!text) continue;
58
+ const t = { l: Infinity, t: Infinity, r: -Infinity, b: -Infinity };
59
+ for (const n of e.childNodes) if (n.nodeType === 3 && n.data.trim()) { const g = document.createRange(); g.selectNodeContents(n); for (const q of g.getClientRects()) if (q.width) { t.l = Math.min(t.l, q.left); t.t = Math.min(t.t, q.top); t.r = Math.max(t.r, q.right); t.b = Math.max(t.b, q.bottom); } }
60
+ if (t.l === Infinity || t.t >= vh || t.b <= 0) continue;
61
+ for (const c of clips(e)) {
62
+ if (c.r - c.l <= 1 || c.b - c.t <= 1 || !(t.r > c.l && t.l < c.r && t.b > c.t && t.t < c.b) || !['hidden', 'clip'].some((v) => v === c.s.overflowX || v === c.s.overflowY)) continue;
63
+ const x = ['hidden', 'clip'].includes(c.s.overflowX) && c.s.textOverflow !== 'ellipsis' && (t.l < c.l - 1 || t.r > c.r + 1);
64
+ const y = ['hidden', 'clip'].includes(c.s.overflowY) && c.s.webkitLineClamp === 'none' && (t.t < c.t - 1 || t.b > c.b + 1);
65
+ if (x || y) { out.clipped_text.push({ element: name(e), text, by: c.a === e ? 'its own box' : name(c.a) }); break; }
66
+ }
67
+ }
68
+ return { overflow: document.documentElement.scrollWidth > vw, overflowing: [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 10).map(name), ...out };
69
+ }`.replace(/\s*\n\s*/g, ' ');
30
70
  const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
31
71
  const strings = (a) => Array.isArray(a) && a.every((s) => typeof s === 'string');
72
+ // An optional record list: absent, or every item has an element and text and passes `more`.
73
+ const list = (a, more) => a === undefined || (Array.isArray(a) && a.every((q) => isMap(q) && typeof q.element === 'string' && typeof q.text === 'string' && more(q)));
32
74
  const badRegex = (s) => { try {
33
75
  new RegExp(s);
34
76
  return false;
@@ -71,13 +113,23 @@ export function domIssues(dom, capture) {
71
113
  }
72
114
  if (!isMap(r) || typeof r.url !== 'string' || typeof r.overflow !== 'boolean' || !strings(r.overflowing ?? []) || !strings(r.console_errors)
73
115
  || !Array.isArray(r.failed_requests) || !r.failed_requests.every((q) => isMap(q) && typeof q.url === 'string' && typeof q.status === 'number')
74
- || (r.squashed_text !== undefined && !(Array.isArray(r.squashed_text) && r.squashed_text.every((q) => isMap(q) && typeof q.element === 'string' && typeof q.text === 'string' && [q.chars, q.lines, q.words].every((n) => typeof n === 'number')))))
75
- return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}]}`];
116
+ || !list(r.squashed_text, (q) => [q.chars, q.lines, q.words].every((n) => typeof n === 'number'))
117
+ || !list(r.clipped_text, (q) => typeof q.by === 'string') || !list(r.cut_off, (q) => strings(q.edges)))
118
+ return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}], clipped_text: [{element, text, by}], cut_off: [{element, text, edges}]}`];
76
119
  const allowed = (list, s) => list.some((re) => new RegExp(re).test(s));
77
120
  return [
78
- ...(dom.fail_on_overflow && r.overflow ? [`${r.url} overflows the viewport horizontally${r.overflowing?.length ? ` (${r.overflowing.join(', ')})` : ''}`] : []),
121
+ ...lookIssues({ ...r, overflow: dom.fail_on_overflow && r.overflow }),
79
122
  ...r.console_errors.filter((m) => !allowed(dom.allow_console, m)).map((m) => `${r.url} console error: ${m}`),
80
123
  ...r.failed_requests.filter((q) => (q.status === 0 || q.status >= 400) && !allowed(dom.allow_requests, q.url)).map((q) => `${r.url} request failed (${q.status || 'no response'}): ${q.url}`),
124
+ ];
125
+ }
126
+ // The look defects a page-check record shows: horizontal overflow, squashed, clipped and cut-off text or elements.
127
+ export function lookIssues(r) {
128
+ const quoted = (t) => (t ? ` "${t}"` : '');
129
+ return [
130
+ ...(r.overflow ? [`${r.url} overflows the viewport horizontally${r.overflowing?.length ? ` (${r.overflowing.join(', ')})` : ''}`] : []),
81
131
  ...(r.squashed_text ?? []).map((q) => `${r.url} squashed text: ${q.element} "${q.text}" is ${q.chars} characters wide (lines ${q.lines}, words ${q.words})`),
132
+ ...(r.clipped_text ?? []).map((q) => `${r.url} clipped text: ${q.element}${quoted(q.text)} is cut by ${q.by}`),
133
+ ...(r.cut_off ?? []).map((q) => `${r.url} cut off: ${q.element}${quoted(q.text)} runs past the ${q.edges.join(' and ')} edge of the viewport`),
82
134
  ];
83
135
  }