pagegraph 0.6.0 → 0.7.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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import * as NFS from "node:fs";
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { spawn } from "node:child_process";
6
6
  import { PassThrough } from "node:stream";
@@ -20616,7 +20616,7 @@ const Boolean$5 = class extends ASTNodeImpl {
20616
20616
  * @category constructors
20617
20617
  * @since 4.0.0
20618
20618
  */
20619
- const boolean = /*#__PURE__*/ new Boolean$5();
20619
+ const boolean$1 = /*#__PURE__*/ new Boolean$5();
20620
20620
  /**
20621
20621
  * Constructs a {@link BigInt}.
20622
20622
  *
@@ -25834,7 +25834,7 @@ const Number$2 = /*#__PURE__*/ make$14(number);
25834
25834
  * @category schemas
25835
25835
  * @since 4.0.0
25836
25836
  */
25837
- const Boolean$4 = /*#__PURE__*/ make$14(boolean);
25837
+ const Boolean$4 = /*#__PURE__*/ make$14(boolean$1);
25838
25838
  /**
25839
25839
  * Schema for `bigint` values. Validates that the input is `typeof` `"bigint"`.
25840
25840
  *
@@ -26424,6 +26424,28 @@ const makeFilter = makeFilter$1;
26424
26424
  */
26425
26425
  const Finite$3 = /*#__PURE__*/ make$14(finite);
26426
26426
  /**
26427
+ * Creates a greater-than-or-equal-to (`>=`) check for any ordered type from an
26428
+ * `Order.Order` instance.
26429
+ *
26430
+ * @category validation
26431
+ * @since 4.0.0
26432
+ */
26433
+ function makeIsGreaterThanOrEqualTo(options) {
26434
+ const gte = isGreaterThanOrEqualTo$1(options.order);
26435
+ const formatter = options.formatter ?? format$2;
26436
+ return (minimum, annotations) => {
26437
+ return makeFilter((input) => gte(input, minimum), {
26438
+ expected: `a value greater than or equal to ${formatter(minimum)}`,
26439
+ arbitraryConstraint: {
26440
+ order: options.order,
26441
+ minimum
26442
+ },
26443
+ ...options.annotate?.(minimum),
26444
+ ...annotations
26445
+ });
26446
+ };
26447
+ }
26448
+ /**
26427
26449
  * Creates an inclusive or exclusive range check for any ordered type from an
26428
26450
  * `Order.Order` instance.
26429
26451
  *
@@ -26458,6 +26480,35 @@ function encodeNumberPayload(number) {
26458
26480
  return number;
26459
26481
  }
26460
26482
  /**
26483
+ * Validates that a number is greater than or equal to the specified value
26484
+ * (inclusive).
26485
+ *
26486
+ * **Details**
26487
+ *
26488
+ * JSON Schema:
26489
+ *
26490
+ * This check corresponds to the `minimum` constraint in JSON Schema.
26491
+ *
26492
+ * Arbitrary:
26493
+ *
26494
+ * During arbitrary generation, this applies a `minimum` constraint
26495
+ * to ensure generated numbers are greater than or equal to the specified value.
26496
+ *
26497
+ * @category validation
26498
+ * @since 4.0.0
26499
+ */
26500
+ const isGreaterThanOrEqualTo = /*#__PURE__*/ makeIsGreaterThanOrEqualTo({
26501
+ order: Number$6,
26502
+ annotate: (minimum) => ({
26503
+ representation: {
26504
+ id: "effect/schema/isGreaterThanOrEqualTo",
26505
+ payload: { minimum: encodeNumberPayload(minimum) }
26506
+ },
26507
+ toJsonSchema: () => ({ minimum }),
26508
+ toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualTo(${format$2(minimum)})` })
26509
+ })
26510
+ });
26511
+ /**
26461
26512
  * Validates that a number is within a specified range. The range boundaries can
26462
26513
  * be inclusive or exclusive based on the provided options.
26463
26514
  *
@@ -26538,6 +26589,19 @@ function isInt(annotations) {
26538
26589
  * @since 3.10.0
26539
26590
  */
26540
26591
  const Int$4 = /*#__PURE__*/ Number$2.check(/*#__PURE__*/ isInt());
26592
+ /**
26593
+ * Schema for non-negative safe integers, including zero.
26594
+ *
26595
+ * **When to use**
26596
+ *
26597
+ * Use when you need a count, index, or size that cannot be negative.
26598
+ *
26599
+ * @see {@link Int} for safe integers that may be negative
26600
+ *
26601
+ * @category schemas
26602
+ * @since 4.0.0
26603
+ */
26604
+ const Natural = /*#__PURE__*/ Int$4.check(/*#__PURE__*/ isGreaterThanOrEqualTo(0));
26541
26605
  const getErrorOptionsKey = (options) => (options?.includeStack === true ? 1 : 0) | (options?.excludeCause === true ? 2 : 0);
26542
26606
  const getErrorOptions = (key) => {
26543
26607
  switch (key) {
@@ -34612,7 +34676,7 @@ const runWith = (command, config) => {
34612
34676
  };
34613
34677
  //#endregion
34614
34678
  //#region package.json
34615
- var version = "0.6.0";
34679
+ var version = "0.7.0";
34616
34680
  //#endregion
34617
34681
  //#region src/core/projections.ts
34618
34682
  const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -34928,19 +34992,17 @@ const globToRegExp = (glob) => {
34928
34992
  return new RegExp(`^${source}$`);
34929
34993
  };
34930
34994
  /**
34931
- * Enforce contextual-link coverage rules: a named set of sitemap-eligible
34932
- * "money" pages each needs `minInbound` incoming `related` edges. Only
34933
- * `related` edges count — breadcrumb ancestry is navigation, not context.
34995
+ * Enforce contextual-link coverage rules over an explicit surface.
34934
34996
  *
34935
34997
  * A rule that matches no sitemap-eligible page is itself a violation: a typo or
34936
34998
  * a rule aimed at a noindex page would otherwise pass silently forever.
34937
34999
  */
34938
- function checkCoverage(graph, rules) {
35000
+ const checkCoverageRules = (surface, rules) => {
34939
35001
  const violations = [];
34940
35002
  for (const rule of rules) {
34941
35003
  const matcher = globToRegExp(rule.path);
34942
- const matched = [...graph.nodes.values()].filter((node) => matcher.test(node.path));
34943
- const eligible = matched.filter(isSitemapEligible);
35004
+ const matched = surface.paths.filter((path) => matcher.test(path));
35005
+ const eligible = surface.eligiblePaths.filter((path) => matcher.test(path));
34944
35006
  if (eligible.length === 0) {
34945
35007
  violations.push({
34946
35008
  severity: "structural",
@@ -34950,19 +35012,55 @@ function checkCoverage(graph, rules) {
34950
35012
  });
34951
35013
  continue;
34952
35014
  }
34953
- for (const node of eligible) {
34954
- const inbound = graph.edges.filter((edge) => edge.type === "related" && edge.to === node.path).length;
35015
+ for (const path of eligible) {
35016
+ const inbound = surface.inbound(path);
34955
35017
  if (inbound >= rule.minInbound) continue;
34956
35018
  violations.push({
34957
35019
  severity: "structural",
34958
35020
  rule: "inbound-link-coverage",
34959
- path: node.path,
34960
- message: `"${node.path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
34961
- fix: `Add ${rule.minInbound - inbound} contextual (related) edge(s) pointing at "${node.path}".`
35021
+ path,
35022
+ message: `"${path}" has ${inbound} incoming contextual link(s); the coverage rule requires ${rule.minInbound}.`,
35023
+ fix: `Add ${rule.minInbound - inbound} contextual link(s) pointing at "${path}".`
34962
35024
  });
34963
35025
  }
34964
35026
  }
34965
35027
  return violations;
35028
+ };
35029
+ /** The sitemap-eligible path set of a declared graph, in graph insertion order. */
35030
+ const eligiblePathsOf = (graph) => [...graph.nodes.values()].filter(isSitemapEligible).map((node) => node.path);
35031
+ /**
35032
+ * Enforce contextual-link coverage against the *declared* graph: only `related`
35033
+ * edges count — breadcrumb ancestry is navigation, not context.
35034
+ */
35035
+ function checkCoverage(graph, rules) {
35036
+ return checkCoverageRules({
35037
+ paths: [...graph.nodes.keys()],
35038
+ eligiblePaths: eligiblePathsOf(graph),
35039
+ inbound: (path) => graph.edges.filter((edge) => edge.type === "related" && edge.to === path).length
35040
+ }, rules);
35041
+ }
35042
+ /**
35043
+ * Enforce contextual-link coverage against the *rendered* graph: only same-origin
35044
+ * body-region anchors count. Nav, header, and footer anchors are site chrome, so
35045
+ * a page linked solely from them has no contextual inbound link — the same
35046
+ * distinction the declared pass draws by counting `related` edges alone.
35047
+ *
35048
+ * The target universe is the declared graph's sitemap-eligible pages: the globs
35049
+ * in `seo.config.ts` are written against declared paths, and the crawl is what
35050
+ * supplies the served edges. Pass the crawl's `internalEdges` (each edge carries
35051
+ * its `region`).
35052
+ */
35053
+ function checkRenderedCoverage(graph, renderedEdges, rules) {
35054
+ const inbound = /* @__PURE__ */ new Map();
35055
+ for (const edge of renderedEdges) {
35056
+ if (edge.region !== "body") continue;
35057
+ inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
35058
+ }
35059
+ return checkCoverageRules({
35060
+ paths: [...graph.nodes.keys()],
35061
+ eligiblePaths: eligiblePathsOf(graph),
35062
+ inbound: (path) => inbound.get(path) ?? 0
35063
+ }, rules);
34966
35064
  }
34967
35065
  /** Structural violations fail `pagegraph check`; editorial-only stays green. */
34968
35066
  const hasStructuralViolations = (violations) => violations.some((violation) => violation.severity === "structural");
@@ -35017,7 +35115,7 @@ const CONFIG_FILENAMES = [
35017
35115
  "seo.config.js",
35018
35116
  "seo.config.mjs"
35019
35117
  ];
35020
- const messageOf$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
35118
+ const messageOf$2 = (cause) => cause instanceof Error ? cause.message : String(cause);
35021
35119
  /** First `seo.config.*` at or above `from`, or undefined at the filesystem root. */
35022
35120
  const findConfigFile = (from) => {
35023
35121
  let directory = resolve(from);
@@ -35045,7 +35143,7 @@ const loadConfigFile = (configPath) => gen(function* () {
35045
35143
  yield* logDebug(`Loading SEO config from ${configPath}`);
35046
35144
  const module = yield* tryPromise({
35047
35145
  try: () => import(pathToFileURL(configPath).href),
35048
- catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$1(cause)}` })
35146
+ catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$2(cause)}` })
35049
35147
  });
35050
35148
  if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, disallow, loadGraph }).` });
35051
35149
  return module.default;
@@ -35080,8 +35178,8 @@ const acquireGraph = (config) => gen(function* () {
35080
35178
  yield* logDebug("Loading the SEO graph…");
35081
35179
  const loaded = yield* acquireRelease(tryPromise({
35082
35180
  try: () => config.loadGraph(),
35083
- catch: (cause) => new SeoCliError({ message: messageOf$1(cause) })
35084
- }), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$1(defect)}`))));
35181
+ catch: (cause) => new SeoCliError({ message: messageOf$2(cause) })
35182
+ }), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$2(defect)}`))));
35085
35183
  yield* logDebug(`Loaded SEO graph: ${loaded.graph.nodes.size} nodes, ${loaded.graph.edges.length} edges.`);
35086
35184
  return loaded.graph;
35087
35185
  });
@@ -35289,6 +35387,17 @@ const renderLinksReport = (report) => {
35289
35387
  lines.push(` rendered not declared ${report.declared.renderedNotDeclared.length}`);
35290
35388
  for (const edge of report.declared.renderedNotDeclared) lines.push(edgeLine(edge));
35291
35389
  }
35390
+ if (report.coverage !== void 0) {
35391
+ const { coverage } = report;
35392
+ lines.push("", `Rendered coverage (${coverage.rules} rule(s)):`);
35393
+ if (coverage.ok) lines.push(" ✓ every coverage rule is satisfied by the served anchors.");
35394
+ else for (const violation of coverage.violations) {
35395
+ const where = violation.path ? ` ${violation.path}` : "";
35396
+ lines.push(` ✗ [${violation.rule}]${where}`);
35397
+ lines.push(` ${violation.message}`);
35398
+ if (violation.fix) lines.push(` fix: ${violation.fix}`);
35399
+ }
35400
+ }
35292
35401
  if (crawl.failures.length > 0) {
35293
35402
  lines.push("", `${crawl.failures.length} page(s) could not be fetched:`);
35294
35403
  for (const failure of crawl.failures) lines.push(` ${failure.url} ${failure.error}`);
@@ -35306,8 +35415,8 @@ const VERDICT_LABELS = {
35306
35415
  review: "Review"
35307
35416
  };
35308
35417
  const decisionLine = (record) => [
35309
- ` ${record.sourceUrl} → ${record.destinationUrl}`,
35310
- ` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)}`,
35418
+ ` ${record.from} ${record.direction === "both" ? "↔" : "→"} ${record.to}`,
35419
+ ` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)} · direction ${record.direction} · relevance ${record.relevance.label}`,
35311
35420
  ` "${record.sourceText}"`
35312
35421
  ];
35313
35422
  const verdictBlock = (verdict, records) => {
@@ -35323,8 +35432,9 @@ const verdictBlock = (verdict, records) => {
35323
35432
  */
35324
35433
  const renderLinksDecideReport = (report) => {
35325
35434
  const { counts } = report;
35435
+ const budget = report.budget === null ? "none" : `${report.budget}/source`;
35326
35436
  const lines = [
35327
- `${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
35437
+ `${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)} · budget ${budget} · dropped ${report.dropped}`,
35328
35438
  "",
35329
35439
  ` recommend ${counts.recommend}`,
35330
35440
  ` present ${counts.present}`,
@@ -36214,6 +36324,30 @@ const breadthFirstDepth = (edges, root) => {
36214
36324
  return depth;
36215
36325
  };
36216
36326
  /**
36327
+ * Assemble a {@link RenderedGraph} from page/edge sets. Edges are already
36328
+ * same-origin and path-keyed by the callers, so this is the shared, pure tail of
36329
+ * {@link buildRenderedGraph} and {@link renderedGraphFromEdges}.
36330
+ */
36331
+ const graphFromEdges = (nodes, edges, root) => {
36332
+ const rootKey = root.replace(/\/+$/, "") || "/";
36333
+ const internalEdges = edges;
36334
+ const contextualEdges = edges.filter((edge) => edge.region === "body");
36335
+ const incoming = new Set(internalEdges.map((edge) => edge.to));
36336
+ const orphans = [...nodes].filter((path) => path !== rootKey && !incoming.has(path)).sort();
36337
+ const depthByPath = breadthFirstDepth(internalEdges, rootKey);
36338
+ let maxDepth = null;
36339
+ for (const value of depthByPath.values()) if (maxDepth === null || value > maxDepth) maxDepth = value;
36340
+ return {
36341
+ nodes: [...nodes],
36342
+ edges,
36343
+ internalEdges,
36344
+ contextualEdges,
36345
+ orphans,
36346
+ depthByPath,
36347
+ maxDepth
36348
+ };
36349
+ };
36350
+ /**
36217
36351
  * Build the rendered graph from crawled pages. `origin` fixes the graph's
36218
36352
  * same-origin boundary — pages and anchors on another origin are dropped, not
36219
36353
  * silently folded in — and `root` is the normalized path the depth BFS starts
@@ -36222,7 +36356,6 @@ const breadthFirstDepth = (edges, root) => {
36222
36356
  */
36223
36357
  const buildRenderedGraph = (origin, pages, root = "/") => {
36224
36358
  const graphOrigin = new URL(origin).origin;
36225
- const rootKey = root.replace(/\/+$/, "") || "/";
36226
36359
  const edges = [];
36227
36360
  const nodes = /* @__PURE__ */ new Set();
36228
36361
  for (const page of pages) {
@@ -36252,21 +36385,39 @@ const buildRenderedGraph = (origin, pages, root = "/") => {
36252
36385
  });
36253
36386
  }
36254
36387
  }
