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