@michaelmagan/dnumb 0.1.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.
Files changed (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. package/skills/dnumb/SKILL.md +136 -0
package/dist/cli.js ADDED
@@ -0,0 +1,719 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dnumb` — the bin entrypoint.
4
+ *
5
+ * Arg parsing is `node:util`'s `parseArgs`, not a dependency and not
6
+ * hand-rolled. The prototype hand-rolled it; `parseArgs` has been stable since
7
+ * Node 18 and this package's floor is 22.12.
8
+ *
9
+ * This file does three things and delegates everything else: turn argv into
10
+ * options, get a `Bundle` off disk, and print one of the two renderings
11
+ * `src/query.ts` already produced. It holds no formatting of its own, because a
12
+ * second place that decides what a measurement looks like is a second place for
13
+ * `--json` and the text to disagree. `capture` is held to the same rule: it
14
+ * launches a browser and navigates, but the measuring and the serializing are
15
+ * `dnumb/playwright`'s `capture()` and `serializeBundle`, called here rather
16
+ * than reimplemented — a second place that decides what a bundle looks like is
17
+ * the same mistake as a second place that decides what a digest looks like.
18
+ *
19
+ * `playwright-core` is loaded with a dynamic `import()` inside the `capture`
20
+ * verb only, never at module scope. It is an optional peer dependency, and a
21
+ * static top-level import would make every other verb — `look`, `q`, `diff`,
22
+ * none of which touch a browser — fail to load for a consumer who never
23
+ * installed it. Verified empirically, not assumed: a module with a static
24
+ * `import ... from 'playwright-core'` throws `ERR_MODULE_NOT_FOUND` on load
25
+ * before any of its exports run, while a module that only imports it inside
26
+ * one function loads fine and fails only when that function is called.
27
+ *
28
+ * Exit codes are the contract a CI script reads: 0 answered, 1 the command was
29
+ * wrong, 2 the bundle was unreadable, 3 a live capture could not be produced
30
+ * (no browser installed, DNS failure, connection refused, navigation timeout),
31
+ * 4 a capture succeeded and then could not be saved (permissions, a full
32
+ * disk, two capture names colliding on one path — `writeBundle` in
33
+ * `src/playwright.ts`). 3 and 4 exist as two codes, not one, because they are
34
+ * opposite failures a CI script needs to tell apart: 3 means nothing was
35
+ * measured, re-run the capture; 4 means the measurement worked and only the
36
+ * write did not, so re-running the same capture is exactly the right fix, not
37
+ * a wasted round trip. Before 4 existed, a write failure after a successful
38
+ * capture had no wrapped path at all — it escaped as an unhandled promise
39
+ * rejection with a raw stack trace and exit 1, misreporting an I/O problem as
40
+ * a bad command after the measurement had already been taken and thrown away.
41
+ * There is no "found problems" code, and there will not be one — dnumb does
42
+ * not adjudicate.
43
+ */
44
+ import { readFileSync } from 'node:fs';
45
+ import { resolve } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { parseArgs } from 'node:util';
48
+ import { BundleFormatError, parseBundle } from "./bundle.js";
49
+ import { DiffViewportMismatchError, diffCaptures } from "./diff/index.js";
50
+ import { SPARSE_CAPTURE_MARKER } from "./digest/index.js";
51
+ import { bundlePath, capture as captureToDisk, DEFAULT_OUTPUT_DIR, safeName, } from "./playwright.js";
52
+ import { look, Q_VERBS, QueryError, query } from "./query.js";
53
+ import { MAX_ELEMENTS } from "./snapshot/limits.js";
54
+ import { formatNotMeasured } from "./snapshot/not-measured.js";
55
+ import { SPEC_VERSION } from "./spec/index.js";
56
+ /**
57
+ * `../package.json` from this file's own location, which is the package root
58
+ * either way this file runs: `src/cli.ts` in dev/test, `dist/cli.cjs` after
59
+ * build. Read lazily, only when `--version` is actually asked for, rather
60
+ * than on every invocation.
61
+ */
62
+ const PACKAGE_JSON_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
63
+ /** `--help` promises "the package and spec versions"; this is the first half. */
64
+ function packageVersion() {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8'));
67
+ const version = pkg.version;
68
+ return typeof version === 'string' && version !== '' ? version : 'unknown';
69
+ }
70
+ catch {
71
+ // A missing or unreadable package.json should not take down `--version`
72
+ // itself -- the spec version, at least, is still answerable.
73
+ return 'unknown';
74
+ }
75
+ }
76
+ const OK = 0;
77
+ /** The command was wrong: unknown verb, missing argument, unknown element id. */
78
+ const USAGE_ERROR = 1;
79
+ /** The bundle was wrong: not there, not readable, not a dnumb bundle. */
80
+ const BUNDLE_ERROR = 2;
81
+ /**
82
+ * `capture` could not produce a bundle: no `playwright-core` installed, no
83
+ * browser binary downloaded, DNS failure, connection refused, or a navigation
84
+ * timeout. None of these are a bad command (1) or a bad bundle (2) — the
85
+ * command was right and there was no bundle to be wrong about yet.
86
+ */
87
+ const CAPTURE_ERROR = 3;
88
+ /**
89
+ * `capture` measured the page and then could not save it: a filesystem error
90
+ * writing the bundle (permissions, a full disk) or `writeBundle`'s own "two
91
+ * capture names write to one path" collision (`src/playwright.ts`). Distinct
92
+ * from `CAPTURE_ERROR` on purpose — see the file header for why.
93
+ */
94
+ const WRITE_ERROR = 4;
95
+ const USAGE = `dnumb — design, in numbers.
96
+
97
+ Turns a rendered page into measurements an agent can read. It reports facts and
98
+ does not decide whether anything is wrong.
99
+
100
+ USAGE
101
+ dnumb capture <url> [--name <name>] [--dir <dir>] [--viewport <WxH>] [--wait <state>]
102
+ [--strategy <auto|walk|cdp>]
103
+ dnumb --from <bundle> look [--focus <id|tag>] [--edges] [--budget-chars <n>] [--budget <n>]
104
+ dnumb --from <bundle> q <verb> [args]
105
+ dnumb --from <before> diff <after>
106
+
107
+ VERBS
108
+ capture <url> Launch a browser, navigate, and write a bundle.
109
+ look Render the digest: every element, geometry, facts.
110
+ diff <bundle> What changed between two captures, and why.
111
+ q describe <id> Everything the capture knows about one element.
112
+ q find <text|tag|role> Elements matching, as digest lines.
113
+ q at <x> <y> What is at a point. Innermost first.
114
+ q within <x> <y> <w> <h> What overlaps a region. Largest overlap first.
115
+ q distance <idA> <idB> Edge-to-edge and centre-to-centre distance.
116
+ q nearest <id> The closest layout nodes by edge distance.
117
+ q facts Every element carrying a fact, one per line.
118
+ q color fg/bg pairs, ratio ascending, unmeasured last.
119
+ q color <id> One element's full composite chain.
120
+ q color --all Every gated element's color line.
121
+
122
+ Capture reports whatever is on screen the instant it runs — a skeleton
123
+ screen, a mid-animation frame, a half-fetched list — with no way to tell
124
+ settled from loading, so choose --wait deliberately and expect to re-run it.
125
+
126
+ OPTIONS
127
+ --from <bundle> The capture bundle to read. Required by every verb but capture.
128
+ --json Machine-readable output. Every verb supports it.
129
+ --limit <n> Rows the list verbs print (default 50; 0 for all).
130
+ --k <n> Neighbours \`q nearest\` returns (default 4).
131
+ --focus <id> \`look\` only: scope the digest to one subtree.
132
+ --edges \`look\` only: add the edge-cluster histogram header.
133
+ --budget-chars <n> \`look\` only: soft target for total digest body characters
134
+ (default 32768). The standing budget meter -- always active.
135
+ --budget <n> \`look\` only: soft target for digest body lines. Additional to
136
+ --budget-chars, never a replacement for it -- passing --budget
137
+ does not turn the character budget off.
138
+ --all \`q color\` only: per-element lines instead of the pair table.
139
+ --name <name> \`capture\` only: name for the bundle (default "capture").
140
+ --dir <dir> \`capture\` only: directory the bundle is written to (default .dnumb).
141
+ --viewport <WxH> \`capture\` only: e.g. 375x812 (default: the browser's own default).
142
+ --wait <state> \`capture\` only: load|domcontentloaded|networkidle|commit before
143
+ capturing (default load). See the hazard above before trusting this.
144
+ --timeout <ms> \`capture\` only: navigation timeout (default: Playwright's own, 30s).
145
+ --strategy <s> \`capture\` only: auto|walk|cdp (default auto). \`docs/digest.md\` walks
146
+ through comparing a cdp capture against a walk capture of the same
147
+ page for both the color and the shadow-DOM asymmetry between the two
148
+ paths -- this is what lets that comparison happen from the CLI alone.
149
+ \`cdp\` on a non-Chromium/non-CDP page is a usage error, not a fallback.
150
+ -h, --help Print this and exit.
151
+ -v, --version Print the package and spec versions and exit.
152
+
153
+ Put \`--\` before a negative coordinate: dnumb --from b.json q at -- -8 120
154
+
155
+ Element ids like e13 are stable within one capture only. They are not DOM ids.
156
+ https://github.com/michaelmagan/dnumb#readme
157
+ `;
158
+ const OPTIONS = {
159
+ help: { type: 'boolean', short: 'h', default: false },
160
+ version: { type: 'boolean', short: 'v', default: false },
161
+ json: { type: 'boolean', default: false },
162
+ from: { type: 'string' },
163
+ focus: { type: 'string' },
164
+ edges: { type: 'boolean', default: false },
165
+ all: { type: 'boolean', default: false },
166
+ budget: { type: 'string' },
167
+ 'budget-chars': { type: 'string' },
168
+ k: { type: 'string' },
169
+ limit: { type: 'string' },
170
+ name: { type: 'string' },
171
+ dir: { type: 'string' },
172
+ viewport: { type: 'string' },
173
+ wait: { type: 'string' },
174
+ timeout: { type: 'string' },
175
+ strategy: { type: 'string' },
176
+ };
177
+ const out = (text) => {
178
+ process.stdout.write(text);
179
+ };
180
+ function fail(code, message) {
181
+ process.stderr.write(`${message}\n`);
182
+ return code;
183
+ }
184
+ /** A flag value that must be a whole number, with the flag named in the complaint. */
185
+ function integer(raw, flag, minimum) {
186
+ if (raw === undefined)
187
+ return undefined;
188
+ const value = Number.parseInt(raw, 10);
189
+ if (!Number.isFinite(value) || String(value) !== raw.trim() || value < minimum) {
190
+ throw new QueryError(`--${flag} takes a whole number ≥ ${minimum}; got "${raw}".`);
191
+ }
192
+ return value;
193
+ }
194
+ /** The states Playwright's `page.goto` can wait on before we take the snapshot. */
195
+ const WAIT_STATES = ['load', 'domcontentloaded', 'networkidle', 'commit'];
196
+ /** An error's first line, so a long CDP call-log doesn't drown the one sentence that matters. */
197
+ function firstLine(error) {
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ return (message.split('\n')[0] ?? message).trim();
200
+ }
201
+ /** `writeFileSync`/`mkdirSync` failure codes `writeBundle` (`src/playwright.ts`) can surface. */
202
+ const FS_ERROR_CODES = new Set([
203
+ 'ENOENT',
204
+ 'EACCES',
205
+ 'EPERM',
206
+ 'ENOSPC',
207
+ 'EROFS',
208
+ 'EEXIST',
209
+ 'EISDIR',
210
+ 'ENOTDIR',
211
+ 'EMFILE',
212
+ 'ENFILE',
213
+ ]);
214
+ /**
215
+ * True for the two failures `writeBundle` can throw: a filesystem error
216
+ * writing the file, or its own "two capture names write to one path"
217
+ * collision. Both happen strictly *after* `captureSnapshot` already
218
+ * succeeded — the measurement was taken — which is what earns them
219
+ * `WRITE_ERROR` instead of `CAPTURE_ERROR`: the latter means nothing was
220
+ * measured, and by the time either of these fires, something was. Anything
221
+ * else `captureToDisk` throws (a page crash mid-measurement, a lost CDP
222
+ * connection) still means `CAPTURE_ERROR` and falls through to it below.
223
+ */
224
+ function isWriteFailure(error) {
225
+ if (error instanceof Error && /^dnumb: capture .+ both write to /.test(error.message)) {
226
+ return true;
227
+ }
228
+ const code = error?.code;
229
+ return typeof code === 'string' && FS_ERROR_CODES.has(code);
230
+ }
231
+ /**
232
+ * `capture <url>` needs an absolute URL, not a bare host, because there is no
233
+ * `baseURL` for a relative one to resolve against. Caught here, before a
234
+ * browser is launched, it is a usage error (1); caught by the navigation
235
+ * itself it would be a capture error (3) with no clean way to tell the two
236
+ * apart from the caller's side.
237
+ */
238
+ function parseCaptureUrl(raw) {
239
+ if (raw === undefined || raw === '') {
240
+ throw new QueryError('capture needs a URL: dnumb capture <url> [options]');
241
+ }
242
+ try {
243
+ new URL(raw);
244
+ }
245
+ catch {
246
+ throw new QueryError(`"${raw}" is not a URL capture can load — it needs a scheme. Did you mean https://${raw}?`);
247
+ }
248
+ return raw;
249
+ }
250
+ /** `--viewport 375x812` → `{ width: 375, height: 812 }`, or a usage error naming what was typed. */
251
+ function parseViewport(raw) {
252
+ if (raw === undefined)
253
+ return undefined;
254
+ const match = /^(\d+)x(\d+)$/.exec(raw.trim());
255
+ if (!match?.[1] || !match[2]) {
256
+ throw new QueryError(`--viewport takes WIDTHxHEIGHT, e.g. 375x812; got "${raw}".`);
257
+ }
258
+ const width = Number.parseInt(match[1], 10);
259
+ const height = Number.parseInt(match[2], 10);
260
+ if (width < 1 || height < 1) {
261
+ throw new QueryError(`--viewport dimensions must be ≥ 1px; got "${raw}".`);
262
+ }
263
+ return { width, height };
264
+ }
265
+ function parseWait(raw) {
266
+ if (raw === undefined)
267
+ return 'load';
268
+ if (!WAIT_STATES.includes(raw)) {
269
+ throw new QueryError(`--wait takes one of ${WAIT_STATES.join(', ')}; got "${raw}".`);
270
+ }
271
+ return raw;
272
+ }
273
+ /**
274
+ * P2-2: `dnumb/playwright`'s `capture()` has always taken a `strategy`
275
+ * (`auto | walk | cdp`, `snapshot/capture.ts`), but the CLI never exposed it
276
+ * -- `doCapture` called `captureToDisk` with no strategy at all, always
277
+ * `auto`. `docs/digest.md` walks a reader through comparing a `cdp` capture
278
+ * against a `walk` capture of the same page twice over -- once for the color
279
+ * overlay-detection asymmetry (`centre` sample vs `whole-rect`), once for
280
+ * shadow DOM (`cdp` can flatten shadow content in, unreliably; `walk` cannot
281
+ * see into a shadow root at all) -- and a CLI-only user, with no Playwright
282
+ * suite to write `{ strategy: 'walk' }` into, had no way to run that
283
+ * comparison. `'auto'` is not in `CaptureOptions['strategy']`'s type as an
284
+ * omittable default by accident here either: passing it explicitly is
285
+ * identical to passing nothing, so this validates it without threading a
286
+ * fourth branch through `doCapture` for no behavioural difference.
287
+ */
288
+ const CAPTURE_STRATEGIES = ['auto', 'walk', 'cdp'];
289
+ function parseStrategy(raw) {
290
+ if (raw === undefined)
291
+ return undefined;
292
+ if (!CAPTURE_STRATEGIES.includes(raw)) {
293
+ throw new QueryError(`--strategy takes one of ${CAPTURE_STRATEGIES.join(', ')}; got "${raw}".`);
294
+ }
295
+ return raw;
296
+ }
297
+ /**
298
+ * Launch a browser, navigate, and hand the page to `dnumb/playwright`'s own
299
+ * `capture()` — the same function a Playwright suite calls. Everything below
300
+ * this line that is not "launch" or "navigate" is that function's job, not
301
+ * this one's, so a second bundle-writer never drifts from the first.
302
+ *
303
+ * `playwright-core` is imported here, inside the verb, and nowhere else in
304
+ * this file — see the file header for why a top-level import would be wrong.
305
+ */
306
+ async function doCapture(url, opts, json) {
307
+ let playwrightCore;
308
+ try {
309
+ playwrightCore = await import('playwright-core');
310
+ }
311
+ catch {
312
+ return fail(CAPTURE_ERROR, 'dnumb: capture needs playwright-core, an optional peer dependency that is not installed. ' +
313
+ "Run `npm install playwright-core` (or your package manager's equivalent), then " +
314
+ '`npx playwright install chromium` to download a browser.');
315
+ }
316
+ let browser;
317
+ try {
318
+ browser = await playwrightCore.chromium.launch();
319
+ }
320
+ catch (error) {
321
+ return fail(CAPTURE_ERROR, `dnumb: could not launch a browser: ${firstLine(error)}\n` +
322
+ 'Run `npx playwright install chromium` to download one.');
323
+ }
324
+ try {
325
+ const page = await browser.newPage(opts.viewport ? { viewport: opts.viewport } : {});
326
+ try {
327
+ await page.goto(url, {
328
+ waitUntil: opts.waitUntil,
329
+ ...(opts.timeoutMs !== undefined ? { timeout: opts.timeoutMs } : {}),
330
+ });
331
+ }
332
+ catch (error) {
333
+ return fail(CAPTURE_ERROR, `dnumb: could not load ${url}: ${firstLine(error)}`);
334
+ }
335
+ let outcome;
336
+ try {
337
+ outcome = await captureToDisk(page, opts.name, {
338
+ outputDir: opts.outputDir,
339
+ ...(opts.strategy !== undefined ? { strategy: opts.strategy } : {}),
340
+ });
341
+ }
342
+ catch (error) {
343
+ // Whatever comes next, the browser measured the page: `page.goto`
344
+ // already succeeded above. If this is a save failure, name it as one
345
+ // and report the path capture was trying to write, computed the same
346
+ // way `writeBundle` computes it, so nothing about a completed capture
347
+ // is silently lost — only unresolved. Anything else here is treated as
348
+ // a genuine capture failure (`CAPTURE_ERROR`) rather than left to
349
+ // escape as an unhandled rejection, which is what used to turn every
350
+ // one of these into a misreported exit 1.
351
+ if (isWriteFailure(error)) {
352
+ let intended = null;
353
+ try {
354
+ intended = resolve(bundlePath(opts.outputDir, opts.name));
355
+ }
356
+ catch {
357
+ // `safeName` already validated `opts.name` in `runCapture`; this
358
+ // is unreachable in practice, and `intended` staying null just
359
+ // drops the path from the message rather than throwing again.
360
+ }
361
+ return fail(WRITE_ERROR, `dnumb: captured ${url} but could not save the bundle` +
362
+ (intended ? ` to ${intended}` : '') +
363
+ `: ${firstLine(error)}\n` +
364
+ 'The measurement was taken; only the write failed. Fix the write problem ' +
365
+ '(permissions, disk space, or rename one of two colliding capture names) and run ' +
366
+ 'this same capture again — nothing about the page needs to change.');
367
+ }
368
+ return fail(CAPTURE_ERROR, `dnumb: captured ${url} but could not finish producing a bundle: ${firstLine(error)}`);
369
+ }
370
+ const { bundle, path } = outcome;
371
+ // `capture` is the first thing a new user runs, so it is also the first
372
+ // moment the tool discloses its own limits -- the moment where a caller
373
+ // could still re-run with a different `--strategy` or a narrower page.
374
+ // Both `truncated` and `notMeasured` were already written into the
375
+ // bundle either way; only the write's own report of them was missing.
376
+ const notMeasuredLine = formatNotMeasured(bundle.notMeasured);
377
+ const textLines = [`dnumb: wrote ${bundle.elements.length} elements to ${path}`];
378
+ if (bundle.truncated) {
379
+ // Named directly from the constant this same process just capped the
380
+ // walk at, not inferred from `bundle.elements.length` -- the count and
381
+ // the cap coincide for a bundle this process just wrote, but naming
382
+ // the cap directly says what is actually true regardless of that, and
383
+ // matches what a caller needs to know: this is the ceiling to raise.
384
+ textLines.push(`# TRUNCATED: this capture stopped at the ${MAX_ELEMENTS}-element cap; the page has more elements than were captured`);
385
+ }
386
+ if (notMeasuredLine)
387
+ textLines.push(`# ${notMeasuredLine}`);
388
+ out(json
389
+ ? `${JSON.stringify({
390
+ verb: 'capture',
391
+ name: bundle.name,
392
+ url: bundle.url,
393
+ path,
394
+ viewport: bundle.viewport,
395
+ // P2-3: `look --json` has always called this `records` (`src/
396
+ // query.ts`'s `look()`), matching what the digest header itself
397
+ // calls it (`# page: WxH (<N> records → <M> layout nodes ...)`,
398
+ // `docs/digest.md`). `capture --json` called the identical
399
+ // count `elements` -- same value, different name, across the
400
+ // two verbs a caller is most likely to pipe straight into each
401
+ // other. `records` is the name that already has two other
402
+ // callers agreeing with it.
403
+ records: bundle.elements.length,
404
+ truncated: bundle.truncated,
405
+ notMeasured: bundle.notMeasured,
406
+ }, null, 2)}\n`
407
+ : `${textLines.join('\n')}\n`);
408
+ return OK;
409
+ }
410
+ finally {
411
+ await browser.close();
412
+ }
413
+ }
414
+ /**
415
+ * The synchronous half of `capture`: parse and validate argv. This never
416
+ * touches a browser, so a bad `--viewport` or a missing URL fails instantly
417
+ * and exits 1 without the dynamic import of `playwright-core` ever running —
418
+ * which matters for a caller who does not have it installed and is only here
419
+ * because they typo'd a flag.
420
+ */
421
+ function runCapture(positionals, values, json) {
422
+ let url;
423
+ let name;
424
+ let outputDir;
425
+ let viewport;
426
+ let waitUntil;
427
+ let timeoutMs;
428
+ let strategy;
429
+ try {
430
+ url = parseCaptureUrl(positionals[0]);
431
+ name = typeof values.name === 'string' && values.name !== '' ? values.name : 'capture';
432
+ safeName(name); // Throws a "dnumb: capture name ..." Error if unusable as a filename.
433
+ outputDir =
434
+ typeof values.dir === 'string' && values.dir !== '' ? values.dir : DEFAULT_OUTPUT_DIR;
435
+ viewport = parseViewport(typeof values.viewport === 'string' ? values.viewport : undefined);
436
+ waitUntil = parseWait(typeof values.wait === 'string' ? values.wait : undefined);
437
+ timeoutMs = integer(typeof values.timeout === 'string' ? values.timeout : undefined, 'timeout', 1);
438
+ strategy = parseStrategy(typeof values.strategy === 'string' ? values.strategy : undefined);
439
+ }
440
+ catch (error) {
441
+ if (error instanceof QueryError)
442
+ return fail(USAGE_ERROR, `dnumb: ${error.message}`);
443
+ if (error instanceof Error) {
444
+ return fail(USAGE_ERROR, error.message.startsWith('dnumb:') ? error.message : `dnumb: ${error.message}`);
445
+ }
446
+ throw error;
447
+ }
448
+ return doCapture(url, {
449
+ name,
450
+ outputDir,
451
+ ...(viewport ? { viewport } : {}),
452
+ waitUntil,
453
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
454
+ ...(strategy !== undefined ? { strategy } : {}),
455
+ }, json);
456
+ }
457
+ function optionsFrom(values) {
458
+ const budgetLines = integer(values.budget, 'budget', 1);
459
+ // No default threaded through here: an absent `--budget-chars` leaves
460
+ // `budgetChars` undefined all the way to `renderDigest`, which supplies
461
+ // `DEFAULT_BUDGET_CHARS` itself (`digest/render.ts`). Hardcoding that
462
+ // number here would be a second place claiming to know the default, and
463
+ // the two would silently drift the next time the library's own changed.
464
+ const budgetChars = integer(values['budget-chars'], 'budget-chars', 1);
465
+ const k = integer(values.k, 'k', 1);
466
+ const limit = integer(values.limit, 'limit', 0);
467
+ return {
468
+ ...(values.focus !== undefined ? { focus: values.focus } : {}),
469
+ ...(values.edges ? { edges: true } : {}),
470
+ ...(values.all ? { all: true } : {}),
471
+ ...(budgetLines !== undefined ? { budgetLines } : {}),
472
+ ...(budgetChars !== undefined ? { budgetChars } : {}),
473
+ ...(k !== undefined ? { k } : {}),
474
+ ...(limit !== undefined ? { limit } : {}),
475
+ };
476
+ }
477
+ /**
478
+ * Read the bundle, or say which path was tried.
479
+ *
480
+ * `--from` is the only thing standing between an agent and six-month-old
481
+ * measurements, so a miss names the resolved absolute path rather than echoing
482
+ * back whatever relative string was typed — the two differ exactly when the
483
+ * caller is in the wrong directory, which is the case worth diagnosing.
484
+ */
485
+ function readBundle(from) {
486
+ const path = resolve(from);
487
+ let text;
488
+ try {
489
+ text = readFileSync(path, 'utf8');
490
+ }
491
+ catch (error) {
492
+ const code = error.code;
493
+ if (code === 'ENOENT') {
494
+ return fail(BUNDLE_ERROR, `dnumb: no bundle at ${path}. --from takes a file written by capture() (dnumb/playwright), not a URL or a directory.`);
495
+ }
496
+ if (code === 'EISDIR') {
497
+ return fail(BUNDLE_ERROR, `dnumb: ${path} is a directory. --from takes one bundle file.`);
498
+ }
499
+ return fail(BUNDLE_ERROR, `dnumb: could not read ${path}: ${error instanceof Error ? error.message : String(error)}`);
500
+ }
501
+ try {
502
+ return parseBundle(text, path);
503
+ }
504
+ catch (error) {
505
+ if (error instanceof BundleFormatError)
506
+ return fail(BUNDLE_ERROR, `dnumb: ${error.message}`);
507
+ throw error;
508
+ }
509
+ }
510
+ /**
511
+ * Mirrors `src/diff/render.ts`'s own (unexported) `RANK` and `documentOrder`
512
+ * -- `renderDiff` decides the text's line order and does not export the
513
+ * pieces that do, and this file owns the `--json` payload's own ordering
514
+ * decision (B5). Kept in the same shape on purpose: `--json` and the text
515
+ * describe the same diff, and a caller comparing the Nth JSON entry against
516
+ * the Nth text line should never find them disagreeing about what comes
517
+ * first. `Change['kind']` values are fixed by `diff/types.ts`; this table
518
+ * cannot silently miss one the compiler wouldn't catch.
519
+ */
520
+ const DIFF_KIND_RANK = {
521
+ added: 0,
522
+ removed: 0,
523
+ appeared: 1,
524
+ disappeared: 1,
525
+ text: 2,
526
+ attr: 2,
527
+ clip: 3,
528
+ overflow: 3,
529
+ box: 4,
530
+ style: 5,
531
+ };
532
+ /** Document order, read off the id -- same convention as `render.ts`'s own. */
533
+ function diffDocumentOrder(change) {
534
+ const id = change.after?.id ?? change.before?.id ?? '';
535
+ const digits = /^e(\d+)$/.exec(id)?.[1];
536
+ return digits === undefined ? 0 : Number(digits);
537
+ }
538
+ const diffRank = (change) => change.cause === 'derived' ? DIFF_KIND_RANK.style + 1 : DIFF_KIND_RANK[change.kind];
539
+ /**
540
+ * The `Change[]` `--json` actually reports: `renderDiff` never prints a
541
+ * `subsumed` change (`src/diff/render.ts`'s own `live` filter) -- it exists
542
+ * only to explain the change that absorbed it -- so including it here let
543
+ * `--json` report entries the text can never produce, and slicing the raw,
544
+ * unsorted array by `--limit` capped a different, differently-ordered list
545
+ * than the one `--limit` (as `maxLines`) capped in the text (B5). Filtering
546
+ * to the same "live" set and sorting it the same way the text is sorted
547
+ * fixes both: the two now describe the same set of changes, in the same
548
+ * order, even though the text goes on to fold some of them onto shared
549
+ * lines (`foldDerived`) where `--json` keeps one entry per change.
550
+ */
551
+ function reportableChanges(changes) {
552
+ return [...changes]
553
+ .filter((change) => change.subsumed === undefined)
554
+ .sort((a, b) => diffRank(a) - diffRank(b) || diffDocumentOrder(a) - diffDocumentOrder(b));
555
+ }
556
+ export function main(argv = process.argv.slice(2)) {
557
+ let values;
558
+ let positionals;
559
+ try {
560
+ ({ values, positionals } = parseArgs({
561
+ args: argv,
562
+ options: OPTIONS,
563
+ allowPositionals: true,
564
+ strict: true,
565
+ }));
566
+ }
567
+ catch (error) {
568
+ const message = error instanceof Error ? error.message : String(error);
569
+ const hint = error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION'
570
+ ? 'If that was a negative coordinate, put `--` before it: dnumb --from b.json q at -- -8 120'
571
+ : 'Run `dnumb --help` for the verbs and their arguments.';
572
+ return fail(USAGE_ERROR, `dnumb: ${message}\n${hint}`);
573
+ }
574
+ if (values.version === true) {
575
+ const version = packageVersion();
576
+ out(values.json === true
577
+ ? `${JSON.stringify({ package: version, spec: SPEC_VERSION })}\n`
578
+ : `dnumb ${version} (spec ${SPEC_VERSION})\n`);
579
+ return OK;
580
+ }
581
+ const verb = positionals[0];
582
+ if (values.help === true || verb === undefined) {
583
+ out(USAGE);
584
+ return values.help === true ? OK : USAGE_ERROR;
585
+ }
586
+ if (verb !== 'look' && verb !== 'q' && verb !== 'diff' && verb !== 'capture') {
587
+ return fail(USAGE_ERROR, `dnumb: unknown verb "${verb}". Known: look, diff, capture, q <${Q_VERBS.join(' | ')}>.`);
588
+ }
589
+ // `capture` is the one verb with a browser in it and no bundle to read, so
590
+ // it is dispatched before `--from` is required of everything else.
591
+ if (verb === 'capture') {
592
+ return runCapture(positionals.slice(1), values, values.json === true);
593
+ }
594
+ let options;
595
+ try {
596
+ options = optionsFrom(values);
597
+ }
598
+ catch (error) {
599
+ if (error instanceof QueryError)
600
+ return fail(USAGE_ERROR, `dnumb: ${error.message}`);
601
+ throw error;
602
+ }
603
+ if (typeof values.from !== 'string' || values.from === '') {
604
+ return fail(USAGE_ERROR, 'dnumb: --from <bundle> is required. Every verb but `capture` reads a capture bundle; ' +
605
+ 'there is no browser in this command. Use `dnumb capture <url>` to create one.');
606
+ }
607
+ const bundle = readBundle(values.from);
608
+ if (typeof bundle === 'number')
609
+ return bundle;
610
+ if (verb === 'diff') {
611
+ const otherPath = positionals[1];
612
+ if (otherPath === undefined) {
613
+ return fail(USAGE_ERROR, 'dnumb: diff needs a second bundle: dnumb --from before.json diff after.json');
614
+ }
615
+ const other = readBundle(otherPath);
616
+ if (typeof other === 'number')
617
+ return other;
618
+ let result;
619
+ try {
620
+ result = diffCaptures(bundle, other, {
621
+ beforeName: bundle.name,
622
+ afterName: other.name,
623
+ // `--help` documents `--limit 0` as "0 for all", matching `query.ts`'s
624
+ // own `cap()` precedent (`limit <= 0` shows everything). `renderDiff`
625
+ // (`src/diff/render.ts`) does not itself treat 0 that way -- it takes
626
+ // `maxLines` at face value, so `maxLines: 0` caps the body to zero
627
+ // lines and prints only the "not listed" notice (B6). `Infinity` is a
628
+ // cap `body.length` can never exceed, so it reads as "no cap" to the
629
+ // exact same `shown.length > maxLines` check without this file
630
+ // reaching into that module to change what it does with the number.
631
+ ...(options.limit === undefined
632
+ ? {}
633
+ : { maxLines: options.limit <= 0 ? Number.POSITIVE_INFINITY : options.limit }),
634
+ });
635
+ }
636
+ catch (error) {
637
+ // Comparing two viewports measures the viewport, not the change --
638
+ // `diffCaptures` itself refuses this now (`DiffViewportMismatchError`,
639
+ // worded identically to what this file used to check for itself), so a
640
+ // second guard here would be a second place deciding what this
641
+ // measurement means, which is the exact mistake this file's own header
642
+ // comment rules out.
643
+ if (error instanceof DiffViewportMismatchError) {
644
+ return fail(USAGE_ERROR, error.message);
645
+ }
646
+ throw error;
647
+ }
648
+ // `--limit` already caps `result.text`'s body (`diffCaptures` ->
649
+ // `renderDiff`); `--json` used to ignore it and always emit every change
650
+ // regardless, so an agent that passed `--limit` to bound its context got
651
+ // an unbounded payload back with no sign the flag did nothing.
652
+ // `result.truncated` already means something else -- whether either
653
+ // *source capture* hit the 5000-element cap -- so the count `--limit`
654
+ // itself left out gets its own key, `changesOmitted`, rather than
655
+ // overloading a key that already answers a different question.
656
+ //
657
+ // `reportableChanges` (above) is the fix for B5: capping the raw,
658
+ // unsorted `result.changes` -- which still carries every `subsumed`
659
+ // change the text never prints -- against a `maxLines` that bounds text
660
+ // *lines* gave `--limit 1` a "complete" text diff and a `changesOmitted`
661
+ // in the double digits on the same response. Capping the same
662
+ // live-and-sorted list the text is built from keeps the two answering
663
+ // the same question about the same diff.
664
+ const reportable = reportableChanges(result.changes);
665
+ const limit = options.limit;
666
+ const capChanges = limit !== undefined && limit > 0;
667
+ const changes = capChanges ? reportable.slice(0, limit) : reportable;
668
+ const changesOmitted = capChanges ? reportable.length - changes.length : 0;
669
+ out(values.json === true
670
+ ? `${JSON.stringify({
671
+ verb: 'diff',
672
+ before: bundle.name,
673
+ after: other.name,
674
+ truncated: result.truncated,
675
+ changesOmitted,
676
+ changes,
677
+ }, null, 2)}\n`
678
+ : `${result.text}\n`);
679
+ return OK;
680
+ }
681
+ try {
682
+ const result = verb === 'look'
683
+ ? look(bundle, options)
684
+ : query(bundle, positionals[1] ?? '', positionals.slice(2), options);
685
+ // `docs/notes/07-refusal.md` §2/§7: `look --json` gains `sparseCapture`
686
+ // so a programmatic caller can branch without string-matching the
687
+ // digest text. Read off the rendered text's own marker rather than
688
+ // recomputing the gate here -- `renderDigest` already decided this once
689
+ // (`elements >= 20 && dropped/total >= 0.9 && page.height <= 2`), and
690
+ // re-deriving it a second time in this file is exactly the "two places
691
+ // that decide what a bundle looks like" failure mode this file's own
692
+ // header comment warns against, one level up. `query`'s other verbs
693
+ // (`q ...`) are untouched -- the gate governs `look`'s default body
694
+ // only, not what `q facts`/`q describe`/`q find` return.
695
+ const json = verb === 'look' && result.json !== null && typeof result.json === 'object'
696
+ ? { ...result.json, sparseCapture: result.text.includes(SPARSE_CAPTURE_MARKER) }
697
+ : result.json;
698
+ out(values.json === true ? `${JSON.stringify(json, null, 2)}\n` : `${result.text}\n`);
699
+ return OK;
700
+ }
701
+ catch (error) {
702
+ if (error instanceof QueryError)
703
+ return fail(USAGE_ERROR, `dnumb: ${error.message}`);
704
+ throw error;
705
+ }
706
+ }
707
+ // `main` returns a plain number for every verb except `capture`, which needs
708
+ // a browser and a network round trip. Handling both shapes here — rather than
709
+ // making every verb async — keeps the exit code synchronous for the common
710
+ // case and lets tests call `main()` and read `.code` back immediately.
711
+ const mainResult = main();
712
+ if (mainResult instanceof Promise) {
713
+ mainResult.then((code) => {
714
+ process.exitCode = code;
715
+ });
716
+ }
717
+ else {
718
+ process.exitCode = mainResult;
719
+ }