@agentmarkup/audit 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Cochinescu and Anima Felix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @agentmarkup/audit
2
+
3
+ Audit any live URL the way AI crawlers actually see it.
4
+
5
+ Most SEO tools fetch a page once, as a browser, and grade the HTML. `@agentmarkup/audit` fetches the **same URL as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Google-Extended**, diffs each response against a normal browser, and reports where AI systems get a different — often worse — view than your human visitors. It also checks the machine-readable surface: `robots.txt` intent, Content-Signal, `llms.txt`, JSON-LD, and JavaScript-dependence.
6
+
7
+ It is deterministic (pass / warn / error, no invented scores) and CI-friendly.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ npx @agentmarkup/audit https://example.com
13
+ ```
14
+
15
+ ```bash
16
+ # JSON for CI / league tables
17
+ npx @agentmarkup/audit https://example.com --json
18
+
19
+ # custom per-request timeout
20
+ npx @agentmarkup/audit example.com --timeout 15000
21
+ ```
22
+
23
+ Bare domains are normalized to `https://`. Exit code is `1` when any **error**-level finding is present (a CI gate), `0` otherwise, `2` on a usage error.
24
+
25
+ ## What it checks
26
+
27
+ | Area | What it does |
28
+ | --- | --- |
29
+ | **Crawler access** | Fetches as each AI crawler user-agent and diffs status against a browser control. Flags challenges, differential blocks, rate limits, and origin errors. |
30
+ | **JS dependence** | Measures whether the raw (un-executed) HTML actually contains content, or is an empty `#root`/`#app` shell that only fills in after JavaScript runs. |
31
+ | **robots.txt** | Reuses `@agentmarkup/core` to detect whether the crawlers you likely want are shadowed by a wildcard `Disallow`, and whether a canonical Content-Signal policy is present. |
32
+ | **llms.txt** | Fetches `/llms.txt`, validates it, and checks the homepage links it for discovery. |
33
+ | **JSON-LD** | Extracts and structurally validates JSON-LD blocks on the page. |
34
+
35
+ ## An honest note on "blocked" crawlers
36
+
37
+ This tool spoofs a crawler's **user-agent** from an ordinary IP. That is exactly what a browser extension or a curious developer can do, and it is *not* what the real, verified bot does. So a `403` for a spoofed `GPTBot` user-agent is genuinely ambiguous:
38
+
39
+ - it can be a **user-agent WAF rule** — which also blocks the real GPTBot (a real problem), **or**
40
+ - it can be **IP allowlisting** — where the verified GPTBot, coming from OpenAI's published IP ranges, is let through just fine (no problem at all).
41
+
42
+ From a spoofed request we cannot tell these apart, so the audit reports them as **warnings with both explanations and the raw evidence**, never as a bare "your site blocks AI" error. Error-level findings are reserved for things that are provable from the response itself: a `robots.txt` that literally disallows the crawler, an empty JavaScript shell, or invalid `llms.txt` / JSON-LD.
43
+
44
+ ## Programmatic use
45
+
46
+ ```ts
47
+ import { audit, renderText } from '@agentmarkup/audit';
48
+
49
+ const report = await audit('https://example.com', {
50
+ fetchedAt: new Date().toISOString(),
51
+ });
52
+ console.log(report.summary); // { pass, warn, error, checks, passed, worst }
53
+ process.stdout.write(renderText(report));
54
+ ```
55
+
56
+ The exported analyzers (`analyzeCrawlerAccess`, `analyzeRobots`, `analyzeJsDependence`, `analyzeMachineReadable`) and the SSRF-safe `safeFetch` are available for building custom pipelines.
57
+
58
+ ## Safety
59
+
60
+ Requests are made with an SSRF-safe fetch: `localhost`, private, loopback, link-local, CGNAT, and IPv6-bypass address forms are refused, redirects are followed manually and re-validated per hop, and responses are size- and time-bounded. The blocklist mirrors the hosted checker at [agentmarkup.dev](https://agentmarkup.dev).
61
+
62
+ ## License
63
+
64
+ MIT © Sebastian Cochinescu and Anima Felix
65
+
66
+ Part of [agentmarkup](https://agentmarkup.dev) — build-time tooling to make websites machine-readable for LLMs and AI agents.
package/dist/bin.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/bin.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ run
4
+ } from "./chunk-PNE6FBX2.js";
5
+
6
+ // src/bin.ts
7
+ import { createRequire } from "module";
8
+ function resolveVersion() {
9
+ try {
10
+ const require2 = createRequire(import.meta.url);
11
+ const pkg = require2("../package.json");
12
+ return pkg.version ?? "0.0.0";
13
+ } catch {
14
+ return "0.0.0";
15
+ }
16
+ }
17
+ run(process.argv.slice(2), { version: resolveVersion() }).then((code) => {
18
+ process.exitCode = code;
19
+ }).catch((error) => {
20
+ process.stderr.write(
21
+ `agentmarkup-audit: ${error instanceof Error ? error.message : String(error)}
22
+ `
23
+ );
24
+ process.exitCode = 1;
25
+ });
@@ -0,0 +1,765 @@
1
+ // src/agents.ts
2
+ var BROWSER_CONTROL = {
3
+ id: "browser",
4
+ ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
5
+ vendor: "Control",
6
+ control: true
7
+ };
8
+ var CRAWLER_AGENTS = [
9
+ {
10
+ id: "gptbot",
11
+ ua: "Mozilla/5.0 (compatible; GPTBot/1.1; +https://openai.com/gptbot)",
12
+ vendor: "OpenAI",
13
+ verification: "ip-range",
14
+ intent: "training",
15
+ docsUrl: "https://platform.openai.com/docs/bots"
16
+ },
17
+ {
18
+ id: "oai-searchbot",
19
+ ua: "Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)",
20
+ vendor: "OpenAI",
21
+ verification: "ip-range",
22
+ intent: "search",
23
+ docsUrl: "https://platform.openai.com/docs/bots"
24
+ },
25
+ {
26
+ id: "claudebot",
27
+ ua: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://www.anthropic.com/claude-bot)",
28
+ vendor: "Anthropic",
29
+ verification: "ip-range",
30
+ intent: "training",
31
+ docsUrl: "https://support.anthropic.com/en/articles/8896518"
32
+ },
33
+ {
34
+ id: "perplexitybot",
35
+ ua: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
36
+ vendor: "Perplexity",
37
+ verification: "ip-range",
38
+ intent: "search",
39
+ docsUrl: "https://docs.perplexity.ai/guides/bots"
40
+ },
41
+ {
42
+ id: "google-extended",
43
+ ua: "Mozilla/5.0 (compatible; Google-Extended/1.0; +http://www.google.com/bot.html)",
44
+ vendor: "Google",
45
+ verification: "reverse-dns",
46
+ intent: "training",
47
+ docsUrl: "https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers"
48
+ }
49
+ ];
50
+ var ALL_AGENTS = [BROWSER_CONTROL, ...CRAWLER_AGENTS];
51
+
52
+ // src/analyzers/crawler-access.ts
53
+ var CHALLENGE_MARKERS = [
54
+ "cf-browser-verification",
55
+ "challenge-platform",
56
+ "just a moment",
57
+ "attention required",
58
+ "enable javascript and cookies to continue"
59
+ ];
60
+ function looksLikeBotChallenge(result) {
61
+ const mitigated = result.headers["cf-mitigated"];
62
+ if (mitigated && mitigated.toLowerCase().includes("challenge")) return true;
63
+ const body = (result.body ?? "").toLowerCase();
64
+ return CHALLENGE_MARKERS.some((marker) => body.includes(marker));
65
+ }
66
+ function statusClass(status) {
67
+ return status === null ? null : Math.floor(status / 100);
68
+ }
69
+ function analyzeCrawlerAccess(control, probes) {
70
+ const findings = [];
71
+ const controlClass = statusClass(control.status);
72
+ if (control.error || controlClass !== 2) {
73
+ findings.push({
74
+ code: "crawler.control-failed",
75
+ level: "warn",
76
+ title: "Could not establish a browser baseline",
77
+ detail: "The control request (normal browser user-agent) did not return a 2xx response, so bot-vs-browser differences cannot be judged reliably.",
78
+ evidence: `browser control: status=${control.status ?? "none"}${control.error ? ` error=${control.error}` : ""}`,
79
+ fix: "Confirm the URL is reachable and returns 200 in a browser, then re-run the audit."
80
+ });
81
+ return findings;
82
+ }
83
+ for (const { agent, result } of probes) {
84
+ const botClass = statusClass(result.status);
85
+ const evidence = `${agent.id} \u2192 status=${result.status ?? "none"}${result.error ? ` error=${result.error}` : ""}; browser \u2192 status=${control.status}`;
86
+ if (result.error === "timeout" || result.error === "network-error") {
87
+ findings.push({
88
+ code: "crawler.probe-failed",
89
+ level: "warn",
90
+ title: `Could not probe as ${agent.vendor} ${agent.id}`,
91
+ detail: `The request as ${agent.id} failed (${result.error}); no conclusion drawn for this crawler.`,
92
+ evidence
93
+ });
94
+ continue;
95
+ }
96
+ if (botClass === 2) {
97
+ findings.push({
98
+ code: "crawler.accessible",
99
+ level: "pass",
100
+ title: `${agent.vendor} ${agent.id} can reach the page`,
101
+ detail: `A request with the ${agent.id} user-agent returned the same success class as a browser.`,
102
+ evidence
103
+ });
104
+ continue;
105
+ }
106
+ if (result.status === 429) {
107
+ findings.push({
108
+ code: "crawler.rate-limited",
109
+ level: "warn",
110
+ title: `${agent.vendor} ${agent.id} is rate-limited`,
111
+ detail: `The ${agent.id} request was rate-limited (429). This is usually transient, but aggressive rate limits can starve crawlers of your content.`,
112
+ evidence
113
+ });
114
+ continue;
115
+ }
116
+ if (result.status === 403 || result.status === 401) {
117
+ const challenge = looksLikeBotChallenge(result);
118
+ if (challenge) {
119
+ findings.push({
120
+ code: "crawler.bot-challenge",
121
+ level: "warn",
122
+ title: `${agent.vendor} ${agent.id} hit a bot challenge`,
123
+ detail: `The ${agent.id} user-agent got a challenge/verification response (${result.status}). Because ${agent.id} is verified by ${agent.verification ?? "its published identity"}, the real crawler may pass where this spoofed user-agent does not. Confirm the verified bot is allowlisted at your CDN.`,
124
+ evidence,
125
+ fix: "Allowlist the crawler by its published IP ranges (verified bots) rather than relying on user-agent rules."
126
+ });
127
+ } else {
128
+ findings.push({
129
+ code: "crawler.ua-differential-block",
130
+ level: "warn",
131
+ title: `${agent.vendor} ${agent.id} is blocked from a generic IP`,
132
+ detail: `A browser gets ${control.status} but the ${agent.id} user-agent gets ${result.status}, with no challenge signal. Two things cause this and they mean opposite things: a user-agent-string WAF rule (which also blocks the real ${agent.id}) or IP allowlisting (where the verified ${agent.id} is fine). Check which it is at your CDN.`,
133
+ evidence,
134
+ fix: `If a WAF rule blocks the "${agent.id}" user-agent, remove or narrow it. If you allowlist verified bots by IP, no action is needed.`
135
+ });
136
+ }
137
+ continue;
138
+ }
139
+ if (botClass === 5) {
140
+ findings.push({
141
+ code: "crawler.origin-error",
142
+ level: "warn",
143
+ title: `${agent.vendor} ${agent.id} triggered a server error`,
144
+ detail: `The ${agent.id} user-agent got a ${result.status} while the browser got ${control.status}. Something in the stack treats this crawler differently and errors.`,
145
+ evidence
146
+ });
147
+ continue;
148
+ }
149
+ findings.push({
150
+ code: "crawler.differential-unknown",
151
+ level: "warn",
152
+ title: `${agent.vendor} ${agent.id} is treated differently than a browser`,
153
+ detail: `The ${agent.id} user-agent returned ${result.status} while a browser returned ${control.status}. The cause is unclear from the response; inspect the evidence.`,
154
+ evidence
155
+ });
156
+ }
157
+ return findings;
158
+ }
159
+
160
+ // src/analyzers/site-checks.ts
161
+ import {
162
+ extractJsonLdScriptContents,
163
+ findBlockedCrawlers,
164
+ hasLlmsTxtDiscoveryLink,
165
+ validateJsonLdNode,
166
+ validateLlmsTxt
167
+ } from "@agentmarkup/core";
168
+ var EXPECTED_CRAWLERS = Object.fromEntries(
169
+ CRAWLER_AGENTS.map((agent) => [agent.ua.split("/")[0], "allow"])
170
+ );
171
+ function stripTags(html) {
172
+ return html.replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
173
+ }
174
+ var EMPTY_ROOT_RE = /<(?:div|main)\b[^>]*\bid=(['"])(?:root|app|__next|__nuxt|svelte)\1[^>]*>\s*<\/(?:div|main)>/i;
175
+ function analyzeJsDependence(control) {
176
+ if (control.error || !control.body || (control.status ?? 0) >= 400) {
177
+ return [];
178
+ }
179
+ const text = stripTags(control.body);
180
+ const emptyRoot = EMPTY_ROOT_RE.test(control.body);
181
+ if (text.length < 200 && emptyRoot) {
182
+ return [
183
+ {
184
+ code: "js.empty-shell",
185
+ level: "error",
186
+ title: "Page content requires JavaScript",
187
+ detail: "The raw HTML has an empty root container and almost no text. Most AI crawlers do not run JavaScript, so they see an empty page. Server-render or prerender the content.",
188
+ evidence: `raw text length=${text.length}; empty root container detected`,
189
+ fix: "Prerender or SSR the page, or add markdown mirrors (agentmarkup markdownPages) so agents get real content."
190
+ }
191
+ ];
192
+ }
193
+ if (text.length < 200) {
194
+ return [
195
+ {
196
+ code: "js.thin-html",
197
+ level: "warn",
198
+ title: "Raw HTML is very thin",
199
+ detail: "The raw (un-executed) HTML contains little text. If the real content is injected by JavaScript, crawlers that do not run JS will miss it.",
200
+ evidence: `raw text length=${text.length}`,
201
+ fix: "Confirm meaningful content is present without JavaScript; consider markdown mirrors."
202
+ }
203
+ ];
204
+ }
205
+ return [
206
+ {
207
+ code: "js.server-rendered",
208
+ level: "pass",
209
+ title: "Content is present without JavaScript",
210
+ detail: "The raw HTML already contains meaningful text, so crawlers that do not execute JavaScript can read the page.",
211
+ evidence: `raw text length=${text.length}`
212
+ }
213
+ ];
214
+ }
215
+ function analyzeRobots(robots) {
216
+ const findings = [];
217
+ const has = !robots.error && (robots.status ?? 0) < 400 && Boolean(robots.body);
218
+ if (!has) {
219
+ findings.push({
220
+ code: "robots.missing",
221
+ level: "warn",
222
+ title: "No robots.txt found",
223
+ detail: "No reachable robots.txt. Crawlers assume full access, but you also cannot express AI-specific or Content-Signal preferences.",
224
+ fix: "Generate robots.txt with agentmarkup (aiCrawlers + contentSignalHeaders)."
225
+ });
226
+ return findings;
227
+ }
228
+ const body = robots.body ?? "";
229
+ const blocked = findBlockedCrawlers(body, EXPECTED_CRAWLERS);
230
+ if (blocked.length > 0) {
231
+ findings.push({
232
+ code: "robots.blocks-crawlers",
233
+ level: "error",
234
+ title: "robots.txt blocks AI crawlers you likely want",
235
+ detail: `A wildcard disallow shadows these crawlers: ${blocked.join(
236
+ ", "
237
+ )}. Blocking search/retrieval crawlers drops you from AI answers.`,
238
+ evidence: blocked.join(", "),
239
+ fix: "Split rules by intent: block training crawlers if you must, but keep search/retrieval crawlers allowed."
240
+ });
241
+ } else {
242
+ findings.push({
243
+ code: "robots.crawlers-allowed",
244
+ level: "pass",
245
+ title: "robots.txt does not block the expected AI crawlers",
246
+ detail: "None of the checked AI crawlers are shadowed by a wildcard disallow."
247
+ });
248
+ }
249
+ if (/^\s*content-signal\s*:/im.test(body)) {
250
+ findings.push({
251
+ code: "robots.content-signal",
252
+ level: "pass",
253
+ title: "Content-Signal policy present in robots.txt",
254
+ detail: "The canonical Content-Signal directive is in robots.txt, where the Content Signals Policy and Lighthouse look for it."
255
+ });
256
+ } else {
257
+ findings.push({
258
+ code: "robots.no-content-signal",
259
+ level: "warn",
260
+ title: "No Content-Signal policy in robots.txt",
261
+ detail: "Content-Signal in robots.txt is the canonical place to state training/search/ai-input preferences. It may still be set as an HTTP header, which fewer tools read.",
262
+ fix: "Enable agentmarkup contentSignalHeaders so Content-Signal is written into robots.txt."
263
+ });
264
+ }
265
+ return findings;
266
+ }
267
+ function analyzeMachineReadable(control, llms) {
268
+ const findings = [];
269
+ const html = control.body ?? "";
270
+ const llmsOk = !llms.error && (llms.status ?? 0) < 400 && Boolean(llms.body);
271
+ if (llmsOk) {
272
+ const results = validateLlmsTxt(llms.body ?? "");
273
+ const errors = results.filter((r) => r.severity === "error");
274
+ findings.push(
275
+ errors.length > 0 ? {
276
+ code: "llms.invalid",
277
+ level: "error",
278
+ title: "llms.txt has errors",
279
+ detail: errors.map((r) => r.message).join("; ")
280
+ } : {
281
+ code: "llms.present",
282
+ level: "pass",
283
+ title: "llms.txt is present and well-formed",
284
+ detail: "A parseable llms.txt was found. Note: most AI crawlers do not yet fetch llms.txt, but AI coding tools and some assistants do."
285
+ }
286
+ );
287
+ } else {
288
+ findings.push({
289
+ code: "llms.missing",
290
+ level: "warn",
291
+ title: "No llms.txt found",
292
+ detail: "No reachable /llms.txt. This is optional \u2014 it helps AI coding tools and some assistants, but major crawlers do not require it.",
293
+ fix: "Generate llms.txt with agentmarkup if you want a curated agent manifest."
294
+ });
295
+ }
296
+ if (html && !hasLlmsTxtDiscoveryLink(html) && llmsOk) {
297
+ findings.push({
298
+ code: "llms.no-discovery-link",
299
+ level: "warn",
300
+ title: "llms.txt is not linked from the homepage",
301
+ detail: 'An llms.txt exists but the homepage has no <link rel="alternate" type="text/plain" href="/llms.txt">, so agents cannot discover it from the page.',
302
+ fix: "agentmarkup injects this discovery link automatically."
303
+ });
304
+ }
305
+ if (html) {
306
+ const blocks = extractJsonLdScriptContents(html);
307
+ if (blocks.length === 0) {
308
+ findings.push({
309
+ code: "jsonld.missing",
310
+ level: "warn",
311
+ title: "No JSON-LD structured data",
312
+ detail: "The page has no JSON-LD. Structured data helps AI systems and search understand the page entity.",
313
+ fix: "Add JSON-LD with agentmarkup schema presets (webSite, organization, article, \u2026)."
314
+ });
315
+ } else {
316
+ const errors = [];
317
+ for (const block of blocks) {
318
+ try {
319
+ const parsed = JSON.parse(block);
320
+ const nodes = Array.isArray(parsed) ? parsed : [parsed];
321
+ for (const node of nodes) {
322
+ for (const r of validateJsonLdNode(node)) {
323
+ if (r.severity === "error") errors.push(r.message);
324
+ }
325
+ }
326
+ } catch {
327
+ errors.push("a JSON-LD script block is not valid JSON");
328
+ }
329
+ }
330
+ findings.push(
331
+ errors.length > 0 ? {
332
+ code: "jsonld.invalid",
333
+ level: "error",
334
+ title: "JSON-LD has errors",
335
+ detail: errors.join("; ")
336
+ } : {
337
+ code: "jsonld.present",
338
+ level: "pass",
339
+ title: "JSON-LD structured data present",
340
+ detail: `${blocks.length} JSON-LD block(s) found and structurally valid.`
341
+ }
342
+ );
343
+ }
344
+ }
345
+ return findings;
346
+ }
347
+
348
+ // src/findings.ts
349
+ function worstLevel(findings) {
350
+ if (findings.some((f) => f.level === "error")) return "error";
351
+ if (findings.some((f) => f.level === "warn")) return "warn";
352
+ return "pass";
353
+ }
354
+ function countByLevel(findings) {
355
+ return findings.reduce(
356
+ (acc, f) => {
357
+ acc[f.level] += 1;
358
+ return acc;
359
+ },
360
+ { pass: 0, warn: 0, error: 0 }
361
+ );
362
+ }
363
+
364
+ // src/net.ts
365
+ var DEFAULT_TIMEOUT_MS = 1e4;
366
+ var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
367
+ var MAX_REDIRECTS = 5;
368
+ function parseIpv4(value) {
369
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
370
+ if (!match) return null;
371
+ const octets = match.slice(1).map(Number);
372
+ return octets.some((octet) => octet > 255) ? null : octets;
373
+ }
374
+ function isBlockedIpv4(octets) {
375
+ const [first, second] = octets;
376
+ return first === 0 || first === 10 || first === 127 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
377
+ }
378
+ function parseIpv6(value) {
379
+ if (!value.includes(":")) return null;
380
+ let head = value;
381
+ const embedded = [];
382
+ const lastColon = value.lastIndexOf(":");
383
+ const suffix = value.slice(lastColon + 1);
384
+ if (suffix.includes(".")) {
385
+ const v4 = parseIpv4(suffix);
386
+ if (!v4) return null;
387
+ embedded.push(v4[0] << 8 | v4[1], v4[2] << 8 | v4[3]);
388
+ head = value.slice(0, lastColon);
389
+ }
390
+ const halves = head.split("::");
391
+ if (halves.length > 2) return null;
392
+ const parseGroups = (part) => part === "" ? [] : part.split(":").map(
393
+ (group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : NaN
394
+ );
395
+ const left = parseGroups(halves[0]);
396
+ const right = halves.length === 2 ? parseGroups(halves[1]) : null;
397
+ let groups;
398
+ if (right === null) {
399
+ groups = [...left, ...embedded];
400
+ } else {
401
+ const known = left.length + right.length + embedded.length;
402
+ const missing = 8 - known;
403
+ if (missing < 1) return null;
404
+ groups = [...left, ...Array(missing).fill(0), ...right, ...embedded];
405
+ }
406
+ if (groups.length !== 8 || groups.some((group) => Number.isNaN(group))) {
407
+ return null;
408
+ }
409
+ return groups;
410
+ }
411
+ function isBlockedIpv6(groups) {
412
+ const [first] = groups;
413
+ if (groups.every((group) => group === 0)) return true;
414
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) {
415
+ return true;
416
+ }
417
+ const mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 65535;
418
+ const compatible = groups.slice(0, 6).every((group) => group === 0);
419
+ if (mapped || compatible) {
420
+ return isBlockedIpv4([
421
+ groups[6] >> 8,
422
+ groups[6] & 255,
423
+ groups[7] >> 8,
424
+ groups[7] & 255
425
+ ]);
426
+ }
427
+ return (first & 65024) === 64512 || (first & 65472) === 65152 || (first & 65472) === 65216;
428
+ }
429
+ function isBlockedHostname(hostname) {
430
+ const lower = hostname.toLowerCase();
431
+ if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) {
432
+ return true;
433
+ }
434
+ const ipv4 = parseIpv4(lower);
435
+ if (ipv4) return isBlockedIpv4(ipv4);
436
+ const ipv6 = parseIpv6(lower.replace(/^\[|\]$/g, ""));
437
+ if (ipv6) return isBlockedIpv6(ipv6);
438
+ return false;
439
+ }
440
+ function isFetchableUrl(url) {
441
+ return (url.protocol === "http:" || url.protocol === "https:") && !isBlockedHostname(url.hostname);
442
+ }
443
+ async function readBounded(response, maxBytes) {
444
+ if (!response.body) {
445
+ const text = await response.text();
446
+ return { text: text.slice(0, maxBytes), bytes: Buffer.byteLength(text) };
447
+ }
448
+ const reader = response.body.getReader();
449
+ const chunks = [];
450
+ let total = 0;
451
+ let kept = 0;
452
+ for (; ; ) {
453
+ const { done, value } = await reader.read();
454
+ if (done) break;
455
+ total += value.byteLength;
456
+ if (kept < maxBytes) {
457
+ const room = maxBytes - kept;
458
+ const slice = value.byteLength <= room ? value : value.slice(0, room);
459
+ chunks.push(slice);
460
+ kept += slice.byteLength;
461
+ }
462
+ if (total >= maxBytes) {
463
+ await reader.cancel().catch(() => void 0);
464
+ break;
465
+ }
466
+ }
467
+ const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
468
+ return { text: buffer.toString("utf8"), bytes: total };
469
+ }
470
+ async function safeFetch(targetUrl, options) {
471
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
472
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
473
+ const readBody = options.readBody ?? true;
474
+ const base = {
475
+ requestedUrl: targetUrl,
476
+ finalUrl: targetUrl,
477
+ status: null,
478
+ ok: false,
479
+ headers: {},
480
+ body: null,
481
+ bodyBytes: 0,
482
+ redirects: 0,
483
+ blocked: false
484
+ };
485
+ let currentUrl;
486
+ try {
487
+ const parsed = new URL(targetUrl);
488
+ if (!isFetchableUrl(parsed)) {
489
+ return { ...base, blocked: true, error: "blocked-by-ssrf-rules" };
490
+ }
491
+ currentUrl = parsed.toString();
492
+ } catch {
493
+ return { ...base, error: "invalid-url" };
494
+ }
495
+ for (let hop = 0; hop < MAX_REDIRECTS; hop += 1) {
496
+ const controller = new AbortController();
497
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
498
+ try {
499
+ const response = await fetch(currentUrl, {
500
+ redirect: "manual",
501
+ signal: controller.signal,
502
+ headers: {
503
+ "user-agent": options.userAgent,
504
+ accept: "text/html,text/plain,application/xml,application/json;q=0.9,*/*;q=0.1"
505
+ }
506
+ });
507
+ const headers = {};
508
+ response.headers.forEach((value, key) => {
509
+ headers[key.toLowerCase()] = value;
510
+ });
511
+ if (response.status >= 300 && response.status < 400) {
512
+ const location = response.headers.get("location");
513
+ if (!location) {
514
+ return {
515
+ ...base,
516
+ finalUrl: currentUrl,
517
+ status: response.status,
518
+ headers,
519
+ redirects: hop,
520
+ error: "redirect-without-location"
521
+ };
522
+ }
523
+ let nextUrl;
524
+ try {
525
+ nextUrl = new URL(location, currentUrl);
526
+ } catch {
527
+ return {
528
+ ...base,
529
+ finalUrl: currentUrl,
530
+ status: response.status,
531
+ headers,
532
+ redirects: hop,
533
+ error: "invalid-redirect-target"
534
+ };
535
+ }
536
+ if (!isFetchableUrl(nextUrl)) {
537
+ return {
538
+ ...base,
539
+ finalUrl: nextUrl.toString(),
540
+ status: response.status,
541
+ headers,
542
+ redirects: hop,
543
+ blocked: true,
544
+ error: "blocked-by-ssrf-rules"
545
+ };
546
+ }
547
+ await response.body?.cancel().catch(() => void 0);
548
+ currentUrl = nextUrl.toString();
549
+ continue;
550
+ }
551
+ let body = null;
552
+ let bodyBytes = 0;
553
+ if (readBody) {
554
+ const read = await readBounded(response, maxBytes);
555
+ body = read.text;
556
+ bodyBytes = read.bytes;
557
+ } else {
558
+ await response.body?.cancel().catch(() => void 0);
559
+ }
560
+ return {
561
+ requestedUrl: targetUrl,
562
+ finalUrl: currentUrl,
563
+ status: response.status,
564
+ ok: response.ok,
565
+ headers,
566
+ body,
567
+ bodyBytes,
568
+ redirects: hop,
569
+ blocked: false
570
+ };
571
+ } catch (error) {
572
+ const aborted = error instanceof Error && error.name === "AbortError";
573
+ return {
574
+ ...base,
575
+ finalUrl: currentUrl,
576
+ redirects: hop,
577
+ error: aborted ? "timeout" : "network-error"
578
+ };
579
+ } finally {
580
+ clearTimeout(timer);
581
+ }
582
+ }
583
+ return { ...base, finalUrl: currentUrl, error: "too-many-redirects" };
584
+ }
585
+
586
+ // src/audit.ts
587
+ function originOf(url) {
588
+ try {
589
+ return new URL(url).origin;
590
+ } catch {
591
+ return url.replace(/\/+$/, "");
592
+ }
593
+ }
594
+ async function audit(targetUrl, options) {
595
+ const doFetch = options.fetchImpl ?? safeFetch;
596
+ const timeoutMs = options.timeoutMs;
597
+ const origin = originOf(targetUrl);
598
+ const control = await doFetch(targetUrl, {
599
+ userAgent: BROWSER_CONTROL.ua,
600
+ timeoutMs,
601
+ readBody: true
602
+ });
603
+ const probes = [];
604
+ for (const agent of CRAWLER_AGENTS) {
605
+ const result = await doFetch(targetUrl, {
606
+ userAgent: agent.ua,
607
+ timeoutMs,
608
+ readBody: true,
609
+ maxBytes: 64 * 1024
610
+ });
611
+ probes.push({ agent, result });
612
+ }
613
+ const robots = await doFetch(`${origin}/robots.txt`, {
614
+ userAgent: BROWSER_CONTROL.ua,
615
+ timeoutMs,
616
+ readBody: true,
617
+ maxBytes: 256 * 1024
618
+ });
619
+ const llms = await doFetch(`${origin}/llms.txt`, {
620
+ userAgent: BROWSER_CONTROL.ua,
621
+ timeoutMs,
622
+ readBody: true,
623
+ maxBytes: 1024 * 1024
624
+ });
625
+ const findings = [
626
+ ...analyzeCrawlerAccess(control, probes),
627
+ ...analyzeJsDependence(control),
628
+ ...analyzeRobots(robots),
629
+ ...analyzeMachineReadable(control, llms)
630
+ ];
631
+ const counts = countByLevel(findings);
632
+ const passed = counts.pass;
633
+ const checks = findings.length;
634
+ return {
635
+ url: targetUrl,
636
+ finalUrl: control.finalUrl,
637
+ fetchedAt: options.fetchedAt,
638
+ findings,
639
+ summary: {
640
+ ...counts,
641
+ checks,
642
+ passed,
643
+ worst: worstLevel(findings)
644
+ }
645
+ };
646
+ }
647
+
648
+ // src/report.ts
649
+ var RESET = "\x1B[0m";
650
+ var GREEN = "\x1B[32m";
651
+ var YELLOW = "\x1B[33m";
652
+ var RED = "\x1B[31m";
653
+ var BOLD = "\x1B[1m";
654
+ var DIM = "\x1B[2m";
655
+ var GLYPH = {
656
+ pass: `${GREEN}\u2713${RESET}`,
657
+ warn: `${YELLOW}\u26A0${RESET}`,
658
+ error: `${RED}\u2717${RESET}`
659
+ };
660
+ var ORDER = { error: 0, warn: 1, pass: 2 };
661
+ function renderText(report) {
662
+ const lines = [];
663
+ lines.push("");
664
+ lines.push(`${BOLD}agentmarkup audit${RESET} ${DIM}${report.url}${RESET}`);
665
+ lines.push("");
666
+ const sorted = [...report.findings].sort(
667
+ (a, b) => ORDER[a.level] - ORDER[b.level]
668
+ );
669
+ for (const f of sorted) {
670
+ lines.push(` ${GLYPH[f.level]} ${f.title}`);
671
+ lines.push(` ${DIM}${f.detail}${RESET}`);
672
+ if (f.evidence) lines.push(` ${DIM}evidence: ${f.evidence}${RESET}`);
673
+ if (f.fix) lines.push(` ${DIM}fix: ${f.fix}${RESET}`);
674
+ lines.push("");
675
+ }
676
+ const { passed, checks, error, warn } = report.summary;
677
+ const headline = error > 0 ? `${RED}${error} error(s)${RESET}, ${warn} warning(s)` : warn > 0 ? `${YELLOW}${warn} warning(s)${RESET}` : `${GREEN}all clear${RESET}`;
678
+ lines.push(` ${BOLD}${passed}/${checks} checks passed${RESET} \u2014 ${headline}`);
679
+ lines.push("");
680
+ return lines.join("\n");
681
+ }
682
+ function renderJson(report) {
683
+ return JSON.stringify(report, null, 2);
684
+ }
685
+
686
+ // src/cli.ts
687
+ var HELP = `agentmarkup audit \u2014 see a URL the way AI crawlers do
688
+
689
+ Usage:
690
+ agentmarkup-audit <url> [options]
691
+
692
+ Options:
693
+ --json Output the full report as JSON (for CI / league tables)
694
+ --timeout <ms> Per-request timeout in milliseconds (default 10000)
695
+ --version Print version
696
+ --help Show this help
697
+
698
+ Exit codes:
699
+ 0 no error-level findings
700
+ 1 at least one error-level finding (CI gate)
701
+ 2 usage error
702
+ `;
703
+ function normalizeUrl(input) {
704
+ if (/^https?:\/\//i.test(input)) return input;
705
+ return `https://${input}`;
706
+ }
707
+ async function run(argv, ctx) {
708
+ const out = ctx.stdout ?? ((t) => process.stdout.write(t));
709
+ const err = ctx.stderr ?? ((t) => process.stderr.write(t));
710
+ if (argv.includes("--help") || argv.includes("-h")) {
711
+ out(HELP);
712
+ return 0;
713
+ }
714
+ if (argv.includes("--version")) {
715
+ out(`${ctx.version}
716
+ `);
717
+ return 0;
718
+ }
719
+ const json = argv.includes("--json");
720
+ let timeoutMs;
721
+ const timeoutIdx = argv.indexOf("--timeout");
722
+ if (timeoutIdx !== -1) {
723
+ const raw = Number(argv[timeoutIdx + 1]);
724
+ if (!Number.isFinite(raw) || raw <= 0) {
725
+ err("agentmarkup-audit: --timeout expects a positive number of milliseconds\n");
726
+ return 2;
727
+ }
728
+ timeoutMs = raw;
729
+ }
730
+ const positional = argv.filter(
731
+ (arg, i) => !arg.startsWith("-") && argv[i - 1] !== "--timeout"
732
+ );
733
+ const target = positional[0];
734
+ if (!target) {
735
+ err("agentmarkup-audit: missing <url>\n\n");
736
+ err(HELP);
737
+ return 2;
738
+ }
739
+ const url = normalizeUrl(target);
740
+ const fetchedAt = (ctx.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
741
+ const report = await audit(url, { timeoutMs, fetchedAt });
742
+ out(json ? `${renderJson(report)}
743
+ ` : renderText(report));
744
+ return report.summary.worst === "error" ? 1 : 0;
745
+ }
746
+
747
+ export {
748
+ BROWSER_CONTROL,
749
+ CRAWLER_AGENTS,
750
+ ALL_AGENTS,
751
+ analyzeCrawlerAccess,
752
+ analyzeJsDependence,
753
+ analyzeRobots,
754
+ analyzeMachineReadable,
755
+ worstLevel,
756
+ countByLevel,
757
+ parseIpv4,
758
+ parseIpv6,
759
+ isBlockedHostname,
760
+ safeFetch,
761
+ audit,
762
+ renderText,
763
+ renderJson,
764
+ run
765
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * A single audit finding. Deterministic pass/warn/error — no scores, matching
3
+ * the repo's checker convention. `evidence` carries the raw observation so the
4
+ * report never asserts more than it saw (see the crawler-access classifier).
5
+ */
6
+ type AuditLevel = 'pass' | 'warn' | 'error';
7
+ interface AuditFinding {
8
+ /** Stable machine-readable code, e.g. `crawler.ua-waf-block`. */
9
+ code: string;
10
+ level: AuditLevel;
11
+ /** One-line summary. */
12
+ title: string;
13
+ /** Human explanation of what was checked and what it means. */
14
+ detail: string;
15
+ /** Raw evidence for the finding (status codes, headers), when applicable. */
16
+ evidence?: string;
17
+ /** Concrete next step, e.g. an agentmarkup config snippet or CDN setting. */
18
+ fix?: string;
19
+ }
20
+ declare function worstLevel(findings: AuditFinding[]): AuditLevel;
21
+ declare function countByLevel(findings: AuditFinding[]): Record<AuditLevel, number>;
22
+
23
+ /**
24
+ * SSRF-safe fetch used by every audit probe. The hostname blocklist mirrors the
25
+ * hardened checker worker (`website/public/_worker.js`) — evaluated as numeric
26
+ * IPs so IPv4-mapped IPv6, `::`, `fe80::/10`, and CGNAT cannot slip through.
27
+ *
28
+ * The audit CLI runs on the user's own machine auditing their own site, so the
29
+ * SSRF surface is lower than the hosted checker, but the same guard keeps the
30
+ * probe honest and lets this module be reused by a future hosted audit.
31
+ */
32
+ interface FetchOptions {
33
+ userAgent: string;
34
+ timeoutMs?: number;
35
+ maxBytes?: number;
36
+ /** Read and return the response body (default true). */
37
+ readBody?: boolean;
38
+ }
39
+ interface FetchResult {
40
+ requestedUrl: string;
41
+ finalUrl: string;
42
+ status: number | null;
43
+ ok: boolean;
44
+ headers: Record<string, string>;
45
+ body: string | null;
46
+ bodyBytes: number;
47
+ redirects: number;
48
+ blocked: boolean;
49
+ error?: string;
50
+ }
51
+ declare function parseIpv4(value: string): number[] | null;
52
+ declare function parseIpv6(value: string): number[] | null;
53
+ declare function isBlockedHostname(hostname: string): boolean;
54
+ /** Fetch a URL with a spoofed user-agent, SSRF-safe manual redirects, timeout, and a size bound. */
55
+ declare function safeFetch(targetUrl: string, options: FetchOptions): Promise<FetchResult>;
56
+
57
+ interface AuditOptions {
58
+ timeoutMs?: number;
59
+ /** Injected for tests; defaults to the real SSRF-safe fetch. */
60
+ fetchImpl?: typeof safeFetch;
61
+ }
62
+ interface AuditReport {
63
+ url: string;
64
+ finalUrl: string;
65
+ fetchedAt: string;
66
+ findings: AuditFinding[];
67
+ summary: {
68
+ pass: number;
69
+ warn: number;
70
+ error: number;
71
+ checks: number;
72
+ passed: number;
73
+ worst: 'pass' | 'warn' | 'error';
74
+ };
75
+ }
76
+ /**
77
+ * Audit a live URL. Fetches the page as a browser control and as each AI
78
+ * crawler user-agent, plus robots.txt and llms.txt, then runs the analyzers.
79
+ * `fetchedAt` is injected by the caller so the core stays deterministic/testable.
80
+ */
81
+ declare function audit(targetUrl: string, options: AuditOptions & {
82
+ fetchedAt: string;
83
+ }): Promise<AuditReport>;
84
+
85
+ interface RunContext {
86
+ version: string;
87
+ now?: () => string;
88
+ stdout?: (text: string) => void;
89
+ stderr?: (text: string) => void;
90
+ }
91
+ declare function run(argv: string[], ctx: RunContext): Promise<number>;
92
+
93
+ declare function renderText(report: AuditReport): string;
94
+ declare function renderJson(report: AuditReport): string;
95
+
96
+ /**
97
+ * The crawler user-agents the audit fetches as, plus the control browser.
98
+ * `verification` records how the real bot proves identity — this is what lets
99
+ * the crawler-access classifier avoid false positives: a 403 for a spoofed UA
100
+ * whose real bot verifies by IP range may be intentional, not a block bug.
101
+ */
102
+ type Verification = 'ip-range' | 'reverse-dns' | 'ua-only';
103
+ interface CrawlerAgent {
104
+ /** Short id used in findings and --json output. */
105
+ id: string;
106
+ /** The User-Agent string sent. */
107
+ ua: string;
108
+ vendor: string;
109
+ /** Whether this agent is the browser control rather than a crawler. */
110
+ control?: boolean;
111
+ verification?: Verification;
112
+ intent?: 'training' | 'search' | 'user-fetch';
113
+ docsUrl?: string;
114
+ }
115
+ declare const BROWSER_CONTROL: CrawlerAgent;
116
+ declare const CRAWLER_AGENTS: CrawlerAgent[];
117
+ declare const ALL_AGENTS: CrawlerAgent[];
118
+
119
+ interface AgentProbe {
120
+ agent: CrawlerAgent;
121
+ result: FetchResult;
122
+ }
123
+ /**
124
+ * Diffs each crawler user-agent's response against the browser control and
125
+ * classifies the difference. Every finding states the evidence and never
126
+ * asserts "your site blocks AI" from a user-agent-only 403 (see plan §6):
127
+ * a 403 for a spoofed UA can mean an intentional IP-verification policy, not a
128
+ * block bug, so those are surfaced as warnings, not errors.
129
+ */
130
+ declare function analyzeCrawlerAccess(control: FetchResult, probes: AgentProbe[]): AuditFinding[];
131
+
132
+ /** Flags pages whose raw HTML has no meaningful content — invisible to crawlers that do not run JS. */
133
+ declare function analyzeJsDependence(control: FetchResult): AuditFinding[];
134
+ /** robots.txt intent: are the crawlers we expect to allow actually blocked? */
135
+ declare function analyzeRobots(robots: FetchResult): AuditFinding[];
136
+ /** Machine-readability surface on the homepage HTML plus a fetched llms.txt. */
137
+ declare function analyzeMachineReadable(control: FetchResult, llms: FetchResult): AuditFinding[];
138
+
139
+ export { ALL_AGENTS, type AgentProbe, type AuditFinding, type AuditLevel, type AuditOptions, type AuditReport, BROWSER_CONTROL, CRAWLER_AGENTS, type CrawlerAgent, type FetchOptions, type FetchResult, type RunContext, analyzeCrawlerAccess, analyzeJsDependence, analyzeMachineReadable, analyzeRobots, audit, countByLevel, isBlockedHostname, parseIpv4, parseIpv6, renderJson, renderText, run, safeFetch, worstLevel };
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import {
2
+ ALL_AGENTS,
3
+ BROWSER_CONTROL,
4
+ CRAWLER_AGENTS,
5
+ analyzeCrawlerAccess,
6
+ analyzeJsDependence,
7
+ analyzeMachineReadable,
8
+ analyzeRobots,
9
+ audit,
10
+ countByLevel,
11
+ isBlockedHostname,
12
+ parseIpv4,
13
+ parseIpv6,
14
+ renderJson,
15
+ renderText,
16
+ run,
17
+ safeFetch,
18
+ worstLevel
19
+ } from "./chunk-PNE6FBX2.js";
20
+ export {
21
+ ALL_AGENTS,
22
+ BROWSER_CONTROL,
23
+ CRAWLER_AGENTS,
24
+ analyzeCrawlerAccess,
25
+ analyzeJsDependence,
26
+ analyzeMachineReadable,
27
+ analyzeRobots,
28
+ audit,
29
+ countByLevel,
30
+ isBlockedHostname,
31
+ parseIpv4,
32
+ parseIpv6,
33
+ renderJson,
34
+ renderText,
35
+ run,
36
+ safeFetch,
37
+ worstLevel
38
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@agentmarkup/audit",
3
+ "version": "0.1.0",
4
+ "description": "Audit a live URL the way AI crawlers see it: fetch as GPTBot, ClaudeBot, PerplexityBot and more, diff against a browser to catch accidental CDN blocks, plus llms.txt, JSON-LD, robots.txt intent, Content-Signal, and JS-dependence checks",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Sebastian Cochinescu <hello@animafelix.com> (https://animafelix.com)",
8
+ "homepage": "https://agentmarkup.dev",
9
+ "bugs": {
10
+ "url": "https://github.com/agentmarkup/agentmarkup/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/agentmarkup/agentmarkup",
15
+ "directory": "packages/audit"
16
+ },
17
+ "keywords": [
18
+ "ai-crawler",
19
+ "gptbot",
20
+ "claudebot",
21
+ "perplexitybot",
22
+ "llms-txt",
23
+ "robots-txt",
24
+ "content-signal",
25
+ "json-ld",
26
+ "geo",
27
+ "aeo",
28
+ "seo",
29
+ "audit",
30
+ "machine-readable",
31
+ "ci"
32
+ ],
33
+ "bin": {
34
+ "agentmarkup-audit": "./dist/bin.js"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "import": "./dist/index.js",
39
+ "types": "./dist/index.d.ts"
40
+ }
41
+ },
42
+ "main": "./dist/index.js",
43
+ "types": "./dist/index.d.ts",
44
+ "files": [
45
+ "dist"
46
+ ],
47
+ "dependencies": {
48
+ "@agentmarkup/core": "0.5.2"
49
+ },
50
+ "devDependencies": {
51
+ "eslint": "^9.0.0",
52
+ "tsup": "^8.4.0",
53
+ "typescript": "^5.7.0",
54
+ "vitest": "^3.0.0"
55
+ },
56
+ "scripts": {
57
+ "prebuild": "pnpm -C ../core build",
58
+ "build": "tsup",
59
+ "predev": "pnpm -C ../core build",
60
+ "dev": "tsup --watch",
61
+ "pretest": "pnpm -C ../core build",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "lint": "eslint src/ test/",
65
+ "pretypecheck": "pnpm -C ../core build",
66
+ "typecheck": "tsc --noEmit"
67
+ }
68
+ }