36255
- const internalEdges = edges;
36256
- const contextualEdges = edges.filter((edge) => edge.region === "body");
36257
- const incoming = new Set(internalEdges.map((edge) => edge.to));
36258
- const orphans = [...nodes].filter((path) => path !== rootKey && !incoming.has(path)).sort();
36259
- const depthByPath = breadthFirstDepth(internalEdges, rootKey);
36260
- let maxDepth = null;
36261
- for (const value of depthByPath.values()) if (maxDepth === null || value > maxDepth) maxDepth = value;
36262
- return {
36263
- edges,
36264
- internalEdges,
36265
- contextualEdges,
36266
- orphans,
36267
- depthByPath,
36268
- maxDepth
36269
- };
36388
+ return graphFromEdges(nodes, edges, root);
36389
+ };
36390
+ /** A URL or an already-normalized graph path key both collapse to the path key. */
36391
+ const pathKeyOf = (value) => {
36392
+ try {
36393
+ return normalizePath(new URL(value));
36394
+ } catch {
36395
+ return (value.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "") || "/";
36396
+ }
36397
+ };
36398
+ /**
36399
+ * Rebuild a {@link RenderedGraph} from a rendered-edge set alone — the artifact
36400
+ * `pagegraph links verify --emit-rendered` writes. Path keys are normalized the
36401
+ * same way as a crawl, and `nodes` carries the rendered page paths the artifact
36402
+ * recorded, so an edge-less page (a redirect target with no internal links) stays
36403
+ * a node and its orphan/depth report survives replay. Without `nodes` the node set
36404
+ * falls back to the edge endpoints.
36405
+ */
36406
+ const renderedGraphFromEdges = (edges, root = "/", nodes = []) => {
36407
+ const nodeSet = new Set(nodes.map(pathKeyOf));
36408
+ const kept = [];
36409
+ for (const edge of edges) {
36410
+ const from = pathKeyOf(edge.from);
36411
+ const to = pathKeyOf(edge.to);
36412
+ nodeSet.add(from);
36413
+ nodeSet.add(to);
36414
+ kept.push({
36415
+ from,
36416
+ to,
36417
+ region: edge.region
36418
+ });
36419
+ }
36420
+ return graphFromEdges(nodeSet, kept, root);
36270
36421
  };
36271
36422
  const edgeKey = (edge) => `${edge.from}\u0000${edge.to}`;
