@forkpoint/agent-lighthouse-core 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -72,9 +72,11 @@ __export(index_exports, {
72
72
  TAG_SCAN_ERROR: () => TAG_SCAN_ERROR,
73
73
  TAG_SKIPPED_NO_EVIDENCE: () => TAG_SKIPPED_NO_EVIDENCE,
74
74
  TAG_SKIPPED_PAGE_TYPE: () => TAG_SKIPPED_PAGE_TYPE,
75
+ TAG_SKIPPED_SCAN_BUDGET: () => TAG_SKIPPED_SCAN_BUDGET,
75
76
  allEvidenceMet: () => allEvidenceMet,
76
77
  allJsonLdNodes: () => allJsonLdNodes,
77
78
  boundedDispatcher: () => boundedDispatcher,
79
+ budgetReason: () => budgetReason,
78
80
  buildCategoryResult: () => buildCategoryResult,
79
81
  buildScanEvidence: () => buildScanEvidence,
80
82
  calculateCategoryScore: () => calculateCategoryScore,
@@ -106,6 +108,7 @@ __export(index_exports, {
106
108
  extractStylesheetUrls: () => extractStylesheetUrls,
107
109
  filterConfig: () => filterConfig,
108
110
  flattenJsonLd: () => flattenJsonLd,
111
+ formatBudget: () => formatBudget,
109
112
  formatTrace: () => formatTrace,
110
113
  getMainContentText: () => getMainContentText,
111
114
  getPreset: () => getPreset,
@@ -143,6 +146,7 @@ __export(index_exports, {
143
146
  runScan: () => runScan,
144
147
  sampleEntries: () => sampleEntries,
145
148
  shouldBypassOriginCache: () => shouldBypassOriginCache,
149
+ skippedMassShare: () => skippedMassShare,
146
150
  stripBom: () => stripBom,
147
151
  topLevelJsonLd: () => topLevelJsonLd,
148
152
  traceFromCheck: () => traceFromCheck,
@@ -157,13 +161,14 @@ var ORIGIN_EVIDENCE_VERSION = "v1";
157
161
  var DEFAULT_ORIGIN_CACHE_TTL_MS = 60 * 60 * 1e3;
158
162
  var DEFAULT_ORIGIN_CACHE_MAX_ENTRIES = 256;
159
163
  var REQUEST_TIMEOUT_MS = 1e4;
160
- var SCAN_TIMEOUT_MS = 6e4;
164
+ var SCAN_TIMEOUT_MS = 18e4;
161
165
  var MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
162
166
  var MAX_CONCURRENT_REQUESTS = 10;
163
167
  var SCANNER_USER_AGENT = "AgentLighthouse/1.0 (+https://github.com/ForkPoint/agent-lighthouse)";
164
168
  var TAG_SKIPPED_PAGE_TYPE = "skipped:page-type";
165
169
  var TAG_SCAN_ERROR = "scan-error";
166
170
  var TAG_SKIPPED_NO_EVIDENCE = "skipped:no-evidence";
171
+ var TAG_SKIPPED_SCAN_BUDGET = "skipped:scan-budget";
167
172
  var CATEGORY_NAMES = {
168
173
  "access-crawl-control": "Access & Crawl Control",
169
174
  "content-extraction": "Content Extraction",
@@ -1252,7 +1257,14 @@ var ScanConditionsSchema = import_zod.z.object({
1252
1257
  informativeCount: import_zod.z.number().int().nonnegative(),
1253
1258
  gatedCount: import_zod.z.number().int().nonnegative(),
1254
1259
  reasons: import_zod.z.record(import_zod.z.string(), import_zod.z.number().int().nonnegative())
1255
- })
1260
+ }),
1261
+ // Optional: reports written before the budget existed carry no block.
1262
+ budget: import_zod.z.object({
1263
+ limitMs: import_zod.z.number().nonnegative(),
1264
+ elapsedMs: import_zod.z.number().nonnegative(),
1265
+ exhausted: import_zod.z.boolean(),
1266
+ skippedCount: import_zod.z.number().int().nonnegative()
1267
+ }).optional()
1256
1268
  });
1257
1269
 
1258
1270
  // src/audit.ts
@@ -1463,16 +1475,19 @@ function calculateOverallScore(categories) {
1463
1475
  }
1464
1476
  var GATED_MASS_UNSCORED_THRESHOLD = 0.35;
1465
1477
  function gatedMassShare(checks2) {
1466
- let gated = 0;
1478
+ return skippedMassShare(checks2, TAG_SKIPPED_NO_EVIDENCE);
1479
+ }
1480
+ function skippedMassShare(checks2, tag2) {
1481
+ let skipped = 0;
1467
1482
  let total = 0;
1468
1483
  for (const check of checks2) {
1469
1484
  if (isInformative(check)) continue;
1470
1485
  const mass = check.weight ?? 0;
1471
1486
  if (mass <= 0) continue;
1472
1487
  total += mass;
1473
- if (check.tags?.includes(TAG_SKIPPED_NO_EVIDENCE)) gated += mass;
1488
+ if (check.tags?.includes(tag2)) skipped += mass;
1474
1489
  }
1475
- return total === 0 ? 0 : gated / total;
1490
+ return total === 0 ? 0 : skipped / total;
1476
1491
  }
1477
1492
 
1478
1493
  // src/audits/access-crawl-control/no-nofollow.ts
@@ -40776,6 +40791,7 @@ function outcomeOf(check) {
40776
40791
  if (tags.includes(TAG_SCAN_ERROR)) return "error";
40777
40792
  if (tags.includes(TAG_SKIPPED_PAGE_TYPE)) return "skipped";
40778
40793
  if (tags.includes(TAG_SKIPPED_NO_EVIDENCE)) return "gated";
40794
+ if (tags.includes(TAG_SKIPPED_SCAN_BUDGET)) return "budget";
40779
40795
  return "ran";
40780
40796
  }
40781
40797
  function traceFromCheck(check, durationMs) {
@@ -40928,9 +40944,28 @@ function planAudits(ctx, config, options = {}) {
40928
40944
  }
40929
40945
  return { runnable, skipped };
40930
40946
  }
40931
- async function runAudits(ctx, config, onEvent, plan, onTrace) {
40947
+ function formatBudget(ms) {
40948
+ return ms >= 1e3 ? `${Math.round(ms / 1e3)} s` : `${ms} ms`;
40949
+ }
40950
+ function budgetReason(signal) {
40951
+ const reason = signal.reason;
40952
+ if (reason instanceof Error && !(reason instanceof DOMException) && reason.message)
40953
+ return reason.message;
40954
+ return "The scan budget ran out.";
40955
+ }
40956
+ async function runAudits(ctx, config, onEvent, plan, onTrace, budget) {
40932
40957
  const { runnable, skipped } = plan ?? planAudits(ctx, config);
40933
40958
  const allChecks = [...skipped];
40959
+ const budgetStub = (reg2) => {
40960
+ const label3 = `${reg2.meta.id} ${reg2.meta.title}`;
40961
+ const stub = stubCheck(
40962
+ reg2.meta,
40963
+ TAG_SKIPPED_SCAN_BUDGET,
40964
+ `Not assessed: ${budgetReason(budget)} This audit had not started.`
40965
+ );
40966
+ if (typeof onEvent === "function") onEvent({ type: "unit:done", label: label3 });
40967
+ return stub;
40968
+ };
40934
40969
  const tracing = Boolean(onTrace) || logger.level === "debug";
40935
40970
  const trace = (check, durationMs) => {
40936
40971
  if (!tracing) return;
@@ -40941,9 +40976,22 @@ async function runAudits(ctx, config, onEvent, plan, onTrace) {
40941
40976
  for (const stub of skipped) trace(stub, 0);
40942
40977
  const batchSize = 20;
40943
40978
  for (let i = 0; i < runnable.length; i += batchSize) {
40979
+ if (budget?.aborted) {
40980
+ for (const { reg: reg2 } of runnable.slice(i)) {
40981
+ const stub = budgetStub(reg2);
40982
+ trace(stub, 0);
40983
+ allChecks.push(stub);
40984
+ }
40985
+ break;
40986
+ }
40944
40987
  const batch = runnable.slice(i, i + batchSize);
40945
40988
  const batchResults = await Promise.all(
40946
40989
  batch.map(async ({ reg: reg2, scopedPages, scoreDisplayMode }) => {
40990
+ if (budget?.aborted) {
40991
+ const stub = budgetStub(reg2);
40992
+ trace(stub, 0);
40993
+ return stub;
40994
+ }
40947
40995
  const label3 = `${reg2.meta.id} ${reg2.meta.title}`;
40948
40996
  const startedAt = tracing ? performance.now() : 0;
40949
40997
  const elapsed = () => tracing ? Math.round(performance.now() - startedAt) : 0;
@@ -40951,6 +40999,17 @@ async function runAudits(ctx, config, onEvent, plan, onTrace) {
40951
40999
  const instance = reg2.create();
40952
41000
  const scopedCtx = scopedPages && scopedPages !== ctx.pages ? { ...ctx, pages: scopedPages, cacheOwner: cacheOwner(ctx) } : ctx;
40953
41001
  const result = await instance.audit(scopedCtx);
41002
+ if (budget?.aborted) {
41003
+ const stub = stubCheck(
41004
+ reg2.meta,
41005
+ TAG_SKIPPED_SCAN_BUDGET,
41006
+ `Not assessed: ${budgetReason(budget)} This audit was still running.`
41007
+ );
41008
+ if (typeof onEvent === "function")
41009
+ onEvent({ type: "unit:done", label: label3 });
41010
+ trace(stub, elapsed());
41011
+ return stub;
41012
+ }
40954
41013
  const check = instance.toCheckResult(result, scoreDisplayMode);
40955
41014
  if (typeof onEvent === "function")
40956
41015
  onEvent({ type: "unit:done", label: label3 });
@@ -41280,8 +41339,22 @@ var A11Y_MAX_PAGES = Math.max(
41280
41339
  );
41281
41340
  var RATE_LIMIT_BACKOFF_MS = 5e3;
41282
41341
  var MAX_RETRY_AFTER_MS = 3e4;
41283
- async function fetchPageWithRetry(fetcher, url, signal) {
41284
- const first5 = await fetcher.fetch({ url, signal });
41342
+ function unsentResult(url, error) {
41343
+ return {
41344
+ url,
41345
+ finalUrl: url,
41346
+ status: 0,
41347
+ headers: {},
41348
+ body: "",
41349
+ ttfbMs: 0,
41350
+ totalMs: 0,
41351
+ contentType: "",
41352
+ contentLength: 0,
41353
+ error
41354
+ };
41355
+ }
41356
+ async function fetchPageWithRetry(fetcher, url, fetchSignal, cancel) {
41357
+ const first5 = await fetcher.fetch({ url, signal: fetchSignal });
41285
41358
  if (first5.status !== 429) return first5;
41286
41359
  const header = Number(first5.headers["retry-after"]);
41287
41360
  const waitMs = Number.isFinite(header) && header > 0 ? Math.min(header * 1e3, MAX_RETRY_AFTER_MS) : RATE_LIMIT_BACKOFF_MS;
@@ -41289,14 +41362,46 @@ async function fetchPageWithRetry(fetcher, url, signal) {
41289
41362
  { url, waitMs },
41290
41363
  `[orchestrator] Page answered 429; retrying once in ${waitMs}ms`
41291
41364
  );
41292
- await new Promise((resolve4) => setTimeout(resolve4, waitMs));
41293
- signal?.throwIfAborted();
41294
- return fetcher.fetch({ url, signal });
41365
+ if (!fetchSignal?.aborted)
41366
+ await new Promise((resolve4) => {
41367
+ const timer = setTimeout(done, waitMs);
41368
+ function done() {
41369
+ clearTimeout(timer);
41370
+ fetchSignal?.removeEventListener("abort", done);
41371
+ resolve4();
41372
+ }
41373
+ fetchSignal?.addEventListener("abort", done, { once: true });
41374
+ });
41375
+ cancel?.throwIfAborted();
41376
+ if (fetchSignal?.aborted) return first5;
41377
+ return fetcher.fetch({ url, signal: fetchSignal });
41295
41378
  }
41296
41379
  async function runScan(url, options) {
41380
+ const limitMs = options?.timeoutMs ?? SCAN_TIMEOUT_MS;
41381
+ if (!Number.isFinite(limitMs) || limitMs < 0)
41382
+ throw new RangeError(
41383
+ `timeoutMs must be a non-negative number of milliseconds, got ${String(options?.timeoutMs)}`
41384
+ );
41385
+ const budget = new AbortController();
41386
+ const timer = limitMs > 0 ? setTimeout(() => {
41387
+ const message = `The scan budget of ${formatBudget(limitMs)} ran out.`;
41388
+ logger.warn(`[orchestrator] ${message} ${splitCredentials(url).url}`);
41389
+ budget.abort(new Error(message));
41390
+ }, limitMs) : void 0;
41391
+ try {
41392
+ return await scanWithinBudget(url, options, {
41393
+ signal: budget.signal,
41394
+ limitMs
41395
+ });
41396
+ } finally {
41397
+ if (timer !== void 0) clearTimeout(timer);
41398
+ }
41399
+ }
41400
+ async function scanWithinBudget(url, options, budget) {
41297
41401
  const onEvent = options?.onEvent;
41298
41402
  const pageOverrides = options?.pages;
41299
41403
  const signal = options?.signal;
41404
+ const fetchSignal = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;
41300
41405
  const tracker = new ProgressTracker((event) => onEvent?.(event));
41301
41406
  const start = performance.now();
41302
41407
  const fetcher = createFetcher({
@@ -41394,7 +41499,7 @@ async function runScan(url, options) {
41394
41499
  tracker.unitDone(path);
41395
41500
  return Promise.resolve(prefetchedRobots);
41396
41501
  }
41397
- return fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
41502
+ return fetcher.fetch({ url: `${baseUrl}${path}`, signal: fetchSignal }).then((result) => {
41398
41503
  tracker.unitDone(path);
41399
41504
  return result;
41400
41505
  });
@@ -41405,7 +41510,7 @@ async function runScan(url, options) {
41405
41510
  rootFiles[path] = rootResults[i];
41406
41511
  });
41407
41512
  const isHomepageScan = url === `${baseUrl}/` || url === baseUrl;
41408
- originHomepageResult = isHomepageScan ? void 0 : await fetchPageWithRetry(fetcher, `${baseUrl}/`, signal);
41513
+ originHomepageResult = isHomepageScan ? void 0 : await fetchPageWithRetry(fetcher, `${baseUrl}/`, fetchSignal, signal);
41409
41514
  originReadAt = (/* @__PURE__ */ new Date()).toISOString();
41410
41515
  originFresh = true;
41411
41516
  tracker.phaseDone();
@@ -41414,7 +41519,12 @@ async function runScan(url, options) {
41414
41519
  signal?.throwIfAborted();
41415
41520
  logger.debug("[orchestrator] Phase 2: Fetching page");
41416
41521
  tracker.phaseStart("fetch-pages", 1 + overrideUrls.length);
41417
- const pageResult = await fetchPageWithRetry(fetcher, url, signal);
41522
+ const pageResult = await fetchPageWithRetry(
41523
+ fetcher,
41524
+ url,
41525
+ fetchSignal,
41526
+ signal
41527
+ );
41418
41528
  tracker.unitDone(displayUrl);
41419
41529
  if (!originHomepageResult && (url === `${baseUrl}/` || url === baseUrl)) {
41420
41530
  originHomepageResult = pageResult;
@@ -41430,7 +41540,7 @@ async function runScan(url, options) {
41430
41540
  }
41431
41541
  const extraResults = await Promise.all(
41432
41542
  overrideUrls.map(
41433
- (pageUrl) => fetcher.fetch({ url: pageUrl, signal }).then((result) => {
41543
+ (pageUrl) => fetcher.fetch({ url: pageUrl, signal: fetchSignal }).then((result) => {
41434
41544
  tracker.unitDone(pageUrl);
41435
41545
  return result;
41436
41546
  })
@@ -41508,7 +41618,11 @@ async function runScan(url, options) {
41508
41618
  pages,
41509
41619
  domain,
41510
41620
  baseUrl,
41511
- fetch: (options2) => fetcher.fetch({ ...options2, signal }),
41621
+ // Once the budget is gone no request leaves: the audit reads an error
41622
+ // result at once, the same shape a refused connection gives it.
41623
+ fetch: (options2) => budget.signal.aborted ? Promise.resolve(
41624
+ unsentResult(options2.url, budgetReason(budget.signal))
41625
+ ) : fetcher.fetch({ ...options2, signal: fetchSignal }),
41512
41626
  wafProtection: wafProtection ?? void 0,
41513
41627
  evidence,
41514
41628
  originEvidence: {
@@ -41539,9 +41653,12 @@ async function runScan(url, options) {
41539
41653
  else tracker.unitFail(event.label, event.error);
41540
41654
  },
41541
41655
  auditPlan,
41542
- options?.onAuditTrace
41656
+ options?.onAuditTrace,
41657
+ budget.signal
41543
41658
  );
41544
41659
  tracker.phaseDone();
41660
+ signal?.throwIfAborted();
41661
+ const budgetExhausted = budget.signal.aborted;
41545
41662
  logger.debug("[orchestrator] Phase 3 complete: Audits finished");
41546
41663
  tracker.phaseStart("report", 1);
41547
41664
  logger.debug("[orchestrator] Phase 4: Building final report");
@@ -41567,7 +41684,11 @@ async function runScan(url, options) {
41567
41684
  );
41568
41685
  const gatedShare = gatedMassShare(allChecks);
41569
41686
  const escalated = gatedShare > GATED_MASS_UNSCORED_THRESHOLD;
41570
- const unscoredReason = !evidence.judgeable ? unjudgeableReason(evidence) : escalated ? `The scan could not feed ${Math.round(gatedShare * 100)}% of the registry's evidence mass, so what remains is not a reading of this site.` : void 0;
41687
+ const budgetShare = skippedMassShare(allChecks, TAG_SKIPPED_SCAN_BUDGET);
41688
+ const unassessedShare = gatedShare + budgetShare;
41689
+ const cut = !escalated && budgetShare > 0 && unassessedShare > GATED_MASS_UNSCORED_THRESHOLD;
41690
+ const budgetSentence = budgetExhausted ? budgetReason(budget.signal) : "";
41691
+ const unscoredReason = !evidence.judgeable ? budgetExhausted ? `${budgetSentence} ${unjudgeableReason(evidence)}` : unjudgeableReason(evidence) : escalated ? `The scan could not feed ${Math.round(gatedShare * 100)}% of the registry's evidence mass, so what remains is not a reading of this site.` : cut ? `${budgetSentence} ${Math.round(unassessedShare * 100)}% of the registry's evidence mass was never assessed, so what remains is not a reading of this site.` : void 0;
41571
41692
  const scored = unscoredReason === void 0;
41572
41693
  const allScoredAudits = Object.values(config.audits).flat().filter((a) => a.meta.tier === "scored");
41573
41694
  let registryMass = 0;
@@ -41602,6 +41723,8 @@ async function runScan(url, options) {
41602
41723
  unscoredReasons["skipped-no-evidence"] = (unscoredReasons["skipped-no-evidence"] ?? 0) + 1;
41603
41724
  } else if (check.tags?.includes(TAG_SKIPPED_PAGE_TYPE)) {
41604
41725
  unscoredReasons["skipped-page-type"] = (unscoredReasons["skipped-page-type"] ?? 0) + 1;
41726
+ } else if (check.tags?.includes(TAG_SKIPPED_SCAN_BUDGET)) {
41727
+ unscoredReasons["skipped-scan-budget"] = (unscoredReasons["skipped-scan-budget"] ?? 0) + 1;
41605
41728
  } else {
41606
41729
  unscoredReasons["not-applicable"] = (unscoredReasons["not-applicable"] ?? 0) + 1;
41607
41730
  }
@@ -41639,6 +41762,17 @@ async function runScan(url, options) {
41639
41762
  informativeCount,
41640
41763
  gatedCount,
41641
41764
  reasons: unscoredReasons
41765
+ },
41766
+ // Counted over every check, advisory ones included: the reasons map
41767
+ // files an informative stub under `informative`, but the budget cut it
41768
+ // all the same.
41769
+ budget: {
41770
+ limitMs: budget.limitMs,
41771
+ elapsedMs: durationMs,
41772
+ exhausted: budgetExhausted,
41773
+ skippedCount: allChecks.filter(
41774
+ (c) => c.tags?.includes(TAG_SKIPPED_SCAN_BUDGET)
41775
+ ).length
41642
41776
  }
41643
41777
  };
41644
41778
  const report = {
@@ -41875,9 +42009,11 @@ function loadConfigFile(customPath) {
41875
42009
  TAG_SCAN_ERROR,
41876
42010
  TAG_SKIPPED_NO_EVIDENCE,
41877
42011
  TAG_SKIPPED_PAGE_TYPE,
42012
+ TAG_SKIPPED_SCAN_BUDGET,
41878
42013
  allEvidenceMet,
41879
42014
  allJsonLdNodes,
41880
42015
  boundedDispatcher,
42016
+ budgetReason,
41881
42017
  buildCategoryResult,
41882
42018
  buildScanEvidence,
41883
42019
  calculateCategoryScore,
@@ -41909,6 +42045,7 @@ function loadConfigFile(customPath) {
41909
42045
  extractStylesheetUrls,
41910
42046
  filterConfig,
41911
42047
  flattenJsonLd,
42048
+ formatBudget,
41912
42049
  formatTrace,
41913
42050
  getMainContentText,
41914
42051
  getPreset,
@@ -41946,6 +42083,7 @@ function loadConfigFile(customPath) {
41946
42083
  runScan,
41947
42084
  sampleEntries,
41948
42085
  shouldBypassOriginCache,
42086
+ skippedMassShare,
41949
42087
  stripBom,
41950
42088
  topLevelJsonLd,
41951
42089
  traceFromCheck,