acuvo-code 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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/delete.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ DELETE — THE MISSING PRIMITIVE THAT DERAILED WHOLE SESSIONS ───────────
|
|
3
|
+
*
|
|
4
|
+
* FOUND BY RUNNING THE CLI, 2026-08-09, and it is the clearest example in this
|
|
5
|
+
* package of why running it beats reasoning about it.
|
|
6
|
+
*
|
|
7
|
+
* The task was "fix slugify, then commit it". The model fixed it, wrote a
|
|
8
|
+
* `check.mjs` to verify, ran it, and it passed. Then, with three rounds left,
|
|
9
|
+
* it tried to tidy up — and could not, because `write_file` was the only verb
|
|
10
|
+
* that touched the filesystem. Its own words in round 8:
|
|
11
|
+
*
|
|
12
|
+
* "I need to remove the scratch file. Since I can't use rm, let me check
|
|
13
|
+
* if there's another way. Actually, the empty file is still there."
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ SO IT OVERWROTE THE FILE WITH ZERO BYTES — the only deletion available to
|
|
16
|
+
* it — and then spent every remaining round circling the problem. **The commit
|
|
17
|
+
* never happened.** A missing primitive does not merely block its own task; it
|
|
18
|
+
* captures the loop, and everything downstream of it silently does not occur.
|
|
19
|
+
* The observable symptom was "the agent won't commit", and the cause was three
|
|
20
|
+
* tools away.
|
|
21
|
+
*
|
|
22
|
+
* ── ⚠️ WHY THIS IS THE MOST DANGEROUS TOOL IN THE PACKAGE, AND WHAT BOUNDS IT ─
|
|
23
|
+
* `write_file` can destroy a file's contents, but the file is still there and
|
|
24
|
+
* `git diff` still shows what it was. Delete removes the evidence too. So:
|
|
25
|
+
*
|
|
26
|
+
* 1. **One path per call.** No globs, no recursion, no arrays. Deleting a
|
|
27
|
+
* tree is the operation nobody can review, and an agent that can only
|
|
28
|
+
* remove one named file at a time cannot do it by accident.
|
|
29
|
+
* 2. **Files only.** A directory is refused outright — see above.
|
|
30
|
+
* 3. **The workspace rules, unchanged.** `resolveInWorkspace(…, 'write')`
|
|
31
|
+
* already forbids `.git`, `node_modules`, `.next` and `.vercel`, so the
|
|
32
|
+
* repository's own history is not reachable from here. That is inherited,
|
|
33
|
+
* not re-implemented — one place decides what "writable" means.
|
|
34
|
+
* 4. **Size-capped, and the size is REPORTED.** Removing a 4KB scratch file
|
|
35
|
+
* and removing a 400KB source file should not read identically in a
|
|
36
|
+
* summary, so the byte count comes back and the summary prints it.
|
|
37
|
+
*
|
|
38
|
+
* ⭐ AND THE HONEST LIMIT: this does not stop a model deleting a real source
|
|
39
|
+
* file it wrongly believes is scratch. Nothing here can, short of refusing to
|
|
40
|
+
* delete anything the session did not create — which would forbid "remove the
|
|
41
|
+
* deprecated module", a legitimate and common request. The mitigation is that
|
|
42
|
+
* it is one file, named, printed, and (in a repo) recoverable with git. That is
|
|
43
|
+
* the trade, stated rather than papered over.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { unlinkSync, statSync } from 'node:fs';
|
|
47
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {{ ok: false, error: string }} DeleteRefused
|
|
51
|
+
* @param {string} root
|
|
52
|
+
* @param {unknown} rawPath
|
|
53
|
+
* @param {{ dryRun?: boolean }} [opts]
|
|
54
|
+
* @returns {DeleteRefused | { ok: true, path: string, bytes: number, dryRun: boolean }}
|
|
55
|
+
*/
|
|
56
|
+
export function deleteFile(root, rawPath, { dryRun = false } = {}) {
|
|
57
|
+
const target = resolveInWorkspace(root, rawPath, 'write');
|
|
58
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
59
|
+
|
|
60
|
+
let stat;
|
|
61
|
+
try {
|
|
62
|
+
stat = statSync(target.absolute);
|
|
63
|
+
} catch {
|
|
64
|
+
/**
|
|
65
|
+
* ⚠️ "ALREADY GONE" IS REPORTED AS A REFUSAL, NOT QUIETLY AS SUCCESS, and
|
|
66
|
+
* the wording matters. A model told "deleted" for a file that never existed
|
|
67
|
+
* will believe a path it invented was real, and carry that belief into the
|
|
68
|
+
* next round. Saying it plainly is what stops the invention compounding.
|
|
69
|
+
*/
|
|
70
|
+
return { ok: false, error: `no such file: ${target.relative} — nothing was deleted` };
|
|
71
|
+
}
|
|
72
|
+
if (stat.isDirectory()) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `${target.relative} is a directory. This agent deletes one FILE at a time and never a directory — removing a tree is the operation nobody can review.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const bytes = stat.size;
|
|
80
|
+
if (!dryRun) {
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(target.absolute);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { ok: false, error: `could not delete ${target.relative}: ${err instanceof Error ? err.message : String(err)}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, path: target.relative, bytes, dryRun };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function deleteToolSchema() {
|
|
91
|
+
return {
|
|
92
|
+
type: 'function',
|
|
93
|
+
function: {
|
|
94
|
+
name: 'delete_file',
|
|
95
|
+
description: [
|
|
96
|
+
'Delete ONE file from the workspace.',
|
|
97
|
+
'Use it to remove a scratch or temporary file you created while verifying your work —',
|
|
98
|
+
'do NOT leave an empty file behind by writing "" to it, delete it properly.',
|
|
99
|
+
'One file per call: no globs, no directories, no recursion.',
|
|
100
|
+
'Be certain before deleting anything you did not create in this session.',
|
|
101
|
+
].join(' '),
|
|
102
|
+
parameters: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
path: { type: 'string', description: 'Workspace-relative path of the file to delete.' },
|
|
106
|
+
},
|
|
107
|
+
required: ['path'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE DESIGN LOOP — THE AGENT LOOKS AT WHAT IT BUILT ───────────────────
|
|
3
|
+
*
|
|
4
|
+
* Two finished capabilities already sit in this package and have never been
|
|
5
|
+
* joined: `seePage` (lib/media.mjs) renders HTML in a real browser and returns
|
|
6
|
+
* MEASURED layout facts, and `renderImage` (lib/terminal-graphics.mjs) draws a
|
|
7
|
+
* PNG inline on kitty and iTerm2. Between them is the loop: build the page,
|
|
8
|
+
* look at it, hand the model a verdict it can act on, and hand the HUMAN the
|
|
9
|
+
* actual picture.
|
|
10
|
+
*
|
|
11
|
+
* ── ⚠️ THE CLAIM. THE OLD ONE WAS FALSE AND WAS STRUCK ON 2026-08-10 ─────────
|
|
12
|
+
*
|
|
13
|
+
* "No other terminal agent can see its own output" is WRONG. Playwright MCP and
|
|
14
|
+
* Chrome DevTools MCP are free, first-party and one `claude mcp add` away; some
|
|
15
|
+
* agents ship a browser. A pitch built on that claim dies at the first customer
|
|
16
|
+
* who has heard of Playwright, and it makes everything else we say suspect.
|
|
17
|
+
*
|
|
18
|
+
* ⭐ THE DEFENSIBLE CLAIM IS THE RETURN VALUE, NOT THE BROWSER. The competing
|
|
19
|
+
* shape is: take a screenshot, hand the PNG back to the model, ask it to
|
|
20
|
+
* interpret its own work. That costs the image's full token price EVERY LOOK —
|
|
21
|
+
* for the 1280×900 viewport this service uses, `imageTokenCost(1280, 900)` =
|
|
22
|
+
* **1,536 tokens** (a taller full-page shot runs to ~3,072) — and it asks the
|
|
23
|
+
* model to do the one thing models are measurably worst at: read fine layout
|
|
24
|
+
* detail out of its own render.
|
|
25
|
+
*
|
|
26
|
+
* This module measures in CODE and returns an ordered, specific verdict that
|
|
27
|
+
* runs about **89 tokens**. Same information, one to two orders of magnitude
|
|
28
|
+
* cheaper, and no vision hop in the middle. `cost` on every pass reports both
|
|
29
|
+
* numbers COMPUTED FROM THE ACTUAL VIEWPORT, so the ratio is never a quoted
|
|
30
|
+
* marketing figure — if a page renders at a size that narrows the gap, the pass
|
|
31
|
+
* says so itself.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ AND IT BUYS A HEAD START, NEVER A MOAT. A competent developer rebuilds this
|
|
34
|
+
* in a weekend. Price and pitch accordingly.
|
|
35
|
+
*
|
|
36
|
+
* ── ⚠️⚠️ THE DEFECT THIS MODULE EXISTS TO CLOSE: THE NAKED PAGE ──────────────
|
|
37
|
+
*
|
|
38
|
+
* On 2026-08-10 `see_page` was fixed after reading `json.findings` from a
|
|
39
|
+
* service that replies `{ ok, measurement: {…} }` — every call had been
|
|
40
|
+
* returning "I looked and it was fine" for pages it never saw. Once fixed, it
|
|
41
|
+
* PASSED FOUR NAKED PAGES: the render service takes HTML **text**, so
|
|
42
|
+
* `<link rel="stylesheet" href="styles.css">` resolved to nothing and every
|
|
43
|
+
* multi-file page arrived unstyled. `inlineLocalAssets` now bundles the page's
|
|
44
|
+
* own siblings, which fixes the common case.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ BUT MEASUREMENT CAN NEVER DETECT THE REMAINING CASES, AND IT IS WORTH
|
|
47
|
+
* BEING PRECISE ABOUT WHY. Measured live 2026-08-11, the same page twice:
|
|
48
|
+
*
|
|
49
|
+
* naked → paintedRatio 0.0986 · lowContrastText [] · consoleErrors []
|
|
50
|
+
* styled → paintedRatio 0.1514 · lowContrastText [] · consoleErrors []
|
|
51
|
+
*
|
|
52
|
+
* Both produce `findingsFrom() === []`. Black Times on white has *excellent*
|
|
53
|
+
* contrast; 9.8% of the viewport really is painted. The unstyled page is not a
|
|
54
|
+
* page that fails the checks — it is a page that passes them, which is why it
|
|
55
|
+
* survived a full audit and shipped.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️⚠️ AND IT IS WORSE THAN "THE CHECKS PASS" — THE UNSTYLED RENDER MASKS REAL
|
|
58
|
+
* DEFECTS. Measured live 2026-08-11 on one page containing a deliberate
|
|
59
|
+
* `.ghost { color: #0c0e11 }` footer note on a `#0b0d10` background:
|
|
60
|
+
*
|
|
61
|
+
* styled render → `unreadable text (contrast 1.01:1, needs 4.5)` ← caught
|
|
62
|
+
* naked render → no findings at all ← MISSED
|
|
63
|
+
*
|
|
64
|
+
* The invisible text is present in both files. In the naked render the rule
|
|
65
|
+
* never applies, so the text comes back as black on white and reads as
|
|
66
|
+
* perfectly legible. So a missing stylesheet does not merely fail to find new
|
|
67
|
+
* problems; it SUPPRESSES the true positives the audit exists to produce. That
|
|
68
|
+
* is why an untrustworthy render is reported as a finding in its own right and
|
|
69
|
+
* never allowed to reach the model as an all-clear.
|
|
70
|
+
*
|
|
71
|
+
* ⭐ SO THE TRUTH IS NOT IN THE PIXELS, IT IS IN THE REQUEST. Did the HTML we
|
|
72
|
+
* handed the browser still point at a stylesheet the browser could not fetch?
|
|
73
|
+
* That is a fact about bytes we sent. `stylingTrust` reads it off the payload,
|
|
74
|
+
* and a pass that fails it is marked UNRELIABLE — because a clean measurement
|
|
75
|
+
* of an undesigned page is worse than no measurement at all.
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ IT CATCHES ONE CASE `inlineLocalAssets` CORRECTLY DOES NOT REPORT. A
|
|
78
|
+
* root-absolute `/styles.css` is server-rooted, so it is not the workspace's to
|
|
79
|
+
* resolve — media.mjs leaves it alone and says nothing. The render service has
|
|
80
|
+
* no server behind it, so the page still arrives naked with no missing-asset
|
|
81
|
+
* finding to explain it. Only the request tells you.
|
|
82
|
+
*
|
|
83
|
+
* ── ⚠️ ABSENT IS NOT CLEAN ──────────────────────────────────────────────────
|
|
84
|
+
*
|
|
85
|
+
* `findingsFrom` reads `(m.lowContrastText ?? [])`. A service that stops
|
|
86
|
+
* measuring contrast therefore reads as a page with *perfect* contrast — the
|
|
87
|
+
* identical failure mode as the envelope bug, one field down, and it would be
|
|
88
|
+
* introduced by a change nobody in this repo made. `checkCoverage` separates
|
|
89
|
+
* "measured and clean" from "not measured", and the verdict prints the second
|
|
90
|
+
* as NOT CHECKED.
|
|
91
|
+
*
|
|
92
|
+
* ⭐ "I could not determine the contrast" is a useful sentence. A confident
|
|
93
|
+
* wrong one is not. Every function here abstains rather than guesses.
|
|
94
|
+
*
|
|
95
|
+
* ── ⚠️ WHAT THIS FILE DOES NOT DO ───────────────────────────────────────────
|
|
96
|
+
* It does not modify `media.mjs` and it does not re-implement the render call.
|
|
97
|
+
* It wraps `seePage`, so the inlining, the path safety, the circuit breaker and
|
|
98
|
+
* the envelope check all stay in exactly one place. It OBSERVES the request and
|
|
99
|
+
* the reply by wrapping `fetchImpl` — which is why the styling fact and the
|
|
100
|
+
* coverage fact are available here without a second network round trip.
|
|
101
|
+
*
|
|
102
|
+
* Zero dependencies. Node built-ins only.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
import { resolve } from 'node:path';
|
|
106
|
+
import { seePage } from './media.mjs';
|
|
107
|
+
import { renderImage } from './terminal-graphics.mjs';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The checks the render service performs, and the plain-English name each one
|
|
111
|
+
* gets in a verdict.
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ THE LIST IS DATA, NOT PROSE IN A PROMPT. An all-clear has to be able to
|
|
114
|
+
* say what it is an all-clear FOR, and that sentence must go stale the moment
|
|
115
|
+
* the service stops returning a field — which it cannot do if the names live in
|
|
116
|
+
* a system prompt somewhere.
|
|
117
|
+
*/
|
|
118
|
+
export const CHECKS = Object.freeze([
|
|
119
|
+
{ key: 'consoleErrors', label: 'console errors' },
|
|
120
|
+
{ key: 'paintedRatio', label: 'painted area' },
|
|
121
|
+
{ key: 'lowContrastText', label: 'text contrast' },
|
|
122
|
+
{ key: 'clippedText', label: 'clipped text' },
|
|
123
|
+
{ key: 'overlaps', label: 'overlapping elements' },
|
|
124
|
+
{ key: 'brokenImages', label: 'broken images' },
|
|
125
|
+
{ key: 'scrollWidth', label: 'horizontal overflow' },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Contrast source values that mean "we did not actually measure it". */
|
|
129
|
+
const CONTRAST_NOT_MEASURED = new Set(['none', 'unavailable', 'skipped', 'error', 'failed']);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Which checks the measurement can actually answer.
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ PRESENCE IS NOT ENOUGH FOR TWO OF THEM. `lowContrastText: []` alongside
|
|
135
|
+
* `contrastSource: "none"` means the service tried and could not — reporting
|
|
136
|
+
* that as "contrast is fine" is precisely the confident-wrong sentence this
|
|
137
|
+
* module is built to refuse. And `scrollWidth` cannot answer overflow without a
|
|
138
|
+
* viewport width to compare it against.
|
|
139
|
+
*
|
|
140
|
+
* Pure: data in, data out. No clock, no network.
|
|
141
|
+
*/
|
|
142
|
+
export function checkCoverage(measurement) {
|
|
143
|
+
const m = measurement && typeof measurement === 'object' && !Array.isArray(measurement) ? measurement : null;
|
|
144
|
+
if (!m) return { known: false, checked: [], undetermined: CHECKS.map((c) => c.label) };
|
|
145
|
+
|
|
146
|
+
const checked = [];
|
|
147
|
+
const undetermined = [];
|
|
148
|
+
for (const { key, label } of CHECKS) {
|
|
149
|
+
let ok = m[key] !== undefined && m[key] !== null;
|
|
150
|
+
if (ok && key === 'lowContrastText') {
|
|
151
|
+
const src = typeof m.contrastSource === 'string' ? m.contrastSource.trim().toLowerCase() : null;
|
|
152
|
+
if (src && CONTRAST_NOT_MEASURED.has(src)) ok = false;
|
|
153
|
+
}
|
|
154
|
+
if (ok && key === 'paintedRatio') ok = typeof m.paintedRatio === 'number' && Number.isFinite(m.paintedRatio);
|
|
155
|
+
if (ok && key === 'scrollWidth') {
|
|
156
|
+
ok = typeof m.scrollWidth === 'number' && typeof m.viewport?.width === 'number';
|
|
157
|
+
}
|
|
158
|
+
(ok ? checked : undetermined).push(label);
|
|
159
|
+
}
|
|
160
|
+
return { known: true, checked, undetermined };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** References the browser can fetch on its own. Everything else is ours to have inlined. */
|
|
164
|
+
const FETCHABLE = /^(https?:|\/\/|data:)/i;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Remove the parts of an HTML document that only LOOK like markup.
|
|
168
|
+
*
|
|
169
|
+
* ⚠️ A CHECK THAT FAILS CORRECT WORK IS WORSE THAN NO CHECK. A commented-out
|
|
170
|
+
* `<link rel="stylesheet">` is not a stylesheet reference, and a `<style>` tag
|
|
171
|
+
* quoted inside a script is not a style block. Accusing either would make this
|
|
172
|
+
* module fire on pages that are perfectly fine — the exact failure this repo has
|
|
173
|
+
* been bitten by four times in a day.
|
|
174
|
+
*/
|
|
175
|
+
function stripInert(html) {
|
|
176
|
+
const withoutComments = html.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
177
|
+
let hadScript = false;
|
|
178
|
+
const withoutScripts = withoutComments.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, () => {
|
|
179
|
+
hadScript = true;
|
|
180
|
+
return ' ';
|
|
181
|
+
});
|
|
182
|
+
if (/<script\b/i.test(withoutScripts)) hadScript = true;
|
|
183
|
+
return { html: withoutScripts, hadScript };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* ── ⭐⭐ CAN THIS RENDER BE BELIEVED? ────────────────────────────────────────
|
|
188
|
+
*
|
|
189
|
+
* Reads the HTML that was actually POSTed to the render service and answers one
|
|
190
|
+
* question the pixels cannot: did the page reach the browser with its design?
|
|
191
|
+
*
|
|
192
|
+
* Returns:
|
|
193
|
+
* · `known` — false when we never observed the request; then every other
|
|
194
|
+
* field is null, because guessing here is the whole bug.
|
|
195
|
+
* · `trustworthy` — false when a stylesheet reference survived into the
|
|
196
|
+
* payload. The browser fetched nothing for it, so the page
|
|
197
|
+
* rendered naked and every measurement below is meaningless.
|
|
198
|
+
* · `styled` — the page carries SOME styling (inline block, remote sheet,
|
|
199
|
+
* or style attributes).
|
|
200
|
+
* · `noCss` — no styling of any kind AND no script that could inject it.
|
|
201
|
+
* That is a real, common defect in model-written pages, and
|
|
202
|
+
* it is stated as an observation, never as a verdict on
|
|
203
|
+
* taste.
|
|
204
|
+
*
|
|
205
|
+
* Pure: a string in, a plain object out.
|
|
206
|
+
*/
|
|
207
|
+
export function stylingTrust(sentHtml) {
|
|
208
|
+
if (typeof sentHtml !== 'string') {
|
|
209
|
+
return {
|
|
210
|
+
known: false,
|
|
211
|
+
trustworthy: null,
|
|
212
|
+
styled: null,
|
|
213
|
+
noCss: null,
|
|
214
|
+
unresolved: [],
|
|
215
|
+
reason: null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { html, hadScript } = stripInert(sentHtml);
|
|
220
|
+
|
|
221
|
+
const unresolved = [];
|
|
222
|
+
let remoteSheet = false;
|
|
223
|
+
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
|
|
224
|
+
if (!/rel\s*=\s*["']?stylesheet/i.test(tag)) continue;
|
|
225
|
+
const href = (/href\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? '').trim();
|
|
226
|
+
if (!href) { unresolved.push('(a stylesheet link with no href)'); continue; }
|
|
227
|
+
if (FETCHABLE.test(href)) { remoteSheet = true; continue; }
|
|
228
|
+
unresolved.push(href);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let hasStyleBlock = false;
|
|
232
|
+
for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
233
|
+
if (m[1].trim()) { hasStyleBlock = true; break; }
|
|
234
|
+
}
|
|
235
|
+
const hasStyleAttr = /\sstyle\s*=\s*["'][^"']*\S[^"']*["']/i.test(html);
|
|
236
|
+
|
|
237
|
+
const styled = hasStyleBlock || remoteSheet || hasStyleAttr;
|
|
238
|
+
const trustworthy = unresolved.length === 0;
|
|
239
|
+
/**
|
|
240
|
+
* ⚠️ ABSTAIN WHEN A SCRIPT COULD BE DOING IT. Plenty of legitimate pages build
|
|
241
|
+
* their stylesheet at runtime; accusing those of having "no CSS" would be a
|
|
242
|
+
* confident wrong sentence about a page that is fine.
|
|
243
|
+
*/
|
|
244
|
+
const noCss = !styled && unresolved.length === 0 && !hadScript;
|
|
245
|
+
|
|
246
|
+
const reason = unresolved.length > 0
|
|
247
|
+
? `the page was rendered WITHOUT its stylesheet — the render service was handed HTML text that still links `
|
|
248
|
+
+ `${unresolved.map((u) => `"${u}"`).join(', ')}, which the browser could not fetch, so every check below `
|
|
249
|
+
+ `ran on an unstyled page and a clean result means nothing`
|
|
250
|
+
: null;
|
|
251
|
+
|
|
252
|
+
return { known: true, trustworthy, styled, noCss, unresolved, reason };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* What handing this screenshot to a model would cost instead.
|
|
257
|
+
*
|
|
258
|
+
* Uses the published image-token arithmetic: an image is scaled so its longest
|
|
259
|
+
* edge is at most 1568px, then costs roughly `width × height / 750` tokens.
|
|
260
|
+
*
|
|
261
|
+
* ⚠️ COMPUTED, NEVER QUOTED. The README once carried "3,072 tokens" as a flat
|
|
262
|
+
* figure; it is only true for one image size. A number that changes with the
|
|
263
|
+
* viewport has to be derived from the viewport, or the first person who checks
|
|
264
|
+
* it finds us wrong about our own headline claim.
|
|
265
|
+
*
|
|
266
|
+
* ⚠️ Junk in gets `null`, not a guess.
|
|
267
|
+
*/
|
|
268
|
+
export const IMAGE_MAX_EDGE = 1568;
|
|
269
|
+
export const IMAGE_PIXELS_PER_TOKEN = 750;
|
|
270
|
+
|
|
271
|
+
export function imageTokenCost(width, height) {
|
|
272
|
+
if (typeof width !== 'number' || typeof height !== 'number') return null;
|
|
273
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
|
274
|
+
const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(width, height));
|
|
275
|
+
const w = Math.round(width * scale);
|
|
276
|
+
const h = Math.round(height * scale);
|
|
277
|
+
return Math.ceil((w * h) / IMAGE_PIXELS_PER_TOKEN);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A token estimate for text, honest about being an estimate.
|
|
282
|
+
*
|
|
283
|
+
* ⚠️ ~4 ASCII CHARACTERS PER TOKEN, AND ONE TOKEN PER NON-ASCII CHARACTER. The
|
|
284
|
+
* second half matters: CJK and emoji do not compress at four-to-one, and a
|
|
285
|
+
* naive `length / 4` would UNDER-count our own side of the comparison — that is,
|
|
286
|
+
* it would flatter the claim this module makes. Over-counting ourselves is the
|
|
287
|
+
* honest direction to be wrong in.
|
|
288
|
+
*/
|
|
289
|
+
export function approxTokens(text) {
|
|
290
|
+
if (typeof text !== 'string' || text.length === 0) return 0;
|
|
291
|
+
let ascii = 0;
|
|
292
|
+
let wide = 0;
|
|
293
|
+
for (const ch of text) {
|
|
294
|
+
if (ch.codePointAt(0) < 128) ascii += 1; else wide += 1;
|
|
295
|
+
}
|
|
296
|
+
return Math.ceil(ascii / 4) + wide;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* ── ⭐ THE RETURN VALUE. THIS IS THE PRODUCT ────────────────────────────────
|
|
301
|
+
*
|
|
302
|
+
* An ordered, specific, ~89-token verdict. Every line is a thing the next round
|
|
303
|
+
* can act on, and the things we could not determine are stated as such.
|
|
304
|
+
*
|
|
305
|
+
* ⚠️ THE THREE SENTENCES IT MUST NEVER GET WRONG:
|
|
306
|
+
* 1. A failed look makes NO claim, and forbids the model from making one. The
|
|
307
|
+
* original bug shipped "I looked and it was fine" for pages never rendered.
|
|
308
|
+
* 2. An untrustworthy render is never phrased as an all-clear, even when the
|
|
309
|
+
* findings list is genuinely empty — an unstyled page passes every check.
|
|
310
|
+
* 3. An all-clear NAMES THE CHECKS it covers, so "fine" and "not measured" can
|
|
311
|
+
* never be read as the same thing.
|
|
312
|
+
*
|
|
313
|
+
* Pure: no clock, no network, no fs.
|
|
314
|
+
*/
|
|
315
|
+
export function buildVerdict({ path, viewport, findings = [], coverage = null, trust = null, looked = false, error = null, screenshot = null } = {}) {
|
|
316
|
+
const where = path ? String(path) : 'the page';
|
|
317
|
+
|
|
318
|
+
if (!looked) {
|
|
319
|
+
return [
|
|
320
|
+
`COULD NOT LOOK at ${where}${error ? ` — ${String(error).slice(0, 300)}` : ''}.`,
|
|
321
|
+
'No visual claim about this page is available: do not report it as working and do not describe how it looks.',
|
|
322
|
+
].join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const dims = Number.isFinite(viewport?.width) && Number.isFinite(viewport?.height)
|
|
326
|
+
? ` — ${viewport.width}×${viewport.height}`
|
|
327
|
+
: '';
|
|
328
|
+
const lines = [`LOOKED AT ${where}${dims}`];
|
|
329
|
+
|
|
330
|
+
const list = Array.isArray(findings) ? findings.filter((f) => typeof f === 'string' && f.trim()) : [];
|
|
331
|
+
if (list.length > 0) {
|
|
332
|
+
list.forEach((f, i) => lines.push(`${i + 1}. ${f}`));
|
|
333
|
+
} else if (trust?.known && trust.trustworthy === false) {
|
|
334
|
+
// Unreachable in practice — the trust failure is itself finding #1 — but a
|
|
335
|
+
// formatter that could ever emit a bare all-clear for an unstyled page is
|
|
336
|
+
// the exact bug, so it is closed here too.
|
|
337
|
+
lines.push('Nothing else was measurable.');
|
|
338
|
+
} else if (coverage?.checked?.length) {
|
|
339
|
+
lines.push(`No measured problems. Checked: ${coverage.checked.join(', ')}.`);
|
|
340
|
+
} else {
|
|
341
|
+
lines.push('No problems were reported, and no check is known to have run — treat this as unverified.');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* ── ⚠️⚠️ THE SCREENSHOT EXISTED AND NOBODY WAS TOLD WHERE ──────────────────
|
|
346
|
+
*
|
|
347
|
+
* `seePage` writes the PNG and returns its path, and this verdict — the only
|
|
348
|
+
* thing the model ever sees — never mentioned it. So the agent was told "I
|
|
349
|
+
* looked" and handed a list of MEASUREMENTS, with no way to look itself.
|
|
350
|
+
*
|
|
351
|
+
* ⚠️ AND IT COULD NOT HAVE FOUND IT BY GUESSING. The file lands in `.acuvo/`,
|
|
352
|
+
* and `find_files` refuses to search hidden directories — measured: it returns
|
|
353
|
+
* `skipped: [{path: ".acuvo", reason: "hidden directory, not searched"}]`. Two
|
|
354
|
+
* tools disagreeing about whether that directory exists.
|
|
355
|
+
*
|
|
356
|
+
* ⭐ Naming it turns three finished halves into a loop: render the page, read
|
|
357
|
+
* the image with `read_image`, change the code. That is the design loop this
|
|
358
|
+
* package is named for, and it was one sentence away the whole time.
|
|
359
|
+
*/
|
|
360
|
+
if (typeof screenshot === 'string' && screenshot.trim()) {
|
|
361
|
+
lines.push(`SCREENSHOT: ${screenshot} — open it with read_image to see the page yourself; the findings above are measurements, not a look.`);
|
|
362
|
+
}
|
|
363
|
+
if (coverage?.undetermined?.length) {
|
|
364
|
+
lines.push(`NOT CHECKED (nothing was measured, so no claim either way): ${coverage.undetermined.join(', ')}.`);
|
|
365
|
+
}
|
|
366
|
+
if (trust?.known === false) {
|
|
367
|
+
lines.push('NOT CHECKED: whether the page reached the browser with its own stylesheet.');
|
|
368
|
+
}
|
|
369
|
+
if (trust?.known && trust.trustworthy === false) {
|
|
370
|
+
lines.push('⚠ This is not an all-clear: an unstyled page passes every check above. Inline the stylesheet or fix the reference, then look again.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return lines.join('\n');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Wrap a fetch so we can read the request we sent and the reply we got, without
|
|
378
|
+
* consuming either from the caller's point of view.
|
|
379
|
+
*
|
|
380
|
+
* ⚠️ A REAL `Response` BODY CAN ONLY BE READ ONCE, and `seePage` reads it. So the
|
|
381
|
+
* observed body is handed back through a duck-typed stand-in carrying exactly
|
|
382
|
+
* the surface `postJson` uses (`ok`, `status`, `statusText`, `text()`). Building
|
|
383
|
+
* a fresh `Response` would be prettier and would throw on null-body statuses —
|
|
384
|
+
* this cannot.
|
|
385
|
+
*/
|
|
386
|
+
function observingFetch(fetchImpl) {
|
|
387
|
+
const seen = { calls: 0, requestHtml: null, measurement: null };
|
|
388
|
+
const wrapped = async (url, init) => {
|
|
389
|
+
seen.calls += 1;
|
|
390
|
+
try {
|
|
391
|
+
const body = JSON.parse(init?.body ?? '{}');
|
|
392
|
+
seen.requestHtml = typeof body?.html === 'string' ? body.html : null;
|
|
393
|
+
} catch { seen.requestHtml = null; }
|
|
394
|
+
|
|
395
|
+
const res = await fetchImpl(url, init);
|
|
396
|
+
let text = '';
|
|
397
|
+
try { text = await res.text(); } catch { text = ''; }
|
|
398
|
+
try {
|
|
399
|
+
const parsed = JSON.parse(text);
|
|
400
|
+
seen.measurement = parsed?.measurement ?? parsed?.measurements ?? null;
|
|
401
|
+
} catch { seen.measurement = null; }
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
ok: res.ok,
|
|
405
|
+
status: res.status,
|
|
406
|
+
statusText: res.statusText,
|
|
407
|
+
headers: res.headers,
|
|
408
|
+
text: async () => text,
|
|
409
|
+
json: async () => JSON.parse(text),
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
return { wrapped, seen };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* ── ⭐⭐ THE PASS ────────────────────────────────────────────────────────────
|
|
417
|
+
*
|
|
418
|
+
* Render the page, read what came back, and return something the model can act
|
|
419
|
+
* on plus something the human can look at.
|
|
420
|
+
*
|
|
421
|
+
* The return is a strict SUPERSET of `seePage`'s, deliberately: every existing
|
|
422
|
+
* consumer (`tools.mjs`, `report.mjs`, `parallel.mjs`, `turn.mjs`) reads `ok`,
|
|
423
|
+
* `path`, `screenshot`, `viewport`, `findings` and `looked`, and all six survive
|
|
424
|
+
* unchanged. Wiring this in is a swap, not a migration.
|
|
425
|
+
*
|
|
426
|
+
* Added on top:
|
|
427
|
+
* · `verdict` the ~89-token string to put in the tool result
|
|
428
|
+
* · `trustworthy` false when the render cannot be believed
|
|
429
|
+
* · `trust` why, in detail
|
|
430
|
+
* · `undetermined` checks that abstained
|
|
431
|
+
* · `checked` checks an all-clear actually covers
|
|
432
|
+
* · `screenshotAbsolute` the path `renderImage` needs to draw pixels
|
|
433
|
+
* · `cost` the measured token comparison, computed per page
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ NEVER THROWS. A picture failing to arrive must not end a coding session.
|
|
436
|
+
*/
|
|
437
|
+
export async function designPass(root, htmlPath, { env = process.env, fetchImpl = fetch, dryRun = false, seeImpl = seePage } = {}) {
|
|
438
|
+
const { wrapped, seen } = observingFetch(fetchImpl);
|
|
439
|
+
|
|
440
|
+
let seen_ = null;
|
|
441
|
+
try {
|
|
442
|
+
seen_ = await seeImpl(root, htmlPath, { env, fetchImpl: wrapped, dryRun });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
seen_ = { ok: false, error: `the render step threw: ${err?.message ?? err}` };
|
|
445
|
+
}
|
|
446
|
+
const looked = seen_?.ok === true && seen_?.looked === true;
|
|
447
|
+
|
|
448
|
+
const trust = stylingTrust(seen.requestHtml);
|
|
449
|
+
const coverage = looked ? checkCoverage(seen.measurement) : { known: false, checked: [], undetermined: CHECKS.map((c) => c.label) };
|
|
450
|
+
|
|
451
|
+
if (!looked) {
|
|
452
|
+
const error = seen_?.error ?? 'the render step returned nothing usable';
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
error,
|
|
456
|
+
path: typeof htmlPath === 'string' ? htmlPath : null,
|
|
457
|
+
looked: false,
|
|
458
|
+
trustworthy: false,
|
|
459
|
+
trust,
|
|
460
|
+
screenshot: null,
|
|
461
|
+
screenshotAbsolute: null,
|
|
462
|
+
screenshotBytes: 0,
|
|
463
|
+
viewport: null,
|
|
464
|
+
findings: [],
|
|
465
|
+
checked: [],
|
|
466
|
+
undetermined: coverage.undetermined,
|
|
467
|
+
verdict: buildVerdict({ path: htmlPath, looked: false, error }),
|
|
468
|
+
cost: null,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* ⚠️ THE TRUST FINDING GOES FIRST, above the console error, because it
|
|
474
|
+
* invalidates every line under it. Same rule `findingsFrom` applies to console
|
|
475
|
+
* errors, one level up: the thing that explains the others leads.
|
|
476
|
+
*
|
|
477
|
+
* ⚠️ AND IT IS IN `findings`, NOT ONLY IN `verdict`. Downstream readers
|
|
478
|
+
* (`turn.mjs` counts looked-at pages, `report.mjs` summarises) read the array.
|
|
479
|
+
* A warning that exists only in the prose is a warning half the pipeline
|
|
480
|
+
* cannot see — which is how this package keeps shipping dead capability.
|
|
481
|
+
*/
|
|
482
|
+
const findings = [];
|
|
483
|
+
if (trust.known && trust.trustworthy === false && trust.reason) findings.push(trust.reason);
|
|
484
|
+
if (trust.known && trust.noCss === true) {
|
|
485
|
+
findings.push('the page carries no CSS at all — no <style> block, no stylesheet link, no style attributes; '
|
|
486
|
+
+ 'the browser rendered its own defaults, so this is what the user will see');
|
|
487
|
+
}
|
|
488
|
+
for (const f of Array.isArray(seen_.findings) ? seen_.findings : []) {
|
|
489
|
+
if (!findings.includes(f)) findings.push(f);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const verdict = buildVerdict({
|
|
493
|
+
path: seen_.path ?? htmlPath,
|
|
494
|
+
viewport: seen_.viewport,
|
|
495
|
+
findings,
|
|
496
|
+
coverage,
|
|
497
|
+
trust,
|
|
498
|
+
looked: true,
|
|
499
|
+
// ⭐ The PNG is already on disk and already returned below as `screenshot`.
|
|
500
|
+
// Until now it never reached the verdict, which is the only thing the model
|
|
501
|
+
// reads — so the file existed and nobody was told where.
|
|
502
|
+
screenshot: seen_.screenshot ?? null,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const screenshotTokens = imageTokenCost(seen_.viewport?.width, seen_.viewport?.height);
|
|
506
|
+
const verdictTokens = approxTokens(verdict);
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
ok: true,
|
|
510
|
+
path: seen_.path ?? htmlPath,
|
|
511
|
+
looked: true,
|
|
512
|
+
trustworthy: trust.known ? trust.trustworthy : null,
|
|
513
|
+
trust,
|
|
514
|
+
screenshot: seen_.screenshot ?? null,
|
|
515
|
+
/**
|
|
516
|
+
* ⭐ THE HALF THAT SHOWS THE HUMAN. `renderImage` needs an absolute path;
|
|
517
|
+
* `seePage` returns a workspace-relative one because that is what belongs in
|
|
518
|
+
* a summary. Both are returned so neither consumer has to guess.
|
|
519
|
+
*/
|
|
520
|
+
screenshotAbsolute: seen_.screenshot ? resolve(root, seen_.screenshot) : null,
|
|
521
|
+
screenshotBytes: seen_.screenshotBytes ?? 0,
|
|
522
|
+
viewport: seen_.viewport ?? null,
|
|
523
|
+
findings: findings.slice(0, 20),
|
|
524
|
+
checked: coverage.checked,
|
|
525
|
+
undetermined: coverage.undetermined,
|
|
526
|
+
verdict,
|
|
527
|
+
cost: {
|
|
528
|
+
verdictChars: verdict.length,
|
|
529
|
+
verdictTokensApprox: verdictTokens,
|
|
530
|
+
screenshotTokens,
|
|
531
|
+
// Abstains rather than dividing by an unknown viewport.
|
|
532
|
+
ratio: screenshotTokens && verdictTokens ? Number((screenshotTokens / verdictTokens).toFixed(1)) : null,
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* ── ⭐ AND THEN SHOW THE PICTURE ────────────────────────────────────────────
|
|
539
|
+
*
|
|
540
|
+
* Turn a pass into terminal lines: the verdict, the screenshot path, and — on a
|
|
541
|
+
* terminal that positively speaks kitty or iTerm2 — the actual pixels.
|
|
542
|
+
*
|
|
543
|
+
* ⚠️ THE PATH LINE STAYS EITHER WAY. The image is an ADDITION to the report,
|
|
544
|
+
* never a replacement for it: on Windows Terminal, in CI, or through a pipe,
|
|
545
|
+
* `renderImage` returns nothing at all and the human still gets a file to open.
|
|
546
|
+
*
|
|
547
|
+
* ⚠️ AND IT NEVER THROWS ON JUNK. Being handed a malformed pass must degrade to
|
|
548
|
+
* fewer lines, not to a crashed session over a picture.
|
|
549
|
+
*/
|
|
550
|
+
export function formatDesignPass(pass, { root = '.', env = process.env, isTTY = process.stdout.isTTY, renderImpl = renderImage } = {}) {
|
|
551
|
+
const out = [];
|
|
552
|
+
if (!pass || typeof pass !== 'object') return out;
|
|
553
|
+
|
|
554
|
+
if (typeof pass.verdict === 'string' && pass.verdict.trim()) out.push(pass.verdict);
|
|
555
|
+
|
|
556
|
+
if (typeof pass.screenshot === 'string' && pass.screenshot) {
|
|
557
|
+
const kb = Number.isFinite(pass.screenshotBytes) && pass.screenshotBytes > 0
|
|
558
|
+
? ` (${Math.max(1, Math.round(pass.screenshotBytes / 1024))}KB)`
|
|
559
|
+
: '';
|
|
560
|
+
out.push(` screenshot: ${pass.screenshot}${kb}`);
|
|
561
|
+
const absolute = typeof pass.screenshotAbsolute === 'string' && pass.screenshotAbsolute
|
|
562
|
+
? pass.screenshotAbsolute
|
|
563
|
+
: resolve(root, pass.screenshot);
|
|
564
|
+
let drawn = null;
|
|
565
|
+
try { drawn = renderImpl(absolute, { env, isTTY }); } catch { drawn = null; }
|
|
566
|
+
if (drawn?.text) out.push(String(drawn.text).replace(/\n+$/, ''));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return out;
|
|
570
|
+
}
|