36272
36423
  const normalizeEdge = (edge) => ({
@@ -36289,6 +36440,97 @@ const diffLinkGraph = (declared, rendered) => {
36289
36440
  renderedContextualCount: rendered.contextualEdges.length
36290
36441
  };
36291
36442
  };
36443
+ const ARTIFACT_KIND = "links-rendered";
36444
+ const REGIONS = /* @__PURE__ */ new Set([
36445
+ "nav",
36446
+ "footer",
36447
+ "header",
36448
+ "body"
36449
+ ]);
36450
+ const decodeArtifactEdges = (input) => {
36451
+ if (!Array.isArray(input)) throw new Error("`edges` must be an array of { from, to, region } edges");
36452
+ return input.map((item) => {
36453
+ if (item === null || typeof item !== "object") throw new Error("each artifact edge must be an object with string `from` and `to`");
36454
+ const { from, to, region } = item;
36455
+ if (typeof from !== "string" || typeof to !== "string") throw new Error("each artifact edge must have string `from` and `to`");
36456
+ if (region !== void 0 && !REGIONS.has(region)) throw new Error(`artifact edge region must be one of nav, footer, header, body (received ${JSON.stringify(region)})`);
36457
+ return {
36458
+ from,
36459
+ to,
36460
+ region: region ?? "body"
36461
+ };
36462
+ });
36463
+ };
36464
+ const decodeArtifactNodes = (input) => {
36465
+ if (input === void 0) return [];
36466
+ if (!Array.isArray(input) || !input.every((path) => typeof path === "string")) throw new Error("rendered-edge artifact `nodes` must be an array of paths");
36467
+ return input;
36468
+ };
36469
+ const decodeArtifactFailures = (input) => {
36470
+ if (input === void 0) return [];
36471
+ if (!Array.isArray(input)) throw new Error("rendered-edge artifact `crawl.failures` must be an array");
36472
+ return input.map((item) => {
36473
+ if (item === null || typeof item !== "object") throw new Error("each artifact failure must be an object with string `url` and `error`");
36474
+ const { url, error } = item;
36475
+ if (typeof url !== "string" || typeof error !== "string") throw new Error("each artifact failure must have string `url` and `error`");
36476
+ return {
36477
+ url,
36478
+ error
36479
+ };
36480
+ });
36481
+ };
36482
+ const requiredAbsoluteUrl = (value, field) => {
36483
+ if (typeof value !== "string" || value === "") throw new Error(`rendered-edge artifact must carry a non-empty string \`${field}\``);
36484
+ try {
36485
+ new URL(value);
36486
+ } catch {
36487
+ throw new Error(`rendered-edge artifact \`${field}\` must be an absolute URL (received ${JSON.stringify(value)})`);
36488
+ }
36489
+ return value;
36490
+ };
36491
+ const requiredInteger = (value, field, minimum) => {
36492
+ if (!Number.isSafeInteger(value) || value < minimum) throw new Error(`rendered-edge artifact \`${field}\` must be an integer >= ${minimum} (received ${JSON.stringify(value)})`);
36493
+ return value;
36494
+ };
36495
+ /**
36496
+ * Decode a rendered-edge artifact from user input. Throws on any other shape —
36497
+ * this is user input, not a provider payload. `root`, `bodyTruncated`,
36498
+ * `truncatedPages`, `failures`, and `nodes` are optional so a hand-written edge
36499
+ * dump that only carries provenance can still be replayed.
36500
+ */
36501
+ const decodeRenderedEdgeArtifact = (input) => {
36502
+ if (input === null || typeof input !== "object") throw new Error("rendered-edge artifact must be an object");
36503
+ const value = input;
36504
+ if (value["kind"] !== ARTIFACT_KIND) throw new Error(`rendered-edge artifact \`kind\` must be "${ARTIFACT_KIND}" (received ${JSON.stringify(value["kind"])})`);
36505
+ if (value["schemaVersion"] !== 1) throw new Error(`unsupported rendered-edge artifact schemaVersion: ${JSON.stringify(value["schemaVersion"])}`);
36506
+ const origin = requiredAbsoluteUrl(value["origin"], "origin");
36507
+ const seed = requiredAbsoluteUrl(value["seed"], "seed");
36508
+ const crawl = value["crawl"];
36509
+ if (crawl === null || typeof crawl !== "object") throw new Error("rendered-edge artifact must carry a `crawl` object");
36510
+ const provenance = crawl;
36511
+ const truncatedPages = provenance["truncatedPages"];
36512
+ if (truncatedPages !== void 0 && (!Array.isArray(truncatedPages) || !truncatedPages.every((url) => typeof url === "string"))) throw new Error("rendered-edge artifact `truncatedPages` must be an array of URLs");
36513
+ const bodyTruncated = provenance["bodyTruncated"];
36514
+ if (bodyTruncated !== void 0 && typeof bodyTruncated !== "boolean") throw new Error("rendered-edge artifact `bodyTruncated` must be a boolean");
36515
+ if (typeof provenance["truncated"] !== "boolean") throw new Error("rendered-edge artifact `crawl.truncated` must be a boolean");
36516
+ return {
36517
+ kind: ARTIFACT_KIND,
36518
+ schemaVersion: 1,
36519
+ origin,
36520
+ seed,
36521
+ crawl: {
36522
+ pages: requiredInteger(provenance["pages"], "crawl.pages", 0),
36523
+ root: typeof provenance["root"] === "string" ? provenance["root"] : pathKeyOf(seed),
36524
+ limit: requiredInteger(provenance["limit"], "crawl.limit", 1),
36525
+ truncated: provenance["truncated"],
36526
+ bodyTruncated: bodyTruncated === true,
36527
+ truncatedPages: truncatedPages ?? [],
36528
+ failures: decodeArtifactFailures(provenance["failures"])
36529
+ },
36530
+ nodes: decodeArtifactNodes(value["nodes"]),
36531
+ edges: decodeArtifactEdges(value["edges"])
36532
+ };
36533
+ };
36292
36534
  //#endregion
36293
36535
  //#region src/audit/network.ts
36294
36536
  const blockedIpv4Addresses = new BlockList();
@@ -37571,7 +37813,7 @@ const requestTimeoutMs$1 = Int("request-timeout-ms").pipe(withDescription$2("HTT
37571
37813
  const scannerTimeoutMs = Int("scanner-timeout-ms").pipe(withDescription$2("Lighthouse and hosted scanner timeout in milliseconds"), withDefault(18e4));
37572
37814
  const maxBodyBytes$1 = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes"), withDefault(2e6));
37573
37815
  const outputDir = String$2("output-dir").pipe(withDescription$2("Atomically write timestamped JSON and Markdown artifacts"), optional$1);
37574
- const positive$1 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
37816
+ const positive$2 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
37575
37817
  const auditCommand = make$9("audit", {
37576
37818
  urls,
37577
37819
  json: jsonFlag,
@@ -37599,11 +37841,11 @@ const auditCommand = make$9("audit", {
37599
37841
  description: "Audit a local app"
37600
37842
  }
37601
37843
  ]), withHandler(fn("SeoCli.audit")(function* (options) {
37602
- const checkedRuns = yield* positive$1("runs", options.runs);
37603
- const checkedConcurrency = yield* positive$1("concurrency", options.concurrency);
37604
- const checkedRequestTimeout = yield* positive$1("request-timeout-ms", options.requestTimeoutMs);
37605
- const checkedScannerTimeout = yield* positive$1("scanner-timeout-ms", options.scannerTimeoutMs);
37606
- const checkedMaxBody = yield* positive$1("max-body-bytes", options.maxBodyBytes);
37844
+ const checkedRuns = yield* positive$2("runs", options.runs);
37845
+ const checkedConcurrency = yield* positive$2("concurrency", options.concurrency);
37846
+ const checkedRequestTimeout = yield* positive$2("request-timeout-ms", options.requestTimeoutMs);
37847
+ const checkedScannerTimeout = yield* positive$2("scanner-timeout-ms", options.scannerTimeoutMs);
37848
+ const checkedMaxBody = yield* positive$2("max-body-bytes", options.maxBodyBytes);
37607
37849
  const scanners = [
37608
37850
  makeHttpScanner({
37609
37851
  allowPrivate: options.allowPrivate,
@@ -37653,300 +37895,6 @@ const auditCommand = make$9("audit", {
37653
37895
  if (structural.length > 0 || http.every((result) => result.status === "error")) return yield* new SeoCliError({ message: `${structural.length} structural finding(s); see the report above.` });
37654
37896
  })));
37655
37897
  //#endregion
37656
- //#region src/cli/commands/diff.ts
37657
- const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
37658
- const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
37659
- const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
37660
- const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
37661
- return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
37662
- });
37663
- const diffCommand = make$9("diff", {
37664
- before,
37665
- after,
37666
- json: jsonFlag
37667
- }).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
37668
- command: "pagegraph diff before.json after.json",
37669
- description: "Render a human-readable semantic comparison"
37670
- }, {
37671
- command: "pagegraph diff before.json after.json --json",
37672
- description: "Emit one versioned JSON diff on stdout"
37673
- }]), withHandler(fn("SeoCli.diff")(function* (options) {
37674
- const beforeReport = yield* readAuditReport("before", options.before);
37675
- const afterReport = yield* readAuditReport("after", options.after);
37676
- const comparison = compareAuditReports(beforeReport, afterReport);
37677
- if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
37678
- if (options.json) yield* printJson(comparison.diff);
37679
- else yield* printText(renderAuditDiff(comparison.diff));
37680
- if (comparison.diff.outcome === "regressed") {
37681
- const summary = comparison.diff.summary;
37682
- return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
37683
- }
37684
- })));
37685
- //#endregion
37686
- //#region src/cli/serialize.ts
37687
- const serializeCrumb = (crumb) => {
37688
- if (crumb === void 0) return void 0;
37689
- return typeof crumb === "function" ? "(dynamic)" : crumb;
37690
- };
37691
- /** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
37692
- const serializeNode = (node) => ({
37693
- path: node.path,
37694
- kind: node.kind,
37695
- source: node.source,
37696
- policy: {
37697
- kind: node.policy.kind,
37698
- crumb: serializeCrumb(node.policy.crumb),
37699
- sitemap: node.policy.sitemap,
37700
- robots: node.policy.robots,
37701
- related: node.policy.related,
37702
- link: node.policy.link,
37703
- redirectTo: node.policy.redirectTo
37704
- },
37705
- instance: node.instance
37706
- });
37707
- /** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
37708
- const serializeGraph = (graph) => ({
37709
- nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
37710
- edges: graph.edges
37711
- });
37712
- const graphCommand = make$9("graph", {
37713
- format: Literals("format", [
37714
- "tree",
37715
- "mermaid",
37716
- "json"
37717
- ]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
37718
- orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
37719
- }).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
37720
- {
37721
- command: "pagegraph graph",
37722
- description: "The graph as an indented path tree"
37723
- },
37724
- {
37725
- command: "pagegraph graph --format mermaid",
37726
- description: "A Mermaid diagram of nodes and edges"
37727
- },
37728
- {
37729
- command: "pagegraph graph --orphans",
37730
- description: "Only nodes with no incoming edge"
37731
- },
37732
- {
37733
- command: "pagegraph graph --format json",
37734
- description: "The serialized graph on stdout"
37735
- }
37736
- ]), withHandler(fnUntraced(function* ({ format, orphans }) {
37737
- const config = yield* loadSeoConfig;
37738
- const graph = yield* scoped(acquireGraph(config));
37739
- if (format === "json") {
37740
- const serialized = serializeGraph(graph);
37741
- if (!orphans) return yield* printJson(serialized);
37742
- const orphanSet = orphanPaths(graph);
37743
- return yield* printJson({
37744
- nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
37745
- edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
37746
- });
37747
- }
37748
- if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
37749
- return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
37750
- })));
37751
- //#endregion
37752
- //#region src/core/inspect-html.ts
37753
- const ENTITIES = {
37754
- "&amp;": "&",
37755
- "&lt;": "<",
37756
- "&gt;": ">",
37757
- "&quot;": "\"",
37758
- "&#39;": "'",
37759
- "&apos;": "'"
37760
- };
37761
- const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
37762
- /** Pull double/single-quoted attributes off a single tag string. */
37763
- const parseAttrs = (tag) => {
37764
- const attrs = {};
37765
- const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
37766
- let match;
37767
- while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
37768
- return attrs;
37769
- };
37770
- /** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
37771
- const typeName = (value) => {
37772
- if (typeof value === "string") return value;
37773
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
37774
- return "unknown";
37775
- };
37776
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
37777
- /** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
37778
- const collectItems = (parsed) => {
37779
- if (Array.isArray(parsed)) return parsed.filter(isObject);
37780
- if (isObject(parsed)) {
37781
- if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
37782
- return [parsed];
37783
- }
37784
- return [];
37785
- };
37786
- const countQuestions = (mainEntity) => {
37787
- if (!Array.isArray(mainEntity)) return 0;
37788
- return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
37789
- };
37790
- const validateItemListElements = (value) => {
37791
- if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
37792
- const errors = [];
37793
- value.forEach((entry, index) => {
37794
- if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
37795
- errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
37796
- return;
37797
- }
37798
- if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
37799
- if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
37800
- });
37801
- return errors;
37802
- };
37803
- /** Minimal per-type validation — enough to catch an empty or malformed block. */
37804
- const validateItem = (item) => {
37805
- const type = typeName(item["@type"]);
37806
- const errors = [];
37807
- if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
37808
- if (!item["headline"]) errors.push("Article is missing `headline`.");
37809
- if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
37810
- } else if (type === "FAQPage") {
37811
- if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
37812
- } else if (type === "BreadcrumbList") {
37813
- const items = item["itemListElement"];
37814
- if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
37815
- } else if (type === "ItemList") {
37816
- errors.push(...validateItemListElements(item["itemListElement"]));
37817
- const items = item["itemListElement"];
37818
- if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
37819
- }
37820
- return {
37821
- type,
37822
- valid: errors.length === 0,
37823
- errors
37824
- };
37825
- };
37826
- const validateLdJson = (raw) => {
37827
- let parsed;
37828
- try {
37829
- parsed = JSON.parse(raw);
37830
- } catch (cause) {
37831
- return [{
37832
- type: "unparseable",
37833
- valid: false,
37834
- errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
37835
- }];
37836
- }
37837
- const items = collectItems(parsed);
37838
- if (items.length === 0) return [{
37839
- type: "unknown",
37840
- valid: false,
37841
- errors: ["No JSON-LD object found in block."]
37842
- }];
37843
- return items.map(validateItem);
37844
- };
37845
- /** Parse a rendered HTML document's `<head>` into a report. Pure. */
37846
- const inspectHtml = (url, status, html) => {
37847
- const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
37848
- const head = headMatch ? headMatch[1] : html;
37849
- const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
37850
- const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
37851
- const og = {};
37852
- const twitter = {};
37853
- let description;
37854
- let robots;
37855
- for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
37856
- const attrs = parseAttrs(tag);
37857
- const content = attrs["content"];
37858
- if (content === void 0) continue;
37859
- const property = attrs["property"];
37860
- const name = attrs["name"];
37861
- if (property?.startsWith("og:")) og[property] = content;
37862
- else if (name?.startsWith("twitter:")) twitter[name] = content;
37863
- else if (name === "description") description = content;
37864
- else if (name === "robots") robots = content;
37865
- }
37866
- let canonical;
37867
- for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
37868
- const attrs = parseAttrs(tag);
37869
- if (attrs["rel"] === "canonical") canonical = attrs["href"];
37870
- }
37871
- const jsonLd = [];
37872
- const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
37873
- let scriptMatch;
37874
- while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
37875
- const issues = [];
37876
- if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
37877
- if (!title) issues.push("Missing <title>.");
37878
- if (!description) issues.push("Missing meta description.");
37879
- if (!canonical) issues.push("Missing canonical link.");
37880
- for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
37881
- return {
37882
- url,
37883
- status,
37884
- title,
37885
- description,
37886
- canonical,
37887
- robots,
37888
- og,
37889
- twitter,
37890
- jsonLd,
37891
- issues
37892
- };
37893
- };
37894
- /** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
37895
- const hasBlockingIssues = (report) => report.issues.length > 0;
37896
- //#endregion
37897
- //#region src/cli/live-inspect.ts
37898
- /**
37899
- * `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
37900
- * validator in `../inspect-html`. The fetch is all that lives here — the
37901
- * validation is a library capability, not a CLI one, so it stays out of the
37902
- * Effect-bearing half of the package.
37903
- */
37904
- /** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
37905
- const fetchAndInspect = (url) => tryPromise({
37906
- try: async () => {
37907
- const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
37908
- const html = await response.text();
37909
- return inspectHtml(url, response.status, html);
37910
- },
37911
- catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
37912
- });
37913
- const inspectCommand = make$9("inspect", {
37914
- target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
37915
- live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
37916
- json: jsonFlag
37917
- }).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
37918
- {
37919
- command: "pagegraph inspect /pricing",
37920
- description: "The graph node, policy, and edges for a path"
37921
- },
37922
- {
37923
- command: "pagegraph inspect https://example.com/pricing --live",
37924
- description: "Fetch the page and validate its head tags + JSON-LD"
37925
- },
37926
- {
37927
- command: "pagegraph inspect /blog/some-post --json",
37928
- description: "The node report as JSON"
37929
- }
37930
- ]), withHandler(fnUntraced(function* ({ target, live, json }) {
37931
- if (live) {
37932
- const report = yield* fetchAndInspect(target);
37933
- if (json) yield* printJson(report);
37934
- else yield* printText(renderLiveReport(report));
37935
- if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
37936
- return;
37937
- }
37938
- const config = yield* loadSeoConfig;
37939
- const report = inspectNode(yield* scoped(acquireGraph(config)), target);
37940
- if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
37941
- if (json) return yield* printJson({
37942
- node: serializeNode(report.node),
37943
- inSitemap: report.inSitemap,
37944
- incoming: report.incoming,
37945
- outgoing: report.outgoing
37946
- });
37947
- return yield* printText(renderNodeReport(report));
37948
- })));
37949
- //#endregion
37950
37898
  //#region node_modules/effect/dist/unstable/http/HttpBody.js
37951
37899
  const TypeId$9 = "~effect/http/HttpBody";
37952
37900
  const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError";
@@ -40734,7 +40682,7 @@ const invalidOutput = (description) => make$5({
40734
40682
  reason: new InvalidOutputError({ description })
40735
40683
  });
40736
40684
  const isFiniteNumber = (value) => isNumber(value) && Number.isFinite(value);
40737
- const isUnitInterval = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
40685
+ const isUnitInterval$1 = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
40738
40686
  const validateDistribution = (key, labels, answer) => {
40739
40687
  const raw = answer.probabilities;
40740
40688
  if (!isObject$1(raw)) return invalidOutput(`Provider returned no probabilities for decision "${key}"`);
@@ -40742,7 +40690,7 @@ const validateDistribution = (key, labels, answer) => {
40742
40690
  let total = 0;
40743
40691
  for (const label of labels) {
40744
40692
  const value = raw[label];
40745
- if (!isUnitInterval(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
40693
+ if (!isUnitInterval$1(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
40746
40694
  probabilities[label] = value;
40747
40695
  total += value;
40748
40696
  }
@@ -40751,7 +40699,7 @@ const validateDistribution = (key, labels, answer) => {
40751
40699
  };
40752
40700
  const validateConfidence = (key, answer) => {
40753
40701
  if (answer.confidence === void 0) return;
40754
- return isUnitInterval(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
40702
+ return isUnitInterval$1(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
40755
40703
  };
40756
40704
  const validateAnswer = (key, decision, answer) => {
40757
40705
  if (!isObject$1(answer)) return invalidOutput(`Provider returned no answer for decision "${key}"`);
@@ -40787,7 +40735,7 @@ const validateAnswer = (key, decision, answer) => {
40787
40735
  };
40788
40736
  }
40789
40737
  case "Probability":
40790
- if (!isUnitInterval(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
40738
+ if (!isUnitInterval$1(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
40791
40739
  return { probability: answer.probability };
40792
40740
  }
40793
40741
  };
@@ -41052,6 +41000,1501 @@ const make$1 = /*#__PURE__*/ fnUntraced(function* (options) {
41052
41000
  */
41053
41001
  const layer = (options) => effect(DecisionModel, make$1(options));
41054
41002
  //#endregion
41003
+ //#region node_modules/effect/dist/unstable/ai/Decision.js
41004
+ /**
41005
+ * Brand for decision definitions.
41006
+ *
41007
+ * @category type IDs
41008
+ * @since 4.0.0
41009
+ */
41010
+ const TypeId = "~effect/ai/Decision";
41011
+ /**
41012
+ * Creates a classification decision from labelled criteria.
41013
+ * Throws if fewer than two labels are supplied.
41014
+ *
41015
+ * **Example** (Choosing a department)
41016
+ *
41017
+ * ```ts
41018
+ * import { Decision } from "effect/unstable/ai"
41019
+ *
41020
+ * const department = Decision.classify({
41021
+ * instructions: "Which team should handle this",
41022
+ * criteria: {
41023
+ * billing: "payments",
41024
+ * technical: "bugs",
41025
+ * sales: "pricing"
41026
+ * }
41027
+ * })
41028
+ * ```
41029
+ *
41030
+ * @see {@link rate} for an ordered scale
41031
+ * @see {@link probability} for a single yes or no likelihood
41032
+ *
41033
+ * @category constructors
41034
+ * @since 4.0.0
41035
+ */
41036
+ const classify = (options) => {
41037
+ if (Object.keys(options.criteria).length < 2) throw new Error("Decision.classify: criteria must contain at least two labels");
41038
+ return {
41039
+ _tag: "Classify",
41040
+ instructions: options.instructions,
41041
+ criteria: options.criteria
41042
+ };
41043
+ };
41044
+ /**
41045
+ * Creates a rating decision from an ordered list of criteria.
41046
+ * Throws if fewer than two levels or duplicate levels are supplied.
41047
+ *
41048
+ * **Example** (Rating frustration)
41049
+ *
41050
+ * ```ts
41051
+ * import { Decision } from "effect/unstable/ai"
41052
+ *
41053
+ * const frustration = Decision.rate({
41054
+ * instructions: "How frustrated",
41055
+ * criteria: ["calm", "frustrated", "angry"]
41056
+ * })
41057
+ * ```
41058
+ *
41059
+ * @see {@link classify} for unordered labels
41060
+ *
41061
+ * @category constructors
41062
+ * @since 4.0.0
41063
+ */
41064
+ const rate = (options) => {
41065
+ if (options.criteria.length < 2) throw new Error("Decision.rate: criteria must contain at least two levels");
41066
+ if (new Set(options.criteria).size !== options.criteria.length) throw new Error("Decision.rate: criteria must contain distinct levels");
41067
+ return {
41068
+ _tag: "Rate",
41069
+ instructions: options.instructions,
41070
+ criteria: options.criteria
41071
+ };
41072
+ };
41073
+ /**
41074
+ * Creates a probability decision from descriptions of both outcomes.
41075
+ *
41076
+ * **Example** (Estimating urgency)
41077
+ *
41078
+ * ```ts
41079
+ * import { Decision } from "effect/unstable/ai"
41080
+ *
41081
+ * const urgent = Decision.probability({
41082
+ * instructions: "The message is time-sensitive",
41083
+ * criteria: {
41084
+ * false: "No time pressure",
41085
+ * true: "Needs action now"
41086
+ * }
41087
+ * })
41088
+ * ```
41089
+ *
41090
+ * @see {@link classify} for more than two outcomes
41091
+ *
41092
+ * @category constructors
41093
+ * @since 4.0.0
41094
+ */
41095
+ const probability = (options) => ({
41096
+ _tag: "Probability",
41097
+ instructions: options.instructions,
41098
+ criteria: options.criteria
41099
+ });
41100
+ /**
41101
+ * Creates a decision definition from an input schema and named decisions.
41102
+ * `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
41103
+ * calling the provider. Answer keys and types are inferred from the decisions.
41104
+ * Throws if `decisions` is empty.
41105
+ *
41106
+ * **Example** (Defining ticket triage)
41107
+ *
41108
+ * ```ts
41109
+ * import { Schema } from "effect"
41110
+ * import { Decision } from "effect/unstable/ai"
41111
+ *
41112
+ * const Ticket = Schema.Struct({
41113
+ * subject: Schema.String,
41114
+ * body: Schema.String
41115
+ * })
41116
+ *
41117
+ * const TicketTriage = Decision.make({
41118
+ * input: Ticket,
41119
+ * decisions: {
41120
+ * department: Decision.classify({
41121
+ * instructions: "Which team should handle this",
41122
+ * criteria: { billing: "payments", technical: "bugs" }
41123
+ * }),
41124
+ * urgent: Decision.probability({
41125
+ * instructions: "The message is time-sensitive",
41126
+ * criteria: { false: "No time pressure", true: "Needs action now" }
41127
+ * })
41128
+ * }
41129
+ * })
41130
+ * ```
41131
+ *
41132
+ * @see {@link Definition} for the returned shape
41133
+ *
41134
+ * @category constructors
41135
+ * @since 4.0.0
41136
+ */
41137
+ const make = (options) => {
41138
+ if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
41139
+ return {
41140
+ [TypeId]: TypeId,
41141
+ input: options.input,
41142
+ decisions: options.decisions
41143
+ };
41144
+ };
41145
+ //#endregion
41146
+ //#region src/decide/cache.ts
41147
+ /**
41148
+ * Answer cache keyed by input hash. One JSON file per key under `--cache <dir>`.
41149
+ *
41150
+ * Only model answers are cached — verdicts are recomputed from answers and the
41151
+ * current threshold, so raising or lowering `--threshold` never serves a stale
41152
+ * verdict. Cache files are plain data: a missing or unreadable file is a miss,
41153
+ * never an error that fails the run.
41154
+ */
41155
+ /** Read cached answers for `key`, or `undefined` on any miss (absent or unreadable). */
41156
+ const cacheGet = (cacheDir, key) => {
41157
+ const file = join(cacheDir, `${key}.json`);
41158
+ if (!existsSync(file)) return void 0;
41159
+ try {
41160
+ return JSON.parse(readFileSync(file, "utf8"));
41161
+ } catch {
41162
+ return;
41163
+ }
41164
+ };
41165
+ /** Persist answers for `key`; creates `cacheDir` on first write. */
41166
+ const cachePut = (cacheDir, key, answers) => {
41167
+ mkdirSync(cacheDir, { recursive: true });
41168
+ writeFileSync(join(cacheDir, `${key}.json`), `${JSON.stringify(answers, null, 2)}\n`, "utf8");
41169
+ };
41170
+ //#endregion
41171
+ //#region src/decide/run.ts
41172
+ /**
41173
+ * The decision runner. A family supplies its input schema, a definition built
41174
+ * from the input (families with dynamic labels build one per input), a way to
41175
+ * reference the input in a report, and a pure `evaluate` that maps the model's
41176
+ * answers to a verdict. The runner hashes, caches, dispatches with bounded
41177
+ * concurrency, and buckets confident answers apart from `review`.
41178
+ *
41179
+ * This module reaches Effect and the provider, so it is CLI-internal: the
41180
+ * library entries stay Effect-free and only the bundled `pagegraph` bin ships it.
41181
+ */
41182
+ const isUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
41183
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
41184
+ /** A probability distribution over exactly `labels`: unit-range, summing to 1. */
41185
+ const isDistribution = (labels, value) => {
41186
+ if (!isPlainObject(value) || Object.keys(value).length !== labels.length) return false;
41187
+ let total = 0;
41188
+ for (const label of labels) {
41189
+ const probability = value[label];
41190
+ if (!isUnitInterval(probability)) return false;
41191
+ total += probability;
41192
+ }
41193
+ return Math.abs(total - 1) <= 1e-6;
41194
+ };
41195
+ /**
41196
+ * Whether cached answers have the shape `DecisionModel` would have validated —
41197
+ * the same labels, ranges, and distributions. A cache file is only trusted when
41198
+ * it round-trips through this check, so a partially malformed entry cannot
41199
+ * produce a confident verdict.
41200
+ */
41201
+ const validCachedAnswers = (decisions, answers) => {
41202
+ if (!isPlainObject(answers)) return false;
41203
+ for (const key of Object.keys(decisions)) {
41204
+ const answer = answers[key];
41205
+ if (!isPlainObject(answer)) return false;
41206
+ const decision = decisions[key];
41207
+ switch (decision._tag) {
41208
+ case "Probability":
41209
+ if (!isUnitInterval(answer.probability)) return false;
41210
+ break;
41211
+ case "Classify":
41212
+ if (typeof answer.label !== "string" || !Object.hasOwn(decision.criteria, answer.label) || !isDistribution(Object.keys(decision.criteria), answer.probabilities)) return false;
41213
+ break;
41214
+ case "Rate": if (typeof answer.rating !== "number" || !Number.isFinite(answer.rating) || answer.rating < 0 || answer.rating > decision.criteria.length - 1 || typeof answer.label !== "string" || !decision.criteria.includes(answer.label) || !isDistribution(decision.criteria, answer.probabilities)) return false;
41215
+ }
41216
+ }
41217
+ return true;
41218
+ };
41219
+ /** A probability is confident when it is at or beyond `threshold` on either side. */
41220
+ const isConfident$1 = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
41221
+ /** True when a probability sits inside the review band `(1 - threshold, threshold)`. */
41222
+ const inReviewBand = (probability, threshold) => !isConfident$1(probability, threshold);
41223
+ /** True when any probability sits inside the review band. */
41224
+ const anyInReviewBand = (probabilities, threshold) => probabilities.some((probability) => inReviewBand(probability, threshold));
41225
+ /** sha256 of the canonical input JSON — deterministic for a schema-decoded input. */
41226
+ const inputHash = (input) => createHash("sha256").update(JSON.stringify(input)).digest("hex");
41227
+ /**
41228
+ * Cache identity. Answers depend on the family and the model as well as the
41229
+ * input, so keying by the input hash alone would let a different family or model
41230
+ * reuse incompatible answers. The answer-schema version guards shape changes.
41231
+ */
41232
+ const cacheKey = (family, model, answerHash) => createHash("sha256").update(`${family}\u0000${model}\u0000v1\u0000${answerHash}`).digest("hex");
41233
+ /** Group records into resolved/review buckets and count each verdict. */
41234
+ const buildDecisionReport = (options) => {
41235
+ const verdicts = {};
41236
+ const resolved = [];
41237
+ const review = [];
41238
+ for (const record of options.records) {
41239
+ verdicts[record.verdict] = (verdicts[record.verdict] ?? 0) + 1;
41240
+ if (record.review) review.push(record);
41241
+ else resolved.push(record);
41242
+ }
41243
+ return {
41244
+ kind: "decide",
41245
+ schemaVersion: 1,
41246
+ family: options.family,
41247
+ model: options.model,
41248
+ threshold: options.threshold,
41249
+ counts: {
41250
+ inputs: options.records.length,
41251
+ resolved: resolved.length,
41252
+ review: review.length
41253
+ },
41254
+ verdicts,
41255
+ resolved,
41256
+ review
41257
+ };
41258
+ };
41259
+ /**
41260
+ * Answer every input through the ambient `DecisionModel` and bucket the results.
41261
+ * One provider call answers every decision for an input; calls run with bounded
41262
+ * concurrency, and a warm cache skips the provider entirely.
41263
+ */
41264
+ const runDecisions = (options) => gen(function* () {
41265
+ const records = yield* forEach$1(options.inputs, (input, index) => gen(function* () {
41266
+ const hash = inputHash(input);
41267
+ const key = cacheKey(options.family.name, options.model, hash);
41268
+ const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
41269
+ let answers;
41270
+ let usage;
41271
+ let verdict;
41272
+ if (cached !== void 0) {
41273
+ const decisions = options.family.definitionFor(input).decisions;
41274
+ if (validCachedAnswers(decisions, cached)) {
41275
+ answers = cached;
41276
+ verdict = options.family.evaluate(input, cached, options.threshold);
41277
+ }
41278
+ }
41279
+ if (verdict === void 0) {
41280
+ const response = yield* decide(options.family.definitionFor(input), { input });
41281
+ answers = response.answers;
41282
+ usage = {
41283
+ inputTokens: response.usage.inputTokens,
41284
+ outputTokens: response.usage.outputTokens
41285
+ };
41286
+ if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
41287
+ verdict = options.family.evaluate(input, answers, options.threshold);
41288
+ }
41289
+ return {
41290
+ decisionId: `${options.family.name}:${index}`,
41291
+ schemaVersion: 1,
41292
+ family: options.family.name,
41293
+ model: options.model,
41294
+ threshold: options.threshold,
41295
+ inputHash: hash,
41296
+ inputRef: options.family.inputRef(input),
41297
+ verdict,
41298
+ review: verdict === "review",
41299
+ answers,
41300
+ ...usage === void 0 ? {} : { usage }
41301
+ };
41302
+ }), { concurrency: options.concurrency ?? 4 });
41303
+ return buildDecisionReport({
41304
+ family: options.family.name,
41305
+ model: options.model,
41306
+ threshold: options.threshold,
41307
+ records
41308
+ });
41309
+ });
41310
+ /** Decode raw batch inputs against a family's schema. Throws on the first invalid input. */
41311
+ /** `decodeUnknownSync` with an unconstrained schema type; the family types the result. */
41312
+ const decodeUnknown = decodeUnknownSync;
41313
+ const decodeFamilyInputs = (family, raw) => raw.map((value) => decodeUnknown(family.input)(value));
41314
+ //#endregion
41315
+ //#region src/decide/families/authority.ts
41316
+ /**
41317
+ * The authority family: is this a real link opportunity, a spam trap, or not
41318
+ * worth outreach?
41319
+ *
41320
+ * Three probabilities judge the domain and one classification places its fit.
41321
+ * A confident spam verdict is `spam`, a confident legitimate-and-worth-while fit
41322
+ * is `accept`, and everything uncertain is `review` — link outreach is never
41323
+ * auto-applied.
41324
+ */
41325
+ const AUTHORITY_FIT_CRITERIA = {
41326
+ directory: "A directory or listing page that accepts submissions.",
41327
+ partner: "A partner or integration page that links relevant products.",
41328
+ editorial: "Editorial coverage: an article or guide that could cite this page.",
41329
+ community: "A community space: forum, Q&A, or discussion group.",
41330
+ none: "No genuine fit; outreach here would be noise."
41331
+ };
41332
+ /** One link target plus the evidence the model reads. */
41333
+ const AuthorityInput = Struct({
41334
+ domain: String$6,
41335
+ url: String$6,
41336
+ anchor: String$6,
41337
+ /** The surrounding copy on our page that would carry the link. */
41338
+ context: String$6,
41339
+ targetPath: String$6,
41340
+ /** Referring domains, when known. */
41341
+ rd: optional$3(Number$2),
41342
+ /** Extra observed signals, e.g. `["no-outbound", "paid-links"]`. */
41343
+ signals: optional$3(ArraySchema(String$6))
41344
+ });
41345
+ /** One provider call answers the three probabilities and the fit classification. */
41346
+ const AuthorityDecision = make({
41347
+ input: AuthorityInput,
41348
+ decisions: {
41349
+ legitimate: probability({
41350
+ instructions: "The target is a legitimate site a reader would trust: real editorial or community content, not a link farm, PBN, or paid-link network.",
41351
+ criteria: {
41352
+ false: "The target looks like spam, a link farm, or a paid-link network.",
41353
+ true: "The target is a legitimate site."
41354
+ }
41355
+ }),
41356
+ spam: probability({
41357
+ instructions: "The target shows spam signals: thin auto-generated content, exact-match anchor farms, or a link-selling footprint.",
41358
+ criteria: {
41359
+ false: "No meaningful spam signals.",
41360
+ true: "Clear spam or link-selling signals."
41361
+ }
41362
+ }),
41363
+ outreachWorth: probability({
41364
+ instructions: "Outreach to this target is worth the effort: a human could plausibly place a reader-useful link.",
41365
+ criteria: {
41366
+ false: "Outreach is not worth the effort.",
41367
+ true: "Outreach could place a useful link."
41368
+ }
41369
+ }),
41370
+ fit: classify({
41371
+ instructions: "Classify the relationship this target could support.",
41372
+ criteria: AUTHORITY_FIT_CRITERIA
41373
+ })
41374
+ }
41375
+ });
41376
+ /**
41377
+ * `spam` when spam is confident; `accept` only when legitimate, outreach-worthy,
41378
+ * and a non-`none` fit are all confident; otherwise `review`.
41379
+ */
41380
+ const classifyAuthorityVerdict = (answers, threshold) => {
41381
+ if (anyInReviewBand([
41382
+ answers.legitimate.probability,
41383
+ answers.spam.probability,
41384
+ answers.outreachWorth.probability
41385
+ ], threshold)) return "review";
41386
+ if (answers.spam.probability >= threshold) return "spam";
41387
+ const fitProbability = answers.fit.probabilities[answers.fit.label] ?? 0;
41388
+ if (answers.fit.label === "none" || fitProbability < threshold || answers.legitimate.probability < threshold || answers.outreachWorth.probability < threshold) return "review";
41389
+ return "accept";
41390
+ };
41391
+ const authorityFamily = {
41392
+ name: "authority",
41393
+ input: AuthorityInput,
41394
+ definitionFor: () => AuthorityDecision,
41395
+ inputRef: (input) => input.domain,
41396
+ evaluate: (_input, answers, threshold) => classifyAuthorityVerdict(answers, threshold)
41397
+ };
41398
+ //#endregion
41399
+ //#region src/decide/families/content.ts
41400
+ /**
41401
+ * The content family: does this page carry enough original substance?
41402
+ *
41403
+ * Seven probabilities score the page's editorial quality and one rating places
41404
+ * its overall value on `thin | adequate | strong`. A confident page with a
41405
+ * `thin` value or any failed probability is `flag`; an uncertain answer is
41406
+ * `review`. Nothing is rewritten here — the verdict is the plan.
41407
+ */
41408
+ /** Ordered value scale from least to most substance. */
41409
+ const CONTENT_VALUES = [
41410
+ "thin",
41411
+ "adequate",
41412
+ "strong"
41413
+ ];
41414
+ /** One page plus the evidence the rubric reads. */
41415
+ const ContentInput = Struct({
41416
+ url: String$6,
41417
+ /** The query the page targets; intent rubrics cannot be judged without it. */
41418
+ query: String$6,
41419
+ title: String$6,
41420
+ h1: String$6,
41421
+ first150Words: String$6,
41422
+ headings: ArraySchema(String$6),
41423
+ wordCount: Natural,
41424
+ /** schema.org types present on the page, e.g. `["Article", "FAQPage"]`. */
41425
+ structuredData: ArraySchema(String$6),
41426
+ /** The topic the page must stay inside; drifting outside it is a failure. */
41427
+ categoryLock: String$6,
41428
+ /** Sibling pages' intents, needed to judge `distinctIntent`. */
41429
+ siblingIntents: optional$3(ArraySchema(String$6)),
41430
+ competitorExcerpts: optional$3(ArraySchema(String$6))
41431
+ });
41432
+ const boolean = (instructions, falseText, trueText) => probability({
41433
+ instructions,
41434
+ criteria: {
41435
+ false: falseText,
41436
+ true: trueText
41437
+ }
41438
+ });
41439
+ /** One provider call answers the seven rubric probabilities and the value rating. */
41440
+ const ContentDecision = make({
41441
+ input: ContentInput,
41442
+ decisions: {
41443
+ answersFirst: boolean("The first 150 words answer the reader's question directly, before background or preamble.", "The opening delays the answer.", "The opening answers the question up front."),
41444
+ originalValue: boolean("The page adds original value — first-hand experience, data, or a distinct point of view — beyond restating common knowledge.", "The page restates what every other result already says.", "The page adds information a reader cannot get elsewhere."),
41445
+ competitiveSubstance: boolean("The page has enough depth and specificity to compete with the current results for this topic.", "The page is shallower or less specific than the competition.", "The page matches or exceeds the competition's substance."),
41446
+ descriptiveTitle: boolean("The title is descriptive and specific to the page's content rather than vague or clickbait.", "The title is vague or misleading.", "The title describes the page's actual content."),
41447
+ structuredDataMatches: boolean("The page's structured data matches its content type and the supplied query's intent.", "Structured data is missing, wrong, or mismatched.", "Structured data correctly describes the page."),
41448
+ categoryConsistency: boolean("The page stays inside its locked category; its headings and body do not drift into an adjacent topic.", "The page drifts outside the locked category.", "The page stays inside the locked category."),
41449
+ distinctIntent: boolean("The page serves a distinct intent rather than duplicating a sibling page's intent. Judge only the sibling intents supplied; if none are supplied, treat this as unresolved.", "The page duplicates a sibling page's intent.", "The page serves an intent no supplied sibling covers."),
41450
+ value: rate({
41451
+ instructions: "Overall reader value of this page: thin restates what exists, adequate is competitive, strong is the best available answer.",
41452
+ criteria: CONTENT_VALUES
41453
+ })
41454
+ }
41455
+ });
41456
+ /** The seven rubric probabilities that decide pass/flag once confidence is settled. */
41457
+ const RUBRIC_KEYS = [
41458
+ "answersFirst",
41459
+ "originalValue",
41460
+ "competitiveSubstance",
41461
+ "descriptiveTitle",
41462
+ "structuredDataMatches",
41463
+ "categoryConsistency",
41464
+ "distinctIntent"
41465
+ ];
41466
+ /** Evidence present by default; a caller with no evidence can still test the band logic. */
41467
+ const ALL_EVIDENCE = {
41468
+ distinctIntent: true,
41469
+ competitiveSubstance: true
41470
+ };
41471
+ /**
41472
+ * `flag` when the value rating is `thin` or any rubric probability is
41473
+ * confidently false; `review` when any probability (rubric or value
41474
+ * distribution) is inside the band, or an evidence-dependent rubric is
41475
+ * confidently positive without its evidence; `pass` otherwise. A confident
41476
+ * negative on an unevidenced rubric stays `flag` — it is the conservative side.
41477
+ */
41478
+ const classifyContentVerdict = (answers, threshold, evidence = ALL_EVIDENCE) => {
41479
+ const rubric = RUBRIC_KEYS.map((key) => answers[key].probability);
41480
+ const distribution = Object.values(answers.value.probabilities);
41481
+ if (anyInReviewBand([...rubric, ...distribution], threshold)) return "review";
41482
+ if (!evidence.distinctIntent && answers.distinctIntent.probability >= threshold) return "review";
41483
+ if (!evidence.competitiveSubstance && answers.competitiveSubstance.probability >= threshold) return "review";
41484
+ if (answers.value.label === "thin") return "flag";
41485
+ if (rubric.some((probability) => probability <= 1 - threshold)) return "flag";
41486
+ return "pass";
41487
+ };
41488
+ const contentFamily = {
41489
+ name: "content",
41490
+ input: ContentInput,
41491
+ definitionFor: () => ContentDecision,
41492
+ inputRef: (input) => input.url,
41493
+ evaluate: (input, answers, threshold) => classifyContentVerdict(answers, threshold, {
41494
+ distinctIntent: input.siblingIntents !== void 0 && input.siblingIntents.length > 0,
41495
+ competitiveSubstance: input.competitorExcerpts !== void 0 && input.competitorExcerpts.length > 0
41496
+ })
41497
+ };
41498
+ /** One query plus the candidate pages already on the site. */
41499
+ const FitInput = Struct({
41500
+ query: String$6,
41501
+ candidates: ArraySchema(Struct({
41502
+ id: String$6,
41503
+ title: String$6,
41504
+ excerpt: String$6
41505
+ }))
41506
+ });
41507
+ const candidateCriteria$1 = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.excerpt}`.slice(0, 255)]));
41508
+ /** One provider call answers `bestPage`, `competition`, and `needsNewPage`. */
41509
+ const makeFitDecision = (input) => make({
41510
+ input: FitInput,
41511
+ decisions: {
41512
+ bestPage: classify({
41513
+ instructions: "Choose the candidate page that best targets this query, or the closest starting point to update. Use the page's title and excerpt.",
41514
+ criteria: candidateCriteria$1(input.candidates)
41515
+ }),
41516
+ competition: probability({
41517
+ instructions: "Two or more of the supplied pages already target this query, so they risk cannibalizing each other.",
41518
+ criteria: {
41519
+ false: "At most one page targets the query.",
41520
+ true: "Multiple pages target the query."
41521
+ }
41522
+ }),
41523
+ needsNewPage: probability({
41524
+ instructions: "None of the supplied pages can serve this query well; a new page is the right answer.",
41525
+ criteria: {
41526
+ false: "An existing page can serve this query.",
41527
+ true: "No existing page can serve this query; a new page is needed."
41528
+ }
41529
+ })
41530
+ }
41531
+ });
41532
+ /** Reject inputs whose candidate ids cannot become provider labels. */
41533
+ const validateFitInput = (input) => {
41534
+ if (input.candidates.length < 2) return "decide fit needs at least two candidates: bestPage compares them.";
41535
+ const long = input.candidates.find((candidate) => candidate.id.length > 255);
41536
+ if (long !== void 0) return `decide fit candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
41537
+ if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide fit candidate ids must be unique: they become provider labels.";
41538
+ };
41539
+ /**
41540
+ * `gap` wins over `cannibalized` when both are confident: a new page is the more
41541
+ * decisive answer. `map` needs a confident `bestPage`; otherwise it is `review`.
41542
+ */
41543
+ const classifyFitVerdict = (answers, threshold) => {
41544
+ if (anyInReviewBand([answers.competition.probability, answers.needsNewPage.probability], threshold)) return "review";
41545
+ if (answers.needsNewPage.probability >= threshold) return "gap";
41546
+ if (answers.competition.probability >= threshold) return "cannibalized";
41547
+ return (answers.bestPage.probabilities[answers.bestPage.label] ?? 0) >= threshold ? "map" : "review";
41548
+ };
41549
+ const fitFamily = {
41550
+ name: "fit",
41551
+ input: FitInput,
41552
+ definitionFor: makeFitDecision,
41553
+ inputRef: (input) => `query:${input.query}`,
41554
+ evaluate: (_input, answers, threshold) => classifyFitVerdict(answers, threshold),
41555
+ validate: validateFitInput
41556
+ };
41557
+ /** One page plus the metadata candidates to rank. */
41558
+ const MetaInput = Struct({
41559
+ url: String$6,
41560
+ intent: String$6,
41561
+ categoryLock: String$6,
41562
+ candidates: ArraySchema(Struct({
41563
+ id: String$6,
41564
+ title: String$6,
41565
+ description: String$6
41566
+ }))
41567
+ });
41568
+ const candidateCriteria = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.description}`.slice(0, 255)]));
41569
+ /** One provider call answers `best` and the four concern probabilities. */
41570
+ const makeMetaDecision = (input) => make({
41571
+ input: MetaInput,
41572
+ decisions: {
41573
+ best: classify({
41574
+ instructions: "Choose the candidate title/description that best serves the page's intent and stays inside its category. Judge the supplied candidates only.",
41575
+ criteria: candidateCriteria(input.candidates)
41576
+ }),
41577
+ intentFit: probability({
41578
+ instructions: "The chosen candidate's promise matches the page's intent.",
41579
+ criteria: {
41580
+ false: "The candidate promises something the page does not deliver.",
41581
+ true: "The candidate matches the page's intent."
41582
+ }
41583
+ }),
41584
+ lengthOk: probability({
41585
+ instructions: "The chosen candidate fits search-result limits: a title near 60 characters, a description near 160.",
41586
+ criteria: {
41587
+ false: "The candidate is too short or truncated.",
41588
+ true: "The candidate fits the result limits."
41589
+ }
41590
+ }),
41591
+ categoryDrift: probability({
41592
+ instructions: "The chosen candidate drifts outside the page's locked category.",
41593
+ criteria: {
41594
+ false: "The candidate stays inside the category.",
41595
+ true: "The candidate drifts outside the category."
41596
+ }
41597
+ }),
41598
+ clickbait: probability({
41599
+ instructions: "The chosen candidate uses clickbait or overpromises relative to the page's content.",
41600
+ criteria: {
41601
+ false: "The candidate is accurate and measured.",
41602
+ true: "The candidate overpromises or uses clickbait."
41603
+ }
41604
+ })
41605
+ }
41606
+ });
41607
+ /** Reject inputs whose candidate ids cannot become provider labels. */
41608
+ const validateMetaInput = (input) => {
41609
+ if (input.candidates.length < 2) return "decide meta needs at least two candidates: best ranks them.";
41610
+ const long = input.candidates.find((candidate) => candidate.id.length > 255);
41611
+ if (long !== void 0) return `decide meta candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
41612
+ if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide meta candidate ids must be unique: they become provider labels.";
41613
+ };
41614
+ /**
41615
+ * A clean candidate is `choose:<id>`. Any review-band probability, a low-probability
41616
+ * pick, confident drift, clickbait, or a mismatch on intent/length is `review`.
41617
+ */
41618
+ const classifyMetaVerdict = (answers, threshold) => {
41619
+ if (anyInReviewBand([
41620
+ answers.intentFit.probability,
41621
+ answers.lengthOk.probability,
41622
+ answers.categoryDrift.probability,
41623
+ answers.clickbait.probability
41624
+ ], threshold)) return "review";
41625
+ if ((answers.best.probabilities[answers.best.label] ?? 0) < threshold) return "review";
41626
+ if (answers.intentFit.probability < threshold || answers.lengthOk.probability < threshold || answers.categoryDrift.probability >= threshold || answers.clickbait.probability >= threshold) return "review";
41627
+ return `choose:${answers.best.label}`;
41628
+ };
41629
+ const metaFamily = {
41630
+ name: "meta",
41631
+ input: MetaInput,
41632
+ definitionFor: makeMetaDecision,
41633
+ inputRef: (input) => input.url,
41634
+ evaluate: (_input, answers, threshold) => classifyMetaVerdict(answers, threshold),
41635
+ validate: validateMetaInput
41636
+ };
41637
+ //#endregion
41638
+ //#region src/decide/families/serp.ts
41639
+ /**
41640
+ * The serp family: is our page the shape this results page rewards?
41641
+ *
41642
+ * The model classifies the dominant SERP format and answers three probabilities
41643
+ * about our page. A confident match is `aligned`, a confident miss is `mismatch`,
41644
+ * and any answer inside the confidence band is `review` — the format question is
41645
+ * the one a human is most often needed to settle.
41646
+ */
41647
+ const SERP_FORMAT_CRITERIA = {
41648
+ definition: "A definition or explainer page: what X is and how it works.",
41649
+ tool: "An interactive tool, calculator, or generator.",
41650
+ comparison: "A comparison of options: X vs Y, or the best X.",
41651
+ listicle: "A ranked or numbered list of items.",
41652
+ docs: "Product, API, or reference documentation.",
41653
+ vendorLanding: "A vendor's marketing or landing page for its own product.",
41654
+ forum: "A community discussion: forum, Reddit, or Stack Overflow.",
41655
+ other: "None of the above."
41656
+ };
41657
+ /** One ranked result in the saved SERP snapshot. */
41658
+ const SerpItem = Struct({
41659
+ type: String$6,
41660
+ rank: Number$2,
41661
+ domain: String$6,
41662
+ title: optional$3(String$6),
41663
+ snippet: optional$3(String$6)
41664
+ });
41665
+ /** Our candidate page, plus the SERP snapshot for the query. */
41666
+ const SerpInput = Struct({
41667
+ query: String$6,
41668
+ ourPage: Struct({
41669
+ url: String$6,
41670
+ title: String$6,
41671
+ kind: String$6,
41672
+ firstWords: String$6
41673
+ }),
41674
+ serpItems: ArraySchema(SerpItem)
41675
+ });
41676
+ /** One provider call answers the SERP format and the three fit probabilities. */
41677
+ const SerpDecision = make({
41678
+ input: SerpInput,
41679
+ decisions: {
41680
+ serpFormat: classify({
41681
+ instructions: "Classify the dominant format of this results page from the ranking results. Weigh the top results most heavily.",
41682
+ criteria: SERP_FORMAT_CRITERIA
41683
+ }),
41684
+ ourFormatFit: probability({
41685
+ instructions: "Our page's format matches how this results page is won: the same shape a top result uses, not merely a related topic.",
41686
+ criteria: {
41687
+ false: "Our format is the wrong shape for this SERP.",
41688
+ true: "Our format is the shape this SERP rewards."
41689
+ }
41690
+ }),
41691
+ intentMatch: probability({
41692
+ instructions: "Our page satisfies the query's dominant intent: the reader's task after landing, not the literal keywords.",
41693
+ criteria: {
41694
+ false: "The query wants something our page does not provide.",
41695
+ true: "Our page provides what the query asks for."
41696
+ }
41697
+ }),
41698
+ titlePatternMatch: probability({
41699
+ instructions: "Our title follows the pattern the ranking titles use, including the qualifiers and framing a searcher expects.",
41700
+ criteria: {
41701
+ false: "Our title does not follow the ranking pattern.",
41702
+ true: "Our title follows the ranking pattern."
41703
+ }
41704
+ })
41705
+ }
41706
+ });
41707
+ /**
41708
+ * `aligned` needs both intent and format confident-positive; anything else that
41709
+ * is confident is `mismatch`. `titlePatternMatch` only widens the review band.
41710
+ */
41711
+ const classifySerpVerdict = (answers, threshold) => {
41712
+ const probabilities = [
41713
+ answers.ourFormatFit.probability,
41714
+ answers.intentMatch.probability,
41715
+ answers.titlePatternMatch.probability
41716
+ ];
41717
+ if (anyInReviewBand(probabilities, threshold)) return "review";
41718
+ return answers.intentMatch.probability >= threshold && answers.ourFormatFit.probability >= threshold ? "aligned" : "mismatch";
41719
+ };
41720
+ const serpFamily = {
41721
+ name: "serp",
41722
+ input: SerpInput,
41723
+ definitionFor: () => SerpDecision,
41724
+ inputRef: (input) => `${input.ourPage.url} · query:${input.query}`,
41725
+ evaluate: (_input, answers, threshold) => classifySerpVerdict(answers, threshold)
41726
+ };
41727
+ //#endregion
41728
+ //#region src/decide/inputs.ts
41729
+ /**
41730
+ * Batch input reading for `pagegraph decide <family>`. A batch is either a JSON
41731
+ * array, a `{ "inputs": [...] }` envelope, or newline-delimited JSON (one input
41732
+ * per non-empty line) — from a file argument or stdin. A TTY with no file is an
41733
+ * error: the command must never appear to hang waiting for input that will not
41734
+ * come from a pipe.
41735
+ */
41736
+ /** Parse a batch of raw inputs from text. Throws a plain `Error` on malformed input. */
41737
+ const decodeInputsText = (text) => {
41738
+ const trimmed = text.trim();
41739
+ if (trimmed.length === 0) throw new Error("no input provided");
41740
+ let parsed;
41741
+ try {
41742
+ parsed = JSON.parse(trimmed);
41743
+ } catch (error) {
41744
+ const lines = trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
41745
+ if (lines.length > 1) try {
41746
+ return lines.map((line) => JSON.parse(line));
41747
+ } catch {
41748
+ throw error;
41749
+ }
41750
+ throw error;
41751
+ }
41752
+ if (Array.isArray(parsed)) return parsed;
41753
+ if (parsed !== null && typeof parsed === "object") {
41754
+ const inputs = parsed.inputs;
41755
+ if (Array.isArray(inputs)) return inputs;
41756
+ return [parsed];
41757
+ }
41758
+ throw new Error("expected a JSON array, a { \"inputs\": [...] } envelope, or JSONL");
41759
+ };
41760
+ //#endregion
41761
+ //#region src/decide/render.ts
41762
+ /** One review-queue entry: what was asked, how it landed, and the raw answers. */
41763
+ const reviewLine = (record) => [` ${record.inputRef} · ${record.verdict}`, ` ${JSON.stringify(record.answers)}`].join("\n");
41764
+ /**
41765
+ * Human `decide` report: the batch header, a verdict breakdown, and the review
41766
+ * queue — the only part a human has to act on.
41767
+ */
41768
+ const renderDecideReport = (report) => {
41769
+ const lines = [
41770
+ `${report.counts.inputs} input(s) · family ${report.family} · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
41771
+ "",
41772
+ ` resolved ${report.counts.resolved}`,
41773
+ ` review ${report.counts.review}`,
41774
+ ""
41775
+ ];
41776
+ const verdicts = Object.entries(report.verdicts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
41777
+ if (verdicts.length > 0) {
41778
+ lines.push("Verdicts:", "");
41779
+ for (const [verdict, count] of verdicts) lines.push(` ${verdict.padEnd(20)} ${count}`);
41780
+ lines.push("");
41781
+ }
41782
+ if (report.review.length === 0) lines.push("No inputs need review.");
41783
+ else {
41784
+ lines.push(`Review queue (${report.review.length}):`, "");
41785
+ for (const record of report.review) lines.push(reviewLine(record), "");
41786
+ lines.pop();
41787
+ }
41788
+ return lines.join("\n").trimEnd();
41789
+ };
41790
+ //#endregion
41791
+ //#region src/links/decide.ts
41792
+ /**
41793
+ * Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
41794
+ *
41795
+ * A candidate is a `(source → destination)` pair with the source copy. The model
41796
+ * answers two probabilities — does the source have a genuine reader-facing
41797
+ * reason to link to the destination, and is descriptive anchor text already in
41798
+ * the copy — and this module maps them to a verdict. Anything the model is not
41799
+ * confident about lands in the `review` bucket; nothing is applied automatically.
41800
+ *
41801
+ * This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
41802
+ * the library entries stay Effect-free and only the bundled `pagegraph` bin
41803
+ * ships it.
41804
+ */
41805
+ /** Default probability boundary between a confident and an uncertain answer. */
41806
+ const DEFAULT_LINK_THRESHOLD = .7;
41807
+ /** Ordered relevance scale for budget selection. */
41808
+ const LINK_RELEVANCE = [
41809
+ "irrelevant",
41810
+ "useful",
41811
+ "essential"
41812
+ ];
41813
+ /** One proposed contextual link, plus the source copy the model reads. */
41814
+ const LinkCandidate = Struct({
41815
+ sourceUrl: String$6,
41816
+ destinationUrl: String$6,
41817
+ sourceText: String$6,
41818
+ /** Descriptive anchor text already present for the destination, when known. */
41819
+ existingAnchor: optional$3(String$6)
41820
+ });
41821
+ /**
41822
+ * The four decisions every candidate answers in one provider call: two
41823
+ * probabilities, a direction classification, and a relevance rating. The
41824
+ * probabilities decide the verdict; direction and relevance inform the plan and
41825
+ * the per-source budget.
41826
+ */
41827
+ const LinkDecision = make({
41828
+ input: LinkCandidate,
41829
+ decisions: {
41830
+ realReason: probability({
41831
+ instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
41832
+ criteria: {
41833
+ false: "No genuine reason; the link would be noise or only keyword overlap.",
41834
+ true: "A reader of the source would plausibly want the destination next."
41835
+ }
41836
+ }),
41837
+ anchorPresent: probability({
41838
+ instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
41839
+ criteria: {
41840
+ false: "No existing anchor to the destination in the copy.",
41841
+ true: "Descriptive anchor text to the destination is already in the copy."
41842
+ }
41843
+ }),
41844
+ direction: classify({
41845
+ instructions: "Which direction between the candidate pair deserves a link: source → destination, destination → source, or both.",
41846
+ criteria: {
41847
+ a_to_b: "The source page should link to the destination page.",
41848
+ b_to_a: "The destination page should link to the source page.",
41849
+ both: "Both directions are reader-useful."
41850
+ }
41851
+ }),
41852
+ relevance: rate({
41853
+ instructions: "How relevant this link is to the reader: irrelevant is noise, useful is a sensible next step, essential is a link the page is incomplete without.",
41854
+ criteria: LINK_RELEVANCE
41855
+ })
41856
+ }
41857
+ });
41858
+ /** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
41859
+ const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
41860
+ /**
41861
+ * Map the two probabilities to a verdict. A probability inside the review band
41862
+ * makes the whole candidate uncertain, so the confident half of the pair can
41863
+ * never auto-resolve it. Requires `threshold > 0.5`, so the review band
41864
+ * `(1 - threshold, threshold)` is non-empty.
41865
+ *
41866
+ * - `present` — descriptive anchor already in the copy; nothing to add.
41867
+ * - `recommend` — a real reason and no existing anchor; add a contextual link.
41868
+ * - `skip` — no real reason; leave the copy alone.
41869
+ * - `review` — at least one answer is inside the band; a human decides.
41870
+ */
41871
+ const classifyVerdict = (realReason, anchorPresent, threshold) => {
41872
+ if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
41873
+ if (anchorPresent >= threshold) return "present";
41874
+ if (realReason >= threshold) return "recommend";
41875
+ return "skip";
41876
+ };
41877
+ /**
41878
+ * Keep the top `budget` candidates per outbound page by relevance rating,
41879
+ * preserving the original order. The outbound page is `record.from`, which
41880
+ * honors `direction` — grouping by the candidate's `sourceUrl` would misspend the
41881
+ * quota when the model recommends the reverse direction. Ties keep their
41882
+ * original order (stable sort). Pages with fewer candidates pass through.
41883
+ */
41884
+ const applyBudget = (records, budget) => {
41885
+ const bySource = /* @__PURE__ */ new Map();
41886
+ for (const record of records) {
41887
+ const bucket = bySource.get(record.from);
41888
+ if (bucket === void 0) bySource.set(record.from, [record]);
41889
+ else bucket.push(record);
41890
+ }
41891
+ const kept = /* @__PURE__ */ new Set();
41892
+ for (const bucket of bySource.values()) {
41893
+ const top = [...bucket].sort((a, b) => b.relevance.rating - a.relevance.rating).slice(0, budget);
41894
+ for (const record of top) kept.add(record);
41895
+ }
41896
+ return records.filter((record) => kept.has(record));
41897
+ };
41898
+ /** Group records into resolved/review buckets and count each verdict. */
41899
+ const buildLinksDecideReport = (options) => {
41900
+ const counts = {
41901
+ recommend: 0,
41902
+ present: 0,
41903
+ skip: 0,
41904
+ review: 0
41905
+ };
41906
+ const resolved = [];
41907
+ const review = [];
41908
+ for (const record of options.records) {
41909
+ counts[record.verdict] += 1;
41910
+ if (record.verdict === "review") review.push(record);
41911
+ else resolved.push(record);
41912
+ }
41913
+ return {
41914
+ kind: "links-decide",
41915
+ schemaVersion: 1,
41916
+ model: options.model,
41917
+ threshold: options.threshold,
41918
+ budget: options.budget ?? null,
41919
+ dropped: options.dropped ?? 0,
41920
+ counts: {
41921
+ candidates: options.records.length,
41922
+ ...counts
41923
+ },
41924
+ resolved,
41925
+ review
41926
+ };
41927
+ };
41928
+ /**
41929
+ * The endpoints a `recommend` verdict actually directs. `b_to_a` reverses the
41930
+ * candidate pair, so the report names the direction the model chose rather than
41931
+ * always printing source → destination.
41932
+ */
41933
+ const endpoints = (candidate, direction) => direction === "b_to_a" ? {
41934
+ from: candidate.destinationUrl,
41935
+ to: candidate.sourceUrl
41936
+ } : {
41937
+ from: candidate.sourceUrl,
41938
+ to: candidate.destinationUrl
41939
+ };
41940
+ const toRecord = (candidate, answers, threshold) => {
41941
+ const realReason = answers.realReason.probability;
41942
+ const anchorPresent = answers.anchorPresent.probability;
41943
+ const direction = answers.direction.label;
41944
+ return {
41945
+ sourceUrl: candidate.sourceUrl,
41946
+ destinationUrl: candidate.destinationUrl,
41947
+ sourceText: candidate.sourceText,
41948
+ existingAnchor: candidate.existingAnchor ?? null,
41949
+ realReason,
41950
+ anchorPresent,
41951
+ direction,
41952
+ relevance: {
41953
+ rating: answers.relevance.rating,
41954
+ label: answers.relevance.label,
41955
+ probabilities: answers.relevance.probabilities
41956
+ },
41957
+ ...endpoints(candidate, direction),
41958
+ verdict: classifyVerdict(realReason, anchorPresent, threshold)
41959
+ };
41960
+ };
41961
+ /**
41962
+ * Answer every candidate through the ambient `DecisionModel` and bucket the
41963
+ * results. The model answers both decisions in one call per candidate; candidates
41964
+ * are dispatched with bounded concurrency. A warm cache skips the provider.
41965
+ */
41966
+ const decideLinks = (candidates, options) => gen(function* () {
41967
+ const records = yield* forEach$1(candidates, (candidate) => gen(function* () {
41968
+ const hash = inputHash(candidate);
41969
+ const key = cacheKey("links", options.model, hash);
41970
+ const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
41971
+ if (cached !== void 0 && validCachedAnswers(LinkDecision.decisions, cached)) return toRecord(candidate, cached, options.threshold);
41972
+ const { answers } = yield* decide(LinkDecision, { input: candidate });
41973
+ if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
41974
+ return toRecord(candidate, answers, options.threshold);
41975
+ }), { concurrency: options.concurrency ?? 4 });
41976
+ const selected = options.budget === void 0 ? records : applyBudget(records, options.budget);
41977
+ return buildLinksDecideReport({
41978
+ model: options.model,
41979
+ threshold: options.threshold,
41980
+ records: selected,
41981
+ budget: options.budget,
41982
+ dropped: records.length - selected.length
41983
+ });
41984
+ });
41985
+ /** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
41986
+ const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
41987
+ //#endregion
41988
+ //#region src/cli/commands/decide.ts
41989
+ /**
41990
+ * `pagegraph decide <family>` — the decision runner over a batch. Each family
41991
+ * subcommand reads a batch of inputs, answers every decision for an input in one
41992
+ * provider call, and separates confident verdicts from `review`.
41993
+ *
41994
+ * `decide links` is the links family alias of the legacy `pagegraph links decide`.
41995
+ * Effect and the TypeSafe provider live only in this CLI-internal command; the
41996
+ * library entries stay Effect-free.
41997
+ */
41998
+ /** Default probability boundary between a confident and an uncertain answer. */
41999
+ const DEFAULT_DECIDE_THRESHOLD = .7;
42000
+ const messageOf$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
42001
+ const positive$1 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
42002
+ const checkThreshold$1 = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
42003
+ const decideFile$1 = String$1("file").pipe(withDescription("JSON batch: an array, { inputs: [...] }, or JSONL (default: stdin)"), optional);
42004
+ const decideThresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_DECIDE_THRESHOLD));
42005
+ const modelFlag$1 = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
42006
+ const concurrencyFlag$1 = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
42007
+ const cacheFlag = String$2("cache").pipe(withDescription$2("Reuse model answers by input hash; directory for cache files"), optional$1);
42008
+ const reviewOutFlag = String$2("review-out").pipe(withDescription$2("Write only below-threshold records to this JSON file"), optional$1);
42009
+ const budgetFlag$1 = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
42010
+ /** Read the batch text from a file argument or stdin; a TTY with no file is an error. */
42011
+ const readInputsText = (file) => try_({
42012
+ try: () => {
42013
+ if (file !== void 0) return readFileSync(file, "utf8");
42014
+ if (process.stdin.isTTY) throw new Error("no input file given and stdin is a TTY");
42015
+ return readFileSync(0, "utf8");
42016
+ },
42017
+ catch: (cause) => new SeoCliError({ message: `Could not read inputs: ${messageOf$1(cause)}` })
42018
+ });
42019
+ /** Parse the batch text, then schema-decode every input against the family. */
42020
+ const parseInputs = (family, text) => try_({
42021
+ try: () => decodeFamilyInputs(family, decodeInputsText(text)),
42022
+ catch: (cause) => new SeoCliError({ message: `Invalid inputs: ${messageOf$1(cause)}` })
42023
+ });
42024
+ /** Apply a family's optional input guard to every decoded input. */
42025
+ const validateInputs = (family, inputs) => gen(function* () {
42026
+ if (family.validate === void 0) return;
42027
+ for (const input of inputs) {
42028
+ const problem = family.validate(input);
42029
+ if (problem !== void 0) return yield* new SeoCliError({ message: problem });
42030
+ }
42031
+ });
42032
+ const requireKey = (family) => process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "" ? fail$3(new SeoCliError({ message: `TYPESAFE_API_KEY is not set; \`pagegraph decide ${family}\` answers through TypeSafe System One.` })) : void_;
42033
+ /** TypeSafe System One decision model over the shared fetch HTTP client. */
42034
+ const typeSafeDecisionModel$1 = (model$2) => model(model$2).pipe(provide$2(layerConfig()), provide$2(layer$1));
42035
+ const decisionErrorMessage$1 = (error) => error._tag === "AiError" ? new SeoCliError({ message: `Decision request failed (${error.reason._tag}): ${error.message}` }) : new SeoCliError({ message: `TypeSafe configuration failed: ${error.message}` });
42036
+ /** Write the below-threshold records to `file`, creating its parent directory. */
42037
+ const writeReviewRecords = (file, payload) => file === void 0 ? void_ : try_({
42038
+ try: () => {
42039
+ mkdirSync(dirname(resolve(file)), { recursive: true });
42040
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
42041
+ },
42042
+ catch: (cause) => new SeoCliError({ message: `Could not write review records to ${file}: ${messageOf$1(cause)}` })
42043
+ });
42044
+ /** Run one family over a decoded batch and print its report. */
42045
+ const runFamily = (family, options) => gen(function* () {
42046
+ const threshold = yield* checkThreshold$1(options.threshold);
42047
+ const concurrency = yield* positive$1("concurrency", options.concurrency);
42048
+ const text = yield* readInputsText(getOrUndefined$1(options.file));
42049
+ const inputs = yield* parseInputs(family, text);
42050
+ yield* validateInputs(family, inputs);
42051
+ yield* requireKey(family.name);
42052
+ const report = yield* runDecisions({
42053
+ family,
42054
+ inputs,
42055
+ model: options.model,
42056
+ threshold,
42057
+ concurrency,
42058
+ cacheDir: getOrUndefined$1(options.cache)
42059
+ }).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
42060
+ yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
42061
+ family: report.family,
42062
+ model: report.model,
42063
+ threshold: report.threshold,
42064
+ review: report.review
42065
+ });
42066
+ if (options.json) yield* printJson(report);
42067
+ else yield* printText(renderDecideReport(report));
42068
+ });
42069
+ /** Parse the legacy link-candidate batch (array or `{ inputs }`). */
42070
+ const parseLinks = (text) => try_({
42071
+ try: () => decodeLinkCandidates(decodeInputsText(text)),
42072
+ catch: (cause) => new SeoCliError({ message: `Invalid candidates: ${messageOf$1(cause)}` })
42073
+ });
42074
+ /** `decide links` — the links family alias over the legacy `links decide` report. */
42075
+ const runLinks = (options) => gen(function* () {
42076
+ const threshold = yield* checkThreshold$1(options.threshold);
42077
+ const concurrency = yield* positive$1("concurrency", options.concurrency);
42078
+ const budget = yield* positive$1("budget", options.budget);
42079
+ const text = yield* readInputsText(getOrUndefined$1(options.file));
42080
+ const candidates = yield* parseLinks(text);
42081
+ yield* requireKey("links");
42082
+ const report = yield* decideLinks(candidates, {
42083
+ model: options.model,
42084
+ threshold,
42085
+ concurrency,
42086
+ cacheDir: getOrUndefined$1(options.cache),
42087
+ budget
42088
+ }).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
42089
+ yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
42090
+ family: "links",
42091
+ model: report.model,
42092
+ threshold: report.threshold,
42093
+ review: report.review
42094
+ });
42095
+ if (options.json) yield* printJson(report);
42096
+ else yield* printText(renderLinksDecideReport(report));
42097
+ });
42098
+ const decideCommand = (config) => make$9(config.name, {
42099
+ file: decideFile$1,
42100
+ json: jsonFlag,
42101
+ threshold: decideThresholdFlag,
42102
+ model: modelFlag$1,
42103
+ concurrency: concurrencyFlag$1,
42104
+ cache: cacheFlag,
42105
+ reviewOut: reviewOutFlag
42106
+ }).pipe(withDescription$1(config.description), withExamples(config.examples), withHandler((options) => config.run(options)));
42107
+ /** Build a family subcommand from its family and help text. */
42108
+ const familySubcommand = (family, config) => decideCommand({
42109
+ name: family.name,
42110
+ description: config.description,
42111
+ examples: config.examples,
42112
+ run: (options) => runFamily(family, options)
42113
+ });
42114
+ const serpCommand = familySubcommand(serpFamily, {
42115
+ description: "Classify the SERP format and score our page's fit, intent, and title (aligned | mismatch | review)",
42116
+ examples: [{
42117
+ command: "pagegraph decide serp serp.json",
42118
+ description: "Decide a saved SERP snapshot and print the reviewable plan"
42119
+ }, {
42120
+ command: "cat serp.json | pagegraph decide serp --json | jq",
42121
+ description: "Read the SERP batch from stdin and emit versioned JSON"
42122
+ }]
42123
+ });
42124
+ const contentCommand = familySubcommand(contentFamily, {
42125
+ description: "Score a page's content rubric and place its value on thin | adequate | strong (pass | flag | review)",
42126
+ examples: [{
42127
+ command: "pagegraph decide content page.json",
42128
+ description: "Decide a page's content quality and print the reviewable plan"
42129
+ }, {
42130
+ command: "cat page.json | pagegraph decide content --json | jq",
42131
+ description: "Read the content batch from stdin and emit versioned JSON"
42132
+ }]
42133
+ });
42134
+ const fitCommand = familySubcommand(fitFamily, {
42135
+ description: "Choose the best existing page for a query, or flag cannibalization or a gap (map | cannibalized | gap | review)",
42136
+ examples: [{
42137
+ command: "pagegraph decide fit candidates.json",
42138
+ description: "Rank existing pages for a query and print the reviewable plan"
42139
+ }, {
42140
+ command: "cat candidates.json | pagegraph decide fit --json | jq",
42141
+ description: "Read the fit batch from stdin and emit versioned JSON"
42142
+ }]
42143
+ });
42144
+ const metaCommand = familySubcommand(metaFamily, {
42145
+ description: "Rank supplied title/description candidates for a page (choose:<id> | review)",
42146
+ examples: [{
42147
+ command: "pagegraph decide meta meta.json",
42148
+ description: "Rank metadata candidates and print the chosen one or a review"
42149
+ }, {
42150
+ command: "cat meta.json | pagegraph decide meta --json | jq",
42151
+ description: "Read the meta batch from stdin and emit versioned JSON"
42152
+ }]
42153
+ });
42154
+ const authorityCommand = familySubcommand(authorityFamily, {
42155
+ description: "Judge a link target's legitimacy, spam, and outreach worth, plus its fit (accept | spam | review)",
42156
+ examples: [{
42157
+ command: "pagegraph decide authority targets.json",
42158
+ description: "Judge a batch of link targets and print the reviewable plan"
42159
+ }, {
42160
+ command: "cat targets.json | pagegraph decide authority --json | jq",
42161
+ description: "Read the authority batch from stdin and emit versioned JSON"
42162
+ }]
42163
+ });
42164
+ const linksDecideCommand$1 = make$9("links", {
42165
+ file: decideFile$1,
42166
+ json: jsonFlag,
42167
+ threshold: decideThresholdFlag,
42168
+ model: modelFlag$1,
42169
+ concurrency: concurrencyFlag$1,
42170
+ cache: cacheFlag,
42171
+ reviewOut: reviewOutFlag,
42172
+ budget: budgetFlag$1
42173
+ }).pipe(withDescription$1("Answer the links family: real reason, anchor present, direction, and relevance per candidate"), withExamples([
42174
+ {
42175
+ command: "pagegraph decide links candidates.json",
42176
+ description: "Answer a candidate batch and print the reviewable plan"
42177
+ },
42178
+ {
42179
+ command: "pagegraph decide links candidates.json --budget 2",
42180
+ description: "Keep the top two candidates per source by relevance"
42181
+ },
42182
+ {
42183
+ command: "cat candidates.json | pagegraph decide links --json | jq",
42184
+ description: "Read candidates from stdin and emit versioned JSON"
42185
+ }
42186
+ ]), withHandler((options) => runLinks(options)));
42187
+ /**
42188
+ * `pagegraph decide` groups the decision families. Each family answers one batch
42189
+ * of inputs through TypeSafe System One; confident verdicts resolve, answers in
42190
+ * the confidence band go to review, and nothing is applied automatically.
42191
+ */
42192
+ const decideCommandGroup = make$9("decide").pipe(withDescription$1("Answer calibrated SEO decisions over a batch with Jev"), withExamples([{
42193
+ command: "pagegraph decide links candidates.json --json | jq",
42194
+ description: "Decide a link-candidate batch and emit versioned JSON"
42195
+ }]), withSubcommands([
42196
+ serpCommand,
42197
+ contentCommand,
42198
+ fitCommand,
42199
+ metaCommand,
42200
+ authorityCommand,
42201
+ linksDecideCommand$1
42202
+ ]));
42203
+ //#endregion
42204
+ //#region src/cli/commands/diff.ts
42205
+ const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
42206
+ const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
42207
+ const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
42208
+ const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
42209
+ return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
42210
+ });
42211
+ const diffCommand = make$9("diff", {
42212
+ before,
42213
+ after,
42214
+ json: jsonFlag
42215
+ }).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
42216
+ command: "pagegraph diff before.json after.json",
42217
+ description: "Render a human-readable semantic comparison"
42218
+ }, {
42219
+ command: "pagegraph diff before.json after.json --json",
42220
+ description: "Emit one versioned JSON diff on stdout"
42221
+ }]), withHandler(fn("SeoCli.diff")(function* (options) {
42222
+ const beforeReport = yield* readAuditReport("before", options.before);
42223
+ const afterReport = yield* readAuditReport("after", options.after);
42224
+ const comparison = compareAuditReports(beforeReport, afterReport);
42225
+ if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
42226
+ if (options.json) yield* printJson(comparison.diff);
42227
+ else yield* printText(renderAuditDiff(comparison.diff));
42228
+ if (comparison.diff.outcome === "regressed") {
42229
+ const summary = comparison.diff.summary;
42230
+ return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
42231
+ }
42232
+ })));
42233
+ //#endregion
42234
+ //#region src/cli/serialize.ts
42235
+ const serializeCrumb = (crumb) => {
42236
+ if (crumb === void 0) return void 0;
42237
+ return typeof crumb === "function" ? "(dynamic)" : crumb;
42238
+ };
42239
+ /** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
42240
+ const serializeNode = (node) => ({
42241
+ path: node.path,
42242
+ kind: node.kind,
42243
+ source: node.source,
42244
+ policy: {
42245
+ kind: node.policy.kind,
42246
+ crumb: serializeCrumb(node.policy.crumb),
42247
+ sitemap: node.policy.sitemap,
42248
+ robots: node.policy.robots,
42249
+ related: node.policy.related,
42250
+ link: node.policy.link,
42251
+ redirectTo: node.policy.redirectTo
42252
+ },
42253
+ instance: node.instance
42254
+ });
42255
+ /** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
42256
+ const serializeGraph = (graph) => ({
42257
+ nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
42258
+ edges: graph.edges
42259
+ });
42260
+ const graphCommand = make$9("graph", {
42261
+ format: Literals("format", [
42262
+ "tree",
42263
+ "mermaid",
42264
+ "json"
42265
+ ]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
42266
+ orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
42267
+ }).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
42268
+ {
42269
+ command: "pagegraph graph",
42270
+ description: "The graph as an indented path tree"
42271
+ },
42272
+ {
42273
+ command: "pagegraph graph --format mermaid",
42274
+ description: "A Mermaid diagram of nodes and edges"
42275
+ },
42276
+ {
42277
+ command: "pagegraph graph --orphans",
42278
+ description: "Only nodes with no incoming edge"
42279
+ },
42280
+ {
42281
+ command: "pagegraph graph --format json",
42282
+ description: "The serialized graph on stdout"
42283
+ }
42284
+ ]), withHandler(fnUntraced(function* ({ format, orphans }) {
42285
+ const config = yield* loadSeoConfig;
42286
+ const graph = yield* scoped(acquireGraph(config));
42287
+ if (format === "json") {
42288
+ const serialized = serializeGraph(graph);
42289
+ if (!orphans) return yield* printJson(serialized);
42290
+ const orphanSet = orphanPaths(graph);
42291
+ return yield* printJson({
42292
+ nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
42293
+ edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
42294
+ });
42295
+ }
42296
+ if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
42297
+ return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
42298
+ })));
42299
+ //#endregion
42300
+ //#region src/core/inspect-html.ts
42301
+ const ENTITIES = {
42302
+ "&amp;": "&",
42303
+ "&lt;": "<",
42304
+ "&gt;": ">",
42305
+ "&quot;": "\"",
42306
+ "&#39;": "'",
42307
+ "&apos;": "'"
42308
+ };
42309
+ const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
42310
+ /** Pull double/single-quoted attributes off a single tag string. */
42311
+ const parseAttrs = (tag) => {
42312
+ const attrs = {};
42313
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
42314
+ let match;
42315
+ while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
42316
+ return attrs;
42317
+ };
42318
+ /** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
42319
+ const typeName = (value) => {
42320
+ if (typeof value === "string") return value;
42321
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
42322
+ return "unknown";
42323
+ };
42324
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
42325
+ /** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
42326
+ const collectItems = (parsed) => {
42327
+ if (Array.isArray(parsed)) return parsed.filter(isObject);
42328
+ if (isObject(parsed)) {
42329
+ if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
42330
+ return [parsed];
42331
+ }
42332
+ return [];
42333
+ };
42334
+ const countQuestions = (mainEntity) => {
42335
+ if (!Array.isArray(mainEntity)) return 0;
42336
+ return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
42337
+ };
42338
+ const validateItemListElements = (value) => {
42339
+ if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
42340
+ const errors = [];
42341
+ value.forEach((entry, index) => {
42342
+ if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
42343
+ errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
42344
+ return;
42345
+ }
42346
+ if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
42347
+ if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
42348
+ });
42349
+ return errors;
42350
+ };
42351
+ /** Minimal per-type validation — enough to catch an empty or malformed block. */
42352
+ const validateItem = (item) => {
42353
+ const type = typeName(item["@type"]);
42354
+ const errors = [];
42355
+ if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
42356
+ if (!item["headline"]) errors.push("Article is missing `headline`.");
42357
+ if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
42358
+ } else if (type === "FAQPage") {
42359
+ if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
42360
+ } else if (type === "BreadcrumbList") {
42361
+ const items = item["itemListElement"];
42362
+ if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
42363
+ } else if (type === "ItemList") {
42364
+ errors.push(...validateItemListElements(item["itemListElement"]));
42365
+ const items = item["itemListElement"];
42366
+ if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
42367
+ }
42368
+ return {
42369
+ type,
42370
+ valid: errors.length === 0,
42371
+ errors
42372
+ };
42373
+ };
42374
+ const validateLdJson = (raw) => {
42375
+ let parsed;
42376
+ try {
42377
+ parsed = JSON.parse(raw);
42378
+ } catch (cause) {
42379
+ return [{
42380
+ type: "unparseable",
42381
+ valid: false,
42382
+ errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
42383
+ }];
42384
+ }
42385
+ const items = collectItems(parsed);
42386
+ if (items.length === 0) return [{
42387
+ type: "unknown",
42388
+ valid: false,
42389
+ errors: ["No JSON-LD object found in block."]
42390
+ }];
42391
+ return items.map(validateItem);
42392
+ };
42393
+ /** Parse a rendered HTML document's `<head>` into a report. Pure. */
42394
+ const inspectHtml = (url, status, html) => {
42395
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
42396
+ const head = headMatch ? headMatch[1] : html;
42397
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
42398
+ const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
42399
+ const og = {};
42400
+ const twitter = {};
42401
+ let description;
42402
+ let robots;
42403
+ for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
42404
+ const attrs = parseAttrs(tag);
42405
+ const content = attrs["content"];
42406
+ if (content === void 0) continue;
42407
+ const property = attrs["property"];
42408
+ const name = attrs["name"];
42409
+ if (property?.startsWith("og:")) og[property] = content;
42410
+ else if (name?.startsWith("twitter:")) twitter[name] = content;
42411
+ else if (name === "description") description = content;
42412
+ else if (name === "robots") robots = content;
42413
+ }
42414
+ let canonical;
42415
+ for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
42416
+ const attrs = parseAttrs(tag);
42417
+ if (attrs["rel"] === "canonical") canonical = attrs["href"];
42418
+ }
42419
+ const jsonLd = [];
42420
+ const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
42421
+ let scriptMatch;
42422
+ while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
42423
+ const issues = [];
42424
+ if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
42425
+ if (!title) issues.push("Missing <title>.");
42426
+ if (!description) issues.push("Missing meta description.");
42427
+ if (!canonical) issues.push("Missing canonical link.");
42428
+ for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
42429
+ return {
42430
+ url,
42431
+ status,
42432
+ title,
42433
+ description,
42434
+ canonical,
42435
+ robots,
42436
+ og,
42437
+ twitter,
42438
+ jsonLd,
42439
+ issues
42440
+ };
42441
+ };
42442
+ /** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
42443
+ const hasBlockingIssues = (report) => report.issues.length > 0;
42444
+ //#endregion
42445
+ //#region src/cli/live-inspect.ts
42446
+ /**
42447
+ * `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
42448
+ * validator in `../inspect-html`. The fetch is all that lives here — the
42449
+ * validation is a library capability, not a CLI one, so it stays out of the
42450
+ * Effect-bearing half of the package.
42451
+ */
42452
+ /** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
42453
+ const fetchAndInspect = (url) => tryPromise({
42454
+ try: async () => {
42455
+ const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
42456
+ const html = await response.text();
42457
+ return inspectHtml(url, response.status, html);
42458
+ },
42459
+ catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
42460
+ });
42461
+ const inspectCommand = make$9("inspect", {
42462
+ target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
42463
+ live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
42464
+ json: jsonFlag
42465
+ }).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
42466
+ {
42467
+ command: "pagegraph inspect /pricing",
42468
+ description: "The graph node, policy, and edges for a path"
42469
+ },
42470
+ {
42471
+ command: "pagegraph inspect https://example.com/pricing --live",
42472
+ description: "Fetch the page and validate its head tags + JSON-LD"
42473
+ },
42474
+ {
42475
+ command: "pagegraph inspect /blog/some-post --json",
42476
+ description: "The node report as JSON"
42477
+ }
42478
+ ]), withHandler(fnUntraced(function* ({ target, live, json }) {
42479
+ if (live) {
42480
+ const report = yield* fetchAndInspect(target);
42481
+ if (json) yield* printJson(report);
42482
+ else yield* printText(renderLiveReport(report));
42483
+ if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
42484
+ return;
42485
+ }
42486
+ const config = yield* loadSeoConfig;
42487
+ const report = inspectNode(yield* scoped(acquireGraph(config)), target);
42488
+ if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
42489
+ if (json) return yield* printJson({
42490
+ node: serializeNode(report.node),
42491
+ inSitemap: report.inSitemap,
42492
+ incoming: report.incoming,
42493
+ outgoing: report.outgoing
42494
+ });
42495
+ return yield* printText(renderNodeReport(report));
42496
+ })));
42497
+ //#endregion
41055
42498
  //#region src/audit/crawl.ts
41056
42499
  /** Anchors that are never HTML documents; skipped so they do not consume the budget. */
41057
42500
  const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
@@ -41325,316 +42768,237 @@ const decodeRenderedEdges = (input) => {
41325
42768
  });
41326
42769
  };
41327
42770
  //#endregion
41328
- //#region node_modules/effect/dist/unstable/ai/Decision.js
41329
- /**
41330
- * Brand for decision definitions.
41331
- *
41332
- * @category type IDs
41333
- * @since 4.0.0
41334
- */
41335
- const TypeId = "~effect/ai/Decision";
41336
- /**
41337
- * Creates a probability decision from descriptions of both outcomes.
41338
- *
41339
- * **Example** (Estimating urgency)
41340
- *
41341
- * ```ts
41342
- * import { Decision } from "effect/unstable/ai"
41343
- *
41344
- * const urgent = Decision.probability({
41345
- * instructions: "The message is time-sensitive",
41346
- * criteria: {
41347
- * false: "No time pressure",
41348
- * true: "Needs action now"
41349
- * }
41350
- * })
41351
- * ```
41352
- *
41353
- * @see {@link classify} for more than two outcomes
41354
- *
41355
- * @category constructors
41356
- * @since 4.0.0
41357
- */
41358
- const probability = (options) => ({
41359
- _tag: "Probability",
41360
- instructions: options.instructions,
41361
- criteria: options.criteria
41362
- });
41363
- /**
41364
- * Creates a decision definition from an input schema and named decisions.
41365
- * `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
41366
- * calling the provider. Answer keys and types are inferred from the decisions.
41367
- * Throws if `decisions` is empty.
41368
- *
41369
- * **Example** (Defining ticket triage)
41370
- *
41371
- * ```ts
41372
- * import { Schema } from "effect"
41373
- * import { Decision } from "effect/unstable/ai"
41374
- *
41375
- * const Ticket = Schema.Struct({
41376
- * subject: Schema.String,
41377
- * body: Schema.String
41378
- * })
41379
- *
41380
- * const TicketTriage = Decision.make({
41381
- * input: Ticket,
41382
- * decisions: {
41383
- * department: Decision.classify({
41384
- * instructions: "Which team should handle this",
41385
- * criteria: { billing: "payments", technical: "bugs" }
41386
- * }),
41387
- * urgent: Decision.probability({
41388
- * instructions: "The message is time-sensitive",
41389
- * criteria: { false: "No time pressure", true: "Needs action now" }
41390
- * })
41391
- * }
41392
- * })
41393
- * ```
41394
- *
41395
- * @see {@link Definition} for the returned shape
41396
- *
41397
- * @category constructors
41398
- * @since 4.0.0
41399
- */
41400
- const make = (options) => {
41401
- if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
41402
- return {
41403
- [TypeId]: TypeId,
41404
- input: options.input,
41405
- decisions: options.decisions
41406
- };
41407
- };
41408
- //#endregion
41409
- //#region src/links/decide.ts
41410
- /**
41411
- * Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
41412
- *
41413
- * A candidate is a `(source → destination)` pair with the source copy. The model
41414
- * answers two probabilities — does the source have a genuine reader-facing
41415
- * reason to link to the destination, and is descriptive anchor text already in
41416
- * the copy — and this module maps them to a verdict. Anything the model is not
41417
- * confident about lands in the `review` bucket; nothing is applied automatically.
41418
- *
41419
- * This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
41420
- * the library entries stay Effect-free and only the bundled `pagegraph` bin
41421
- * ships it.
41422
- */
41423
- /** Default probability boundary between a confident and an uncertain answer. */
41424
- const DEFAULT_LINK_THRESHOLD = .7;
41425
- /** One proposed contextual link, plus the source copy the model reads. */
41426
- const LinkCandidate = Struct({
41427
- sourceUrl: String$6,
41428
- destinationUrl: String$6,
41429
- sourceText: String$6,
41430
- /** Descriptive anchor text already present for the destination, when known. */
41431
- existingAnchor: optional$3(String$6)
41432
- });
41433
- /**
41434
- * The two decisions every candidate answers. Both are probabilities, so the
41435
- * model returns a calibrated number rather than generated prose.
41436
- */
41437
- const LinkDecision = make({
41438
- input: LinkCandidate,
41439
- decisions: {
41440
- realReason: probability({
41441
- instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
41442
- criteria: {
41443
- false: "No genuine reason; the link would be noise or only keyword overlap.",
41444
- true: "A reader of the source would plausibly want the destination next."
41445
- }
41446
- }),
41447
- anchorPresent: probability({
41448
- instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
41449
- criteria: {
41450
- false: "No existing anchor to the destination in the copy.",
41451
- true: "Descriptive anchor text to the destination is already in the copy."
41452
- }
41453
- })
41454
- }
41455
- });
41456
- /** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
41457
- const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
41458
- /**
41459
- * Map the two probabilities to a verdict. A probability inside the review band
41460
- * makes the whole candidate uncertain, so the confident half of the pair can
41461
- * never auto-resolve it. Requires `threshold > 0.5`, so the review band
41462
- * `(1 - threshold, threshold)` is non-empty.
41463
- *
41464
- * - `present` — descriptive anchor already in the copy; nothing to add.
41465
- * - `recommend` — a real reason and no existing anchor; add a contextual link.
41466
- * - `skip` — no real reason; leave the copy alone.
41467
- * - `review` — at least one answer is inside the band; a human decides.
41468
- */
41469
- const classifyVerdict = (realReason, anchorPresent, threshold) => {
41470
- if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
41471
- if (anchorPresent >= threshold) return "present";
41472
- if (realReason >= threshold) return "recommend";
41473
- return "skip";
41474
- };
41475
- /** Group records into resolved/review buckets and count each verdict. */
41476
- const buildLinksDecideReport = (options) => {
41477
- const counts = {
41478
- recommend: 0,
41479
- present: 0,
41480
- skip: 0,
41481
- review: 0
41482
- };
41483
- const resolved = [];
41484
- const review = [];
41485
- for (const record of options.records) {
41486
- counts[record.verdict] += 1;
41487
- if (record.verdict === "review") review.push(record);
41488
- else resolved.push(record);
41489
- }
41490
- return {
41491
- kind: "links-decide",
41492
- schemaVersion: 1,
41493
- model: options.model,
41494
- threshold: options.threshold,
41495
- counts: {
41496
- candidates: options.records.length,
41497
- ...counts
41498
- },
41499
- resolved,
41500
- review
41501
- };
41502
- };
41503
- /**
41504
- * Answer every candidate through the ambient `DecisionModel` and bucket the
41505
- * results. The model answers both decisions in one call per candidate; candidates
41506
- * are dispatched with bounded concurrency.
41507
- */
41508
- const decideLinks = (candidates, options) => gen(function* () {
41509
- const records = yield* forEach$1(candidates, (candidate) => decide(LinkDecision, { input: candidate }).pipe(map$5(({ answers }) => {
41510
- const realReason = answers.realReason.probability;
41511
- const anchorPresent = answers.anchorPresent.probability;
41512
- return {
41513
- sourceUrl: candidate.sourceUrl,
41514
- destinationUrl: candidate.destinationUrl,
41515
- sourceText: candidate.sourceText,
41516
- existingAnchor: candidate.existingAnchor ?? null,
41517
- realReason,
41518
- anchorPresent,
41519
- verdict: classifyVerdict(realReason, anchorPresent, options.threshold)
41520
- };
41521
- })), { concurrency: options.concurrency ?? 4 });
41522
- return buildLinksDecideReport({
41523
- model: options.model,
41524
- threshold: options.threshold,
41525
- records
41526
- });
41527
- });
41528
- /** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
41529
- const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
41530
- //#endregion
41531
42771
  //#region src/cli/commands/links.ts
41532
42772
  const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
41533
42773
  const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
41534
- const urlArg = String$1("url").pipe(withDescription("Absolute http(s) URL of the site homepage to crawl"));
42774
+ const urlArg = String$1("url").pipe(withDescription("Absolute http(s) URL of the site homepage to crawl (omit with --rendered)"), optional);
41535
42775
  const limitFlag = Int("limit").pipe(withDescription$2("Maximum pages to fetch, including the homepage"), withDefault(100));
41536
42776
  const allowPrivate = Boolean$1("allow-private").pipe(withDescription$2("Allow localhost and private addresses (local development only)"), withDefault(false));
41537
42777
  const requestTimeoutMs = Int("request-timeout-ms").pipe(withDescription$2("HTTP request timeout in milliseconds"), withDefault(15e3));
41538
42778
  const maxBodyBytes = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes per page"), withDefault(2e6));
41539
42779
  /**
41540
- * The declared graph is optional input. A missing `seo.config.ts` means the
41541
- * rendered-only report; a config that exists but fails to load is a warning, not
41542
- * a failure, because verifying served HTML works on any deployed site. Origins
41543
- * are compared before diffing, so a graph from another project is never silently
41544
- * attributed to this crawl.
42780
+ * Load the app's declared graph when a `seo.config.ts` exists and derive the two
42781
+ * things `verify` needs from it: the declared `related` edges for the diff, and —
42782
+ * under `--assert-coverage` — the rendered coverage assertion. Both are computed
42783
+ * inside one scoped graph acquisition so the Vite loader is acquired once.
42784
+ *
42785
+ * Without `--assert-coverage` the declared graph is optional enrichment: a
42786
+ * missing or broken config, an invalid origin, or an origin mismatch each become a
42787
+ * warning, because verifying served HTML works on any deployed site. Under
42788
+ * `--assert-coverage` every one of those is a hard failure — an assertion must
42789
+ * know which pages the rules target and that this crawl is the same site, and a
42790
+ * truncated crawl is refused before the graph is even loaded.
41545
42791
  */
41546
- const loadDeclaredGraph = gen(function* () {
41547
- const config = yield* loadSeoConfigOptional;
41548
- if (config === void 0) return { kind: "absent" };
42792
+ const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState) => gen(function* () {
42793
+ if (assertCoverage) {
42794
+ if (crawlState.truncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage on a truncated crawl; re-run with a higher --limit." });
42795
+ if (crawlState.failures > 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: ${crawlState.failures} page(s) failed to fetch, so their anchors are missing.` });
42796
+ if (crawlState.bodyTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage when a crawled body was truncated; raise --max-body-bytes." });
42797
+ }
42798
+ const warnings = [];
42799
+ const config = yield* loadSeoConfigOptional.pipe(catchTag("SeoCliError", (error) => assertCoverage ? fail$3(error) : sync(() => {
42800
+ warnings.push(`Ignoring declared graph: ${error.message}`);
42801
+ })));
42802
+ if (config === void 0) {
42803
+ if (assertCoverage) return yield* new SeoCliError({ message: "Coverage assertion needs a seo.config.ts declaring `coverage` rules." });
42804
+ return {
42805
+ declaredEdges: void 0,
42806
+ coverage: void 0,
42807
+ warnings
42808
+ };
42809
+ }
41549
42810
  let configOrigin;
41550
42811
  try {
41551
42812
  configOrigin = new URL(config.origin).origin;
41552
42813
  } catch {
42814
+ const message = `Ignoring declared graph: seo.config.ts origin "${config.origin}" is not a valid URL.`;
42815
+ if (assertCoverage) return yield* new SeoCliError({ message });
42816
+ warnings.push(message);
41553
42817
  return {
41554
- kind: "warning",
41555
- message: `Ignoring declared graph: seo.config.ts origin "${config.origin}" is not a valid URL.`
42818
+ declaredEdges: void 0,
42819
+ coverage: void 0,
42820
+ warnings
41556
42821
  };
41557
42822
  }
41558
- const graph = yield* scoped(acquireGraph(config));
41559
- return {
41560
- kind: "loaded",
41561
- graph: {
41562
- origin: configOrigin,
41563
- edges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
42823
+ const rules = config.coverage ?? [];
42824
+ if (assertCoverage && rules.length === 0) return yield* new SeoCliError({ message: "seo.config.ts declares no `coverage` rules; nothing to assert." });
42825
+ if (configOrigin !== crawlOrigin) {
42826
+ const mismatch = `declared graph origin ${configOrigin} differs from crawled origin ${crawlOrigin}`;
42827
+ if (assertCoverage) return yield* new SeoCliError({ message: `Cannot assert coverage: ${mismatch}.` });
42828
+ warnings.push(`${mismatch.charAt(0).toUpperCase()}${mismatch.slice(1)}; skipping the declared-vs-rendered diff.`);
42829
+ return {
42830
+ declaredEdges: void 0,
42831
+ coverage: void 0,
42832
+ warnings
42833
+ };
42834
+ }
42835
+ return yield* scoped(gen(function* () {
42836
+ const graph = yield* acquireGraph(config);
42837
+ return {
42838
+ declaredEdges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
41564
42839
  from: edge.from,
41565
42840
  to: edge.to
41566
- }))
41567
- }
41568
- };
41569
- }).pipe(catchTag("SeoCliError", (error) => succeed$2({
41570
- kind: "warning",
41571
- message: `Ignoring declared graph: ${error.message}`
41572
- })));
42841
+ })),
42842
+ coverage: assertCoverage ? {
42843
+ rules: rules.length,
42844
+ violations: checkRenderedCoverage(graph, renderedEdges, rules)
42845
+ } : void 0,
42846
+ warnings
42847
+ };
42848
+ }));
42849
+ });
42850
+ /** Read and decode a saved rendered-edge artifact; the file is user input. */
42851
+ const readRenderedArtifact = (file) => try_({
42852
+ try: () => decodeRenderedEdgeArtifact(JSON.parse(readFileSync(file, "utf8"))),
42853
+ catch: (cause) => new SeoCliError({ message: `Could not read rendered artifact from ${file}: ${messageOf(cause)}` })
42854
+ });
42855
+ /** Write a rendered-edge artifact, creating its parent directory when needed. */
42856
+ const writeRenderedArtifact = (file, artifact) => try_({
42857
+ try: () => {
42858
+ mkdirSync(dirname(resolve(file)), { recursive: true });
42859
+ writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
42860
+ },
42861
+ catch: (cause) => new SeoCliError({ message: `Could not write rendered artifact to ${file}: ${messageOf(cause)}` })
42862
+ });
41573
42863
  const linksVerifyCommand = make$9("verify", {
41574
42864
  url: urlArg,
41575
42865
  json: jsonFlag,
41576
42866
  limit: limitFlag,
41577
42867
  allowPrivate,
41578
42868
  requestTimeoutMs,
41579
- maxBodyBytes
42869
+ maxBodyBytes,
42870
+ emitRendered: String$2("emit-rendered").pipe(withDescription$2("Write the crawled rendered-edge artifact to this path (crawl mode)"), optional$1),
42871
+ rendered: String$2("rendered").pipe(withDescription$2("Replay a saved rendered-edge artifact instead of crawling"), optional$1),
42872
+ assertCoverage: Boolean$1("assert-coverage").pipe(withDescription$2("Assert seo.config.ts coverage rules against the rendered anchors; exit 1 on unmet (refuses truncated crawls)"), withDefault(false))
41580
42873
  }).pipe(withDescription$1("Crawl served HTML and report homepage depth, rendered orphans, and the declared-vs-rendered gap"), withExamples([
41581
42874
  {
41582
42875
  command: "pagegraph links verify https://example.com",
41583
42876
  description: "Crawl the homepage and report the rendered link graph"
41584
42877
  },
41585
42878
  {
41586
- command: "pagegraph links verify https://example.com --limit 25",
41587
- description: "Bound the crawl to 25 pages"
42879
+ command: "pagegraph links verify https://example.com --limit 25 --json | jq",
42880
+ description: "Bound the crawl to 25 pages and emit versioned JSON"
42881
+ },
42882
+ {
42883
+ command: "pagegraph links verify https://example.com --emit-rendered rendered.json",
42884
+ description: "Crawl once and save the rendered-edge artifact"
42885
+ },
42886
+ {
42887
+ command: "pagegraph links verify --rendered rendered.json --assert-coverage",
42888
+ description: "Assert seo.config.ts coverage offline against a saved crawl (exit 1 on unmet)"
41588
42889
  },
41589
42890
  {
41590
- command: "pagegraph links verify https://example.com --json | jq",
41591
- description: "One versioned JSON report on stdout"
42891
+ command: "pagegraph links verify https://example.com --assert-coverage",
42892
+ description: "Crawl and assert coverage against the served anchors in one pass"
41592
42893
  },
41593
42894
  {
41594
42895
  command: "pagegraph links verify http://localhost:3000 --allow-private",
41595
42896
  description: "Verify a local app"
41596
42897
  }
41597
42898
  ]), withHandler(fn("SeoCli.linksVerify")(function* (options) {
41598
- const checkedLimit = yield* positive("limit", options.limit);
41599
- const checkedTimeout = yield* positive("request-timeout-ms", options.requestTimeoutMs);
41600
- const checkedMaxBody = yield* positive("max-body-bytes", options.maxBodyBytes);
41601
- const crawl = yield* tryPromise({
41602
- try: () => crawlRenderedPages(options.url, {
41603
- limit: checkedLimit,
41604
- timeoutMs: checkedTimeout,
41605
- maxBodyBytes: checkedMaxBody,
41606
- allowPrivate: options.allowPrivate
41607
- }),
41608
- catch: (cause) => new SeoCliError({ message: `Could not crawl ${options.url}: ${messageOf(cause)}` })
41609
- });
41610
- if (crawl.pages.length === 0) {
41611
- const reason = crawl.failures[0]?.error ?? "no pages fetched";
41612
- return yield* new SeoCliError({ message: `No page could be crawled at ${options.url}: ${reason}` });
41613
- }
41614
- const graph = buildRenderedGraph(crawl.origin, crawl.pages, crawl.root);
42899
+ const emitPath = getOrUndefined$1(options.emitRendered);
42900
+ const artifactPath = getOrUndefined$1(options.rendered);
42901
+ const url = getOrUndefined$1(options.url);
42902
+ if (emitPath !== void 0 && artifactPath !== void 0) return yield* new SeoCliError({ message: "--emit-rendered writes a fresh crawl; --rendered replays one. Use one or the other." });
42903
+ if (url === void 0 && artifactPath === void 0) return yield* new SeoCliError({ message: "Provide a homepage URL to crawl, or --rendered <path> to replay a saved crawl." });
41615
42904
  const warnings = [];
41616
- if (crawl.truncatedBodies.length > 0) warnings.push(`${crawl.truncatedBodies.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
41617
- const declared = yield* loadDeclaredGraph;
41618
- let declaredEdges;
41619
- if (declared.kind === "warning") warnings.push(declared.message);
41620
- if (declared.kind === "loaded") if (declared.graph.origin === crawl.origin) declaredEdges = declared.graph.edges;
41621
- else warnings.push(`Declared graph origin ${declared.graph.origin} differs from crawled origin ${crawl.origin}; skipping the declared-vs-rendered diff.`);
41622
- const diff = declaredEdges === void 0 ? void 0 : diffLinkGraph(declaredEdges, graph);
41623
- const report = {
41624
- kind: "links-verify",
41625
- schemaVersion: 1,
41626
- origin: crawl.origin,
41627
- seed: crawl.seed,
41628
- crawl: {
42905
+ let origin;
42906
+ let seed;
42907
+ let graph;
42908
+ let renderedEdges;
42909
+ let crawlReport;
42910
+ if (artifactPath !== void 0) {
42911
+ const artifact = yield* readRenderedArtifact(artifactPath);
42912
+ if (url !== void 0) {
42913
+ let requestedOrigin;
42914
+ try {
42915
+ requestedOrigin = new URL(url).origin;
42916
+ } catch {
42917
+ return yield* new SeoCliError({ message: `Not a valid URL: ${url}` });
42918
+ }
42919
+ if (requestedOrigin !== new URL(artifact.origin).origin) return yield* new SeoCliError({ message: `--rendered artifact origin ${artifact.origin} does not match ${url}.` });
42920
+ }
42921
+ origin = artifact.origin;
42922
+ seed = artifact.seed;
42923
+ graph = renderedGraphFromEdges(artifact.edges, artifact.crawl.root, artifact.nodes);
42924
+ renderedEdges = artifact.edges;
42925
+ crawlReport = {
42926
+ pages: artifact.crawl.pages,
42927
+ limit: artifact.crawl.limit,
42928
+ truncated: artifact.crawl.truncated,
42929
+ failures: artifact.crawl.failures,
42930
+ bodyTruncated: artifact.crawl.bodyTruncated,
42931
+ truncatedPages: artifact.crawl.truncatedPages
42932
+ };
42933
+ } else {
42934
+ const checkedLimit = yield* positive("limit", options.limit);
42935
+ const checkedTimeout = yield* positive("request-timeout-ms", options.requestTimeoutMs);
42936
+ const checkedMaxBody = yield* positive("max-body-bytes", options.maxBodyBytes);
42937
+ const crawl = yield* tryPromise({
42938
+ try: () => crawlRenderedPages(url, {
42939
+ limit: checkedLimit,
42940
+ timeoutMs: checkedTimeout,
42941
+ maxBodyBytes: checkedMaxBody,
42942
+ allowPrivate: options.allowPrivate
42943
+ }),
42944
+ catch: (cause) => new SeoCliError({ message: `Could not crawl ${url}: ${messageOf(cause)}` })
42945
+ });
42946
+ if (crawl.pages.length === 0) return yield* new SeoCliError({ message: `No page could be crawled at ${url}: ${crawl.failures[0]?.error ?? "no pages fetched"}` });
42947
+ graph = buildRenderedGraph(crawl.origin, crawl.pages, crawl.root);
42948
+ renderedEdges = graph.internalEdges;
42949
+ origin = crawl.origin;
42950
+ seed = crawl.seed;
42951
+ crawlReport = {
41629
42952
  pages: crawl.pages.length,
41630
42953
  limit: crawl.limit,
41631
42954
  truncated: crawl.truncated,
41632
42955
  failures: crawl.failures,
41633
42956
  bodyTruncated: crawl.truncatedBodies.length > 0,
41634
42957
  truncatedPages: crawl.truncatedBodies
41635
- },
42958
+ };
42959
+ if (emitPath !== void 0) {
42960
+ const artifact = {
42961
+ kind: "links-rendered",
42962
+ schemaVersion: 1,
42963
+ origin: crawl.origin,
42964
+ seed: crawl.seed,
42965
+ crawl: {
42966
+ pages: crawl.pages.length,
42967
+ root: crawl.root,
42968
+ limit: crawl.limit,
42969
+ truncated: crawl.truncated,
42970
+ bodyTruncated: crawl.truncatedBodies.length > 0,
42971
+ truncatedPages: crawl.truncatedBodies,
42972
+ failures: crawl.failures
42973
+ },
42974
+ nodes: graph.nodes,
42975
+ edges: graph.internalEdges
42976
+ };
42977
+ yield* writeRenderedArtifact(emitPath, artifact);
42978
+ yield* logInfo(`Wrote rendered edges to ${emitPath}`);
42979
+ }
42980
+ }
42981
+ if (crawlReport.truncatedPages.length > 0) warnings.push(`${crawlReport.truncatedPages.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
42982
+ const analysis = yield* analyzeDeclared(origin, renderedEdges, options.assertCoverage, {
42983
+ truncated: crawlReport.truncated,
42984
+ bodyTruncated: crawlReport.bodyTruncated,
42985
+ failures: crawlReport.failures.length
42986
+ });
42987
+ warnings.push(...analysis.warnings);
42988
+ const diff = analysis.declaredEdges === void 0 ? void 0 : diffLinkGraph(analysis.declaredEdges, graph);
42989
+ const coverage = analysis.coverage === void 0 ? void 0 : {
42990
+ rules: analysis.coverage.rules,
42991
+ ok: analysis.coverage.violations.length === 0,
42992
+ violations: analysis.coverage.violations
42993
+ };
42994
+ const report = {
42995
+ kind: "links-verify",
42996
+ schemaVersion: 1,
42997
+ origin,
42998
+ seed,
42999
+ crawl: crawlReport,
41636
43000
  rendered: {
41637
- pages: crawl.pages.length,
43001
+ pages: crawlReport.pages,
41638
43002
  internalEdges: graph.internalEdges.length,
41639
43003
  contextualEdges: graph.contextualEdges.length,
41640
43004
  maxDepth: graph.maxDepth,
@@ -41646,15 +43010,18 @@ const linksVerifyCommand = make$9("verify", {
41646
43010
  declaredNotRendered: diff.declaredNotRendered,
41647
43011
  renderedNotDeclared: diff.renderedNotDeclared
41648
43012
  },
43013
+ ...coverage === void 0 ? {} : { coverage },
41649
43014
  warnings
41650
43015
  };
41651
43016
  if (options.json) yield* printJson(report);
41652
43017
  else yield* printText(renderLinksReport(report));
43018
+ if (coverage !== void 0 && !coverage.ok) return yield* new SeoCliError({ message: `${coverage.violations.length} rendered coverage violation(s) — the served anchors do not satisfy seo.config.ts.` });
41653
43019
  })));
41654
43020
  const decideFile = String$1("file").pipe(withDescription("JSON file with an array of link candidates (default: stdin)"), optional);
41655
43021
  const thresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_LINK_THRESHOLD));
41656
43022
  const modelFlag = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
41657
43023
  const concurrencyFlag = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
43024
+ const budgetFlag = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
41658
43025
  const checkThreshold = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
41659
43026
  /** Read the candidate JSON from a file argument or stdin; a TTY with no file is an error. */
41660
43027
  const readCandidatesText = (file) => try_({
@@ -41678,8 +43045,9 @@ const linksDecideCommand = make$9("decide", {
41678
43045
  json: jsonFlag,
41679
43046
  threshold: thresholdFlag,
41680
43047
  model: modelFlag,
41681
- concurrency: concurrencyFlag
41682
- }).pipe(withDescription$1("Answer real-reason and anchor-present decisions for candidate links; below-threshold candidates go to review"), withExamples([
43048
+ concurrency: concurrencyFlag,
43049
+ budget: budgetFlag
43050
+ }).pipe(withDescription$1("Answer real-reason, anchor-present, direction, and relevance for candidate links; below-threshold candidates go to review"), withExamples([
41683
43051
  {
41684
43052
  command: "pagegraph links decide candidates.json",
41685
43053
  description: "Decide a candidate set and print the reviewable plan"
@@ -41695,13 +43063,15 @@ const linksDecideCommand = make$9("decide", {
41695
43063
  ]), withHandler(fn("SeoCli.linksDecide")(function* (options) {
41696
43064
  const threshold = yield* checkThreshold(options.threshold);
41697
43065
  const concurrency = yield* positive("concurrency", options.concurrency);
43066
+ const budget = yield* positive("budget", options.budget);
41698
43067
  const text = yield* readCandidatesText(getOrUndefined$1(options.file));
41699
43068
  const candidates = yield* parseCandidates(text);
41700
43069
  if (process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "") return yield* new SeoCliError({ message: "TYPESAFE_API_KEY is not set; `pagegraph links decide` answers through TypeSafe System One." });
41701
43070
  const report = yield* decideLinks(candidates, {
41702
43071
  model: options.model,
41703
43072
  threshold,
41704
- concurrency
43073
+ concurrency,
43074
+ budget
41705
43075
  }).pipe(provide(typeSafeDecisionModel(options.model)), mapError(decisionErrorMessage));
41706
43076
  if (options.json) yield* printJson(report);
41707
43077
  else yield* printText(renderLinksDecideReport(report));
@@ -41864,6 +43234,7 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect and audit a TanS
41864
43234
  }
41865
43235
  ]), withSubcommands([
41866
43236
  auditCommand,
43237
+ decideCommandGroup,
41867
43238
  diffCommand,
41868
43239
  graphCommand,
41869
43240
  inspectCommand,