@eightmedia/flightcheck 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/analyze/structuredReport.d.ts +9 -0
  3. package/dist/analyze/structuredReport.d.ts.map +1 -1
  4. package/dist/analyze/structuredReport.js +38 -15
  5. package/dist/analyze.js +6 -0
  6. package/dist/checks/agent-ready.d.ts.map +1 -1
  7. package/dist/checks/agent-ready.js +8 -8
  8. package/dist/checks/analyze.d.ts +4 -0
  9. package/dist/checks/analyze.d.ts.map +1 -1
  10. package/dist/checks/analyze.js +2 -0
  11. package/dist/checks/crawl.d.ts.map +1 -1
  12. package/dist/checks/crawl.js +11 -0
  13. package/dist/checks/dns.d.ts.map +1 -1
  14. package/dist/checks/dns.js +2 -2
  15. package/dist/checks/issues.d.ts +3 -1
  16. package/dist/checks/issues.d.ts.map +1 -1
  17. package/dist/checks/issues.js +2 -2
  18. package/dist/checks/redirect.d.ts.map +1 -1
  19. package/dist/checks/redirect.js +12 -12
  20. package/dist/checks/robots-txt.d.ts.map +1 -1
  21. package/dist/checks/robots-txt.js +6 -6
  22. package/dist/checks/security-headers.d.ts.map +1 -1
  23. package/dist/checks/security-headers.js +6 -6
  24. package/dist/checks/sitemap.d.ts.map +1 -1
  25. package/dist/checks/sitemap.js +6 -6
  26. package/dist/cli.js +7 -1
  27. package/dist/crawler/defaults.d.ts +2 -1
  28. package/dist/crawler/defaults.d.ts.map +1 -1
  29. package/dist/crawler/defaults.js +2 -1
  30. package/dist/crawler/runCrawler.d.ts.map +1 -1
  31. package/dist/crawler/runCrawler.js +12 -3
  32. package/dist/crawler/smartPageSelection.d.ts +77 -0
  33. package/dist/crawler/smartPageSelection.d.ts.map +1 -0
  34. package/dist/crawler/smartPageSelection.js +308 -0
  35. package/dist/version.d.ts +9 -0
  36. package/dist/version.d.ts.map +1 -0
  37. package/dist/version.js +24 -0
  38. package/docs/agent-fixes.md +5 -7
  39. package/docs/agent-ready.md +2 -2
  40. package/docs/analyze.md +19 -7
  41. package/docs/crawler.md +15 -2
  42. package/docs/index.md +2 -4
  43. package/docs/qc-checks-plan.md +5 -4
  44. package/docs/stack.md +11 -18
  45. package/package.json +1 -1
@@ -16,12 +16,12 @@ export function analyzeSitemapCompare(input) {
16
16
  const issues = [];
17
17
  for (const source of input.sources) {
18
18
  if (source.status === null || source.status >= 400) {
19
- issues.push(issue("sitemap-unreachable", "error", `Sitemap not reachable: ${source.url} (${source.status ?? "unreachable"}).`));
19
+ issues.push(issue("sitemap-unreachable", "error", `Sitemap not reachable: ${source.url} (${source.status ?? "unreachable"}).`, `Ensure ${source.url} returns HTTP 200 with a valid XML sitemap or sitemap index.`));
20
20
  }
21
21
  }
22
22
  const successfulSources = input.sources.filter((source) => source.status !== null && source.status >= 200 && source.status < 300);
23
23
  if (successfulSources.length === 0) {
24
- issues.push(issue("sitemap-missing", "error", "No sitemap found."));
24
+ issues.push(issue("sitemap-missing", "error", "No sitemap found.", "Publish /sitemap.xml (or reference it from robots.txt) and make sure it returns HTTP 200."));
25
25
  return {
26
26
  sources: input.sources,
27
27
  sitemapPageUrls: [],
@@ -35,7 +35,7 @@ export function analyzeSitemapCompare(input) {
35
35
  const sitemapPageUrls = filterComparableUrls(successfulSources.flatMap((source) => source.pageUrls), excludePatterns);
36
36
  const crawledUrls = filterComparableUrls(input.crawledUrls, excludePatterns);
37
37
  if (sitemapPageUrls.length === 0) {
38
- issues.push(issue("sitemap-empty", "error", "Sitemap was reachable but contained no page URLs (check sitemap index nesting and host aliases such as apex vs www)."));
38
+ issues.push(issue("sitemap-empty", "error", "Sitemap was reachable but contained no page URLs (check sitemap index nesting and host aliases such as apex vs www).", "Populate the sitemap with page `<loc>` entries, or fix nested sitemap indexes and apex/www host mismatches."));
39
39
  return {
40
40
  sources: input.sources,
41
41
  sitemapPageUrls,
@@ -51,17 +51,17 @@ export function analyzeSitemapCompare(input) {
51
51
  const inSitemapNotCrawled = sitemapPageUrls.filter((url) => !crawledSet.has(url));
52
52
  const crawledNotInSitemap = crawledUrls.filter((url) => !sitemapSet.has(url));
53
53
  if (inSitemapNotCrawled.length > 0) {
54
- issues.push(issue("sitemap-url-not-crawled", "critical", `${inSitemapNotCrawled.length} sitemap URL(s) were not crawled. ${SITEMAP_URL_NOT_CRAWLED_TIP}`));
54
+ issues.push(issue("sitemap-url-not-crawled", "critical", `${inSitemapNotCrawled.length} sitemap URL(s) were not crawled. ${SITEMAP_URL_NOT_CRAWLED_TIP}`, "Remove stale sitemap entries, fix robots blocks, or raise --max-pages so listed URLs can be crawled."));
55
55
  }
56
56
  if (crawledNotInSitemap.length > 0) {
57
- issues.push(issue("sitemap-missing-crawled-url", "critical", `${crawledNotInSitemap.length} crawled URL(s) are missing from the sitemap.`));
57
+ issues.push(issue("sitemap-missing-crawled-url", "critical", `${crawledNotInSitemap.length} crawled URL(s) are missing from the sitemap.`, "Add the crawled URLs to the sitemap (or regenerate the sitemap from the live route set)."));
58
58
  }
59
59
  const total = new Set([...sitemapPageUrls, ...crawledUrls]).size;
60
60
  const mismatchCount = new Set([...inSitemapNotCrawled, ...crawledNotInSitemap]).size;
61
61
  const mismatchPercent = total === 0 ? 0 : (mismatchCount / total) * 100;
62
62
  if (mismatchPercent > threshold) {
63
63
  issues.push(issue("sitemap-mismatch-threshold", "critical", `Sitemap mismatch is ${mismatchPercent.toFixed(1)}% (threshold ${threshold}%). ` +
64
- "Sitemap URLs must match crawled URLs exactly, including hostname."));
64
+ "Sitemap URLs must match crawled URLs exactly, including hostname.", "Align sitemap hostnames and paths with the live site (apex vs www) and regenerate the sitemap."));
65
65
  }
66
66
  return {
67
67
  sources: input.sources,
package/dist/cli.js CHANGED
@@ -5,15 +5,19 @@ import { DEFAULT_CHECK_IDS } from "./checks/types.js";
5
5
  import { DEFAULT_BURST_DELAY_MS, DEFAULT_CONCURRENCY, DEFAULT_DELAY_MS, DEFAULT_MAX_PAGES, DEFAULT_TIMEOUT_MS, } from "./crawler/defaults.js";
6
6
  import { buildDefaultOutputPaths } from "./crawler/outputPaths.js";
7
7
  import { isHttpUrl, normalizeUrl, parseExcludePatterns } from "./crawler/urlUtils.js";
8
+ import { cliFlagsFromArgv, getPackageVersion } from "./version.js";
8
9
  async function main() {
9
10
  const sanitizedArgv = [...process.argv];
10
11
  const separatorIndex = sanitizedArgv.indexOf("--");
11
12
  if (separatorIndex >= 2) {
12
13
  sanitizedArgv.splice(separatorIndex, 1);
13
14
  }
15
+ const runFlags = cliFlagsFromArgv(sanitizedArgv);
16
+ const flightcheckVersion = getPackageVersion();
14
17
  const program = new Command();
15
18
  program
16
19
  .name("flightcheck")
20
+ .version(flightcheckVersion)
17
21
  .description("Crawl a website and generate crawl JSON plus structured report JSON/Markdown.")
18
22
  .argument("<startUrl>", "Start URL for the crawl")
19
23
  .option("--output <file>", "Output crawl JSON file path")
@@ -22,7 +26,7 @@ async function main() {
22
26
  .option("--errors-only", "Include only critical/error findings in the Markdown report", false)
23
27
  .option("--parallel", "Run independent checks in parallel", false)
24
28
  .option("--checks <ids>", "Comma-separated checks to run (crawl,analyze,lighthouse,security-headers,dns,robots-txt,sitemap,agent-ready)")
25
- .option("--max-pages <n>", "Maximum number of pages", String(DEFAULT_MAX_PAGES))
29
+ .option("--max-pages <n>", "Maximum pages to crawl (default 100; prefers structural pages, samples leaf URLs under large list branches)", String(DEFAULT_MAX_PAGES))
26
30
  .option("--delay-ms <n>", "Delay between requests in ms", String(DEFAULT_DELAY_MS))
27
31
  .option("--timeout-ms <n>", "Request timeout in ms", String(DEFAULT_TIMEOUT_MS))
28
32
  .option("--concurrency <n>", "Maximum concurrent requests", String(DEFAULT_CONCURRENCY))
@@ -79,6 +83,8 @@ async function main() {
79
83
  reportJsonPath: defaultPaths.reportJson,
80
84
  errorsOnly,
81
85
  siteUrl: normalizedStartUrl,
86
+ flightcheckVersion,
87
+ flags: runFlags,
82
88
  },
83
89
  });
84
90
  const crawlResult = outputs.crawl;
@@ -1,4 +1,5 @@
1
- export declare const DEFAULT_MAX_PAGES = 1000;
1
+ /** Default crawl budget; smart selection prefers root/list pages within this cap. */
2
+ export declare const DEFAULT_MAX_PAGES = 100;
2
3
  export declare const DEFAULT_DELAY_MS = 500;
3
4
  export declare const DEFAULT_TIMEOUT_MS = 10000;
4
5
  export declare const DEFAULT_CONCURRENCY = 1;
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/crawler/defaults.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AACxC,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,sBAAsB,IAAI,CAAC;AACxC,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAClD,eAAO,MAAM,0BAA0B,iBAAiB,CAAC;AACzD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAC9C,eAAO,MAAM,kBAAkB,2CAA2C,CAAC"}
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/crawler/defaults.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AACxC,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,sBAAsB,IAAI,CAAC;AACxC,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAClD,eAAO,MAAM,0BAA0B,iBAAiB,CAAC;AACzD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAC9C,eAAO,MAAM,kBAAkB,2CAA2C,CAAC"}
@@ -1,4 +1,5 @@
1
- export const DEFAULT_MAX_PAGES = 1000;
1
+ /** Default crawl budget; smart selection prefers root/list pages within this cap. */
2
+ export const DEFAULT_MAX_PAGES = 100;
2
3
  export const DEFAULT_DELAY_MS = 500;
3
4
  export const DEFAULT_TIMEOUT_MS = 10000;
4
5
  export const DEFAULT_CONCURRENCY = 1;
@@ -1 +1 @@
1
- {"version":3,"file":"runCrawler.d.ts","sourceRoot":"","sources":["../../src/crawler/runCrawler.ts"],"names":[],"mappings":"AAUA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAOjE,OAAO,KAAK,EAAiB,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAKrE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,YAAY,CAAC,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAC/B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,CAAC,CAqK7H"}
1
+ {"version":3,"file":"runCrawler.d.ts","sourceRoot":"","sources":["../../src/crawler/runCrawler.ts"],"names":[],"mappings":"AAUA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAOjE,OAAO,KAAK,EAAiB,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAMrE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,YAAY,CAAC,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAC/B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,CAAC,CA4K7H"}
@@ -8,6 +8,7 @@ import { extractSameOriginScripts } from "./jsAssets.js";
8
8
  import { loadRobotsPolicy } from "./robots.js";
9
9
  import { writeReportToFile } from "./reporter.js";
10
10
  import { scanText } from "./secrets.js";
11
+ import { createDiscoveryGate } from "./smartPageSelection.js";
11
12
  import { matchesExcludePattern, normalizeUrl, sleep } from "./urlUtils.js";
12
13
  const MAX_JS_ASSET_BYTES = 5_000_000; // 5 MB — skip larger bundles to bound memory
13
14
  export async function runCrawler(options) {
@@ -84,10 +85,14 @@ export async function runCrawler(options) {
84
85
  const requestQueue = await RequestQueue.open();
85
86
  const records = [];
86
87
  const seen = new Set();
88
+ const discovery = createDiscoveryGate(seedUrls, maxPages);
89
+ // Seeds are already smart-ordered (home/root/list before details); FIFO preserves that.
87
90
  for (const seed of seedUrls) {
88
91
  if (!matchesExcludePattern(seed, excludePatterns)) {
89
- await requestQueue.addRequest({ url: seed, uniqueKey: normalizeUrl(seed) });
90
- seen.add(normalizeUrl(seed));
92
+ const normalized = normalizeUrl(seed);
93
+ await requestQueue.addRequest({ url: seed, uniqueKey: normalized });
94
+ seen.add(normalized);
95
+ discovery.remember(normalized);
91
96
  }
92
97
  }
93
98
  let processedSinceBurst = 0;
@@ -151,8 +156,12 @@ export async function runCrawler(options) {
151
156
  continue;
152
157
  if (robotsPolicy && !robotsPolicy.isAllowed(normalized))
153
158
  continue;
159
+ if (!discovery.allowEnqueue(normalized))
160
+ continue;
154
161
  seen.add(normalized);
155
- await requestQueue.addRequest({ url: normalized, uniqueKey: normalized });
162
+ discovery.remember(normalized);
163
+ const forefront = discovery.isStructural(normalized);
164
+ await requestQueue.addRequest({ url: normalized, uniqueKey: normalized }, { forefront });
156
165
  }
157
166
  }
158
167
  },
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Smart crawl URL selection based on URL-tree shape (not slug names).
3
+ *
4
+ * A "list" parent is a path with many direct children that are mostly leaves
5
+ * (no further subpages) — typical for archives like news/events/blog, whatever
6
+ * they are named. Within --max-pages we keep structural pages and sample
7
+ * leaves under each list parent.
8
+ */
9
+ export type UrlKind = "home" | "root" | "list" | "detail" | "other";
10
+ export type ClassifiedUrl = {
11
+ url: string;
12
+ kind: UrlKind;
13
+ /** Pathname of the list parent when kind is list/detail, e.g. "/stories". */
14
+ section: string | null;
15
+ /** Sort priority (higher = sooner). */
16
+ priority: number;
17
+ };
18
+ export type UrlTreeAnalysis = {
19
+ /** Pathnames that look like list indexes (many leaf children). */
20
+ listParents: ReadonlySet<string>;
21
+ /** Origin used to rebuild URLs from pathnames when needed. */
22
+ origin: string;
23
+ };
24
+ /** Minimum direct leaf children before a path is treated as a list parent. */
25
+ export declare const DEFAULT_LIST_MIN_CHILDREN = 8;
26
+ /** Share of direct children that must be leaves to count as a list. */
27
+ export declare const DEFAULT_LIST_LEAF_RATIO = 0.7;
28
+ export declare function pathSegments(urlOrPath: string): string[];
29
+ export declare function pathnameOf(urlString: string): string;
30
+ /** Parent pathname: `/a/b` → `/a`, `/a` → `/`, `/` → null. */
31
+ export declare function parentPathname(pathname: string): string | null;
32
+ /**
33
+ * Build parent → direct children maps from a URL set and detect list parents:
34
+ * paths with many children that are mostly leaves (no further children in the set).
35
+ */
36
+ export declare function analyzeUrlTree(urls: string[], options?: {
37
+ startUrl?: string;
38
+ listMinChildren?: number;
39
+ listLeafRatio?: number;
40
+ }): UrlTreeAnalysis;
41
+ /** Closest list-parent pathname for this path, or null. */
42
+ export declare function listParentForPathname(pathname: string, listParents: ReadonlySet<string>): string | null;
43
+ export declare function classifyCrawlUrl(urlString: string, tree: UrlTreeAnalysis): ClassifiedUrl;
44
+ export declare function crawlRequestPriority(urlString: string, tree: UrlTreeAnalysis): number;
45
+ /**
46
+ * Soft per-list-parent cap for detail URLs discovered during a crawl.
47
+ * Structural pages (home/root/list) are never capped this way.
48
+ */
49
+ export declare function detailCapPerSection(maxPages: number, listParentCount?: number): number;
50
+ /**
51
+ * Pick up to `maxPages` URLs using URL-tree shape: keep home/root/list pages,
52
+ * then round-robin a sample of leaf details under each detected list parent.
53
+ */
54
+ export declare function selectSmartCrawlUrls(urls: string[], maxPages: number, options?: {
55
+ startUrl?: string;
56
+ listMinChildren?: number;
57
+ listLeafRatio?: number;
58
+ }): {
59
+ urls: string[];
60
+ tree: UrlTreeAnalysis;
61
+ };
62
+ /** Sort URLs by crawl priority (desc), stable for equal priority. */
63
+ export declare function sortByCrawlPriority(urls: string[], tree: UrlTreeAnalysis): string[];
64
+ /**
65
+ * Tracks list parents during a live crawl: uses seed analysis up front, and
66
+ * promotes parents to "list" when enough leaf-like children are observed.
67
+ */
68
+ export declare function createDiscoveryGate(seedUrls: string[], maxPages: number, options?: {
69
+ listMinChildren?: number;
70
+ listLeafRatio?: number;
71
+ }): {
72
+ tree: UrlTreeAnalysis;
73
+ allowEnqueue: (url: string) => boolean;
74
+ remember: (url: string) => void;
75
+ isStructural: (url: string) => boolean;
76
+ };
77
+ //# sourceMappingURL=smartPageSelection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smartPageSelection.d.ts","sourceRoot":"","sources":["../../src/crawler/smartPageSelection.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpE,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,OAAO,CAAC;IACd,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,kEAAkE;IAClE,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACjC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C,uEAAuE;AACvE,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAU3C,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAOxD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQpD;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK9D;AA2BD;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE;IACR,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACA,eAAe,CA2CjB;AAED,2DAA2D;AAC3D,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B,MAAM,GAAG,IAAI,CAOf;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,aAAa,CAsBxF;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,CAErF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,eAAe,SAAI,GAAG,MAAM,CAIjF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EAAE,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACR,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACA;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,eAAe,CAAA;CAAE,CAmE3C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,EAAE,CAKnF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,EAAE,EAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7D;IACD,IAAI,EAAE,eAAe,CAAC;IACtB,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACvC,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CACxC,CAqEA"}
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Smart crawl URL selection based on URL-tree shape (not slug names).
3
+ *
4
+ * A "list" parent is a path with many direct children that are mostly leaves
5
+ * (no further subpages) — typical for archives like news/events/blog, whatever
6
+ * they are named. Within --max-pages we keep structural pages and sample
7
+ * leaves under each list parent.
8
+ */
9
+ /** Minimum direct leaf children before a path is treated as a list parent. */
10
+ export const DEFAULT_LIST_MIN_CHILDREN = 8;
11
+ /** Share of direct children that must be leaves to count as a list. */
12
+ export const DEFAULT_LIST_LEAF_RATIO = 0.7;
13
+ const PRIORITY = {
14
+ home: 100,
15
+ root: 80,
16
+ list: 60,
17
+ detail: 40,
18
+ other: 20,
19
+ };
20
+ export function pathSegments(urlOrPath) {
21
+ try {
22
+ const pathname = urlOrPath.includes("://") ? new URL(urlOrPath).pathname : urlOrPath;
23
+ return pathname.split("/").filter(Boolean);
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
29
+ export function pathnameOf(urlString) {
30
+ try {
31
+ const pathname = new URL(urlString).pathname;
32
+ if (!pathname || pathname === "")
33
+ return "/";
34
+ return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
35
+ }
36
+ catch {
37
+ return "/";
38
+ }
39
+ }
40
+ /** Parent pathname: `/a/b` → `/a`, `/a` → `/`, `/` → null. */
41
+ export function parentPathname(pathname) {
42
+ if (pathname === "/" || pathname === "")
43
+ return null;
44
+ const segments = pathname.split("/").filter(Boolean);
45
+ if (segments.length <= 1)
46
+ return "/";
47
+ return `/${segments.slice(0, -1).join("/")}`;
48
+ }
49
+ function originOf(urls, startUrl) {
50
+ const candidate = startUrl ?? urls[0];
51
+ if (!candidate)
52
+ return "https://example.com";
53
+ try {
54
+ return new URL(candidate).origin;
55
+ }
56
+ catch {
57
+ return "https://example.com";
58
+ }
59
+ }
60
+ function uniqueUrls(urls, startUrl) {
61
+ const seen = new Set();
62
+ const unique = [];
63
+ if (startUrl) {
64
+ unique.push(startUrl);
65
+ seen.add(startUrl);
66
+ }
67
+ for (const url of urls) {
68
+ if (seen.has(url))
69
+ continue;
70
+ seen.add(url);
71
+ unique.push(url);
72
+ }
73
+ return unique;
74
+ }
75
+ /**
76
+ * Build parent → direct children maps from a URL set and detect list parents:
77
+ * paths with many children that are mostly leaves (no further children in the set).
78
+ */
79
+ export function analyzeUrlTree(urls, options) {
80
+ const minChildren = options?.listMinChildren ?? DEFAULT_LIST_MIN_CHILDREN;
81
+ const leafRatio = options?.listLeafRatio ?? DEFAULT_LIST_LEAF_RATIO;
82
+ const unique = uniqueUrls(urls, options?.startUrl);
83
+ const origin = originOf(unique, options?.startUrl);
84
+ const childrenOf = new Map();
85
+ const pathnames = new Set();
86
+ for (const url of unique) {
87
+ const pathname = pathnameOf(url);
88
+ pathnames.add(pathname);
89
+ let child = pathname;
90
+ let parent = parentPathname(child);
91
+ while (parent !== null) {
92
+ let bucket = childrenOf.get(parent);
93
+ if (!bucket) {
94
+ bucket = new Set();
95
+ childrenOf.set(parent, bucket);
96
+ }
97
+ bucket.add(child);
98
+ child = parent;
99
+ parent = parentPathname(child);
100
+ }
101
+ }
102
+ const isLeaf = (pathname) => {
103
+ const kids = childrenOf.get(pathname);
104
+ return !kids || kids.size === 0;
105
+ };
106
+ const listParents = new Set();
107
+ for (const [parent, children] of childrenOf) {
108
+ if (parent === "/")
109
+ continue; // home fan-out is structural, not an archive list
110
+ const childList = [...children];
111
+ if (childList.length < minChildren)
112
+ continue;
113
+ const leafCount = childList.filter((child) => isLeaf(child)).length;
114
+ if (leafCount >= minChildren && leafCount / childList.length >= leafRatio) {
115
+ listParents.add(parent);
116
+ }
117
+ }
118
+ return { listParents, origin };
119
+ }
120
+ /** Closest list-parent pathname for this path, or null. */
121
+ export function listParentForPathname(pathname, listParents) {
122
+ let current = parentPathname(pathname);
123
+ while (current !== null) {
124
+ if (listParents.has(current))
125
+ return current;
126
+ current = parentPathname(current);
127
+ }
128
+ return null;
129
+ }
130
+ export function classifyCrawlUrl(urlString, tree) {
131
+ const pathname = pathnameOf(urlString);
132
+ if (pathname === "/") {
133
+ return { url: urlString, kind: "home", section: null, priority: PRIORITY.home };
134
+ }
135
+ if (tree.listParents.has(pathname)) {
136
+ return { url: urlString, kind: "list", section: pathname, priority: PRIORITY.list };
137
+ }
138
+ const listParent = listParentForPathname(pathname, tree.listParents);
139
+ if (listParent) {
140
+ return { url: urlString, kind: "detail", section: listParent, priority: PRIORITY.detail };
141
+ }
142
+ const depth = pathSegments(pathname).length;
143
+ if (depth <= 2) {
144
+ return { url: urlString, kind: "root", section: null, priority: PRIORITY.root - (depth - 1) * 5 };
145
+ }
146
+ return { url: urlString, kind: "other", section: null, priority: PRIORITY.other };
147
+ }
148
+ export function crawlRequestPriority(urlString, tree) {
149
+ return classifyCrawlUrl(urlString, tree).priority;
150
+ }
151
+ /**
152
+ * Soft per-list-parent cap for detail URLs discovered during a crawl.
153
+ * Structural pages (home/root/list) are never capped this way.
154
+ */
155
+ export function detailCapPerSection(maxPages, listParentCount = 8) {
156
+ const detailBudget = Math.max(8, Math.floor(maxPages * 0.5));
157
+ const sections = Math.max(1, listParentCount);
158
+ return Math.max(2, Math.floor(detailBudget / Math.min(sections, 8)));
159
+ }
160
+ /**
161
+ * Pick up to `maxPages` URLs using URL-tree shape: keep home/root/list pages,
162
+ * then round-robin a sample of leaf details under each detected list parent.
163
+ */
164
+ export function selectSmartCrawlUrls(urls, maxPages, options) {
165
+ if (!Number.isFinite(maxPages) || maxPages <= 0) {
166
+ return { urls: [], tree: analyzeUrlTree([], options) };
167
+ }
168
+ const limit = Math.floor(maxPages);
169
+ const unique = uniqueUrls(urls, options?.startUrl);
170
+ const tree = analyzeUrlTree(unique, options);
171
+ if (unique.length <= limit) {
172
+ return { urls: sortByCrawlPriority(unique, tree), tree };
173
+ }
174
+ const classified = unique.map((url) => classifyCrawlUrl(url, tree));
175
+ const home = [];
176
+ const roots = [];
177
+ const lists = [];
178
+ const others = [];
179
+ const detailsBySection = new Map();
180
+ for (const item of classified) {
181
+ if (item.kind === "home")
182
+ home.push(item);
183
+ else if (item.kind === "root")
184
+ roots.push(item);
185
+ else if (item.kind === "list")
186
+ lists.push(item);
187
+ else if (item.kind === "detail") {
188
+ const key = item.section ?? "_";
189
+ const bucket = detailsBySection.get(key) ?? [];
190
+ bucket.push(item);
191
+ detailsBySection.set(key, bucket);
192
+ }
193
+ else
194
+ others.push(item);
195
+ }
196
+ const selected = [];
197
+ const selectedSet = new Set();
198
+ const take = (items) => {
199
+ for (const item of items) {
200
+ if (selected.length >= limit)
201
+ return;
202
+ if (selectedSet.has(item.url))
203
+ continue;
204
+ selected.push(item.url);
205
+ selectedSet.add(item.url);
206
+ }
207
+ };
208
+ take(home);
209
+ take(roots);
210
+ take(lists);
211
+ const sectionQueues = [...detailsBySection.values()].map((bucket) => [...bucket]);
212
+ let progress = true;
213
+ while (selected.length < limit && progress) {
214
+ progress = false;
215
+ for (const queue of sectionQueues) {
216
+ if (selected.length >= limit)
217
+ break;
218
+ while (queue.length > 0) {
219
+ const next = queue.shift();
220
+ if (selectedSet.has(next.url))
221
+ continue;
222
+ selected.push(next.url);
223
+ selectedSet.add(next.url);
224
+ progress = true;
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ take(others);
230
+ return { urls: selected, tree };
231
+ }
232
+ /** Sort URLs by crawl priority (desc), stable for equal priority. */
233
+ export function sortByCrawlPriority(urls, tree) {
234
+ return urls
235
+ .map((url, index) => ({ url, index, priority: crawlRequestPriority(url, tree) }))
236
+ .sort((a, b) => b.priority - a.priority || a.index - b.index)
237
+ .map((row) => row.url);
238
+ }
239
+ /**
240
+ * Tracks list parents during a live crawl: uses seed analysis up front, and
241
+ * promotes parents to "list" when enough leaf-like children are observed.
242
+ */
243
+ export function createDiscoveryGate(seedUrls, maxPages, options) {
244
+ const minChildren = options?.listMinChildren ?? DEFAULT_LIST_MIN_CHILDREN;
245
+ const leafRatio = options?.listLeafRatio ?? DEFAULT_LIST_LEAF_RATIO;
246
+ const tree = analyzeUrlTree(seedUrls, options);
247
+ const listParents = new Set(tree.listParents);
248
+ const childrenOf = new Map();
249
+ const known = new Set();
250
+ const detailCounts = new Map();
251
+ const register = (url) => {
252
+ const pathname = pathnameOf(url);
253
+ if (known.has(pathname))
254
+ return;
255
+ known.add(pathname);
256
+ let child = pathname;
257
+ let parent = parentPathname(child);
258
+ while (parent !== null) {
259
+ let bucket = childrenOf.get(parent);
260
+ if (!bucket) {
261
+ bucket = new Set();
262
+ childrenOf.set(parent, bucket);
263
+ }
264
+ bucket.add(child);
265
+ if (parent !== "/") {
266
+ const childList = [...bucket];
267
+ if (childList.length >= minChildren) {
268
+ const leafCount = childList.filter((c) => {
269
+ const grand = childrenOf.get(c);
270
+ return !grand || grand.size === 0;
271
+ }).length;
272
+ if (leafCount >= minChildren && leafCount / childList.length >= leafRatio) {
273
+ listParents.add(parent);
274
+ }
275
+ }
276
+ }
277
+ child = parent;
278
+ parent = parentPathname(child);
279
+ }
280
+ };
281
+ for (const seed of seedUrls)
282
+ register(seed);
283
+ const liveTree = () => ({ listParents, origin: tree.origin });
284
+ const capFor = () => detailCapPerSection(maxPages, Math.max(1, listParents.size));
285
+ return {
286
+ get tree() {
287
+ return liveTree();
288
+ },
289
+ allowEnqueue(url) {
290
+ const pathname = pathnameOf(url);
291
+ const parent = listParentForPathname(pathname, listParents);
292
+ if (!parent)
293
+ return true;
294
+ return (detailCounts.get(parent) ?? 0) < capFor();
295
+ },
296
+ remember(url) {
297
+ register(url);
298
+ const classified = classifyCrawlUrl(url, liveTree());
299
+ if (classified.kind !== "detail" || !classified.section)
300
+ return;
301
+ detailCounts.set(classified.section, (detailCounts.get(classified.section) ?? 0) + 1);
302
+ },
303
+ isStructural(url) {
304
+ const kind = classifyCrawlUrl(url, liveTree()).kind;
305
+ return kind === "home" || kind === "root" || kind === "list";
306
+ },
307
+ };
308
+ }
@@ -0,0 +1,9 @@
1
+ /** Installed package version from package.json (works from src/ and dist/). */
2
+ export declare function getPackageVersion(): string;
3
+ /**
4
+ * CLI flags as passed on the command line (everything after node + script path).
5
+ * Example: `["https://example.com", "--quiet", "--max-pages", "50"]`
6
+ */
7
+ export declare function cliFlagsFromArgv(argv?: string[]): string[];
8
+ export declare function formatCliFlags(flags: string[]): string;
9
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAIA,+EAA+E;AAC/E,wBAAgB,iBAAiB,IAAI,MAAM,CAO1C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,MAAM,EAAE,CAExE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAGtD"}
@@ -0,0 +1,24 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ /** Installed package version from package.json (works from src/ and dist/). */
4
+ export function getPackageVersion() {
5
+ try {
6
+ const pkg = require("../package.json");
7
+ return pkg.version ?? "unknown";
8
+ }
9
+ catch {
10
+ return "unknown";
11
+ }
12
+ }
13
+ /**
14
+ * CLI flags as passed on the command line (everything after node + script path).
15
+ * Example: `["https://example.com", "--quiet", "--max-pages", "50"]`
16
+ */
17
+ export function cliFlagsFromArgv(argv = process.argv) {
18
+ return argv.slice(2);
19
+ }
20
+ export function formatCliFlags(flags) {
21
+ if (flags.length === 0)
22
+ return "(defaults)";
23
+ return flags.map((flag) => (/\s/.test(flag) ? JSON.stringify(flag) : flag)).join(" ");
24
+ }
@@ -3,15 +3,13 @@
3
3
  **Status:** deprecated. Analyze no longer writes `*-fix-manifest.json` or
4
4
  `*-agent-jobs/`.
5
5
 
6
- Use the structured report instead:
7
-
8
- - [Analyze reports](./analyze.md) — `*-report.json` + `*-report.md`
9
- - [Design](./superpowers/specs/2026-10-27-structured-report-design.md)
6
+ Use the structured report instead: [Analyze reports](./analyze.md)
7
+ (`*-report.json` + `*-report.md`).
10
8
 
11
9
  The previous agent-jobs / fix-manifest workflow (work packages, `prompt.md` per
12
- package, `flightcheck-orchestrate`) remains in the repo for transitional imports
13
- but is not part of the default output. Prefer building any new agent automation
14
- on `findings[]` in `*-report.json`.
10
+ package, orchestrate helpers) may still exist in the source tree for reference, but
11
+ it is not part of the published package or default CLI output. Prefer building any
12
+ new agent automation on `findings[]` in `*-report.json`.
15
13
 
16
14
  Stack detection still feeds the report `analysis.stack` block. See
17
15
  [stack.md](./stack.md).
@@ -25,8 +25,8 @@ robots.txt existence and sitemaps remain in the existing `robots-txt` and `sitem
25
25
  - Network runner: `runAgentReadyCheck()` — parallel fetches of robots, llms.txt, homepage
26
26
  (HTML + markdown Accept), and well-known endpoints
27
27
  - Wave 1 (parallel with crawl / DNS / headers / robots)
28
- - Report section: `## Agent readiness` via `buildAgentReadySection()` in `reportTier1.ts`
29
- - Fix package: `agent-ready` → `prompts/fix/agent-ready.md`
28
+ - Report: issues land in `findings[]` (`category: "infra"`) in `*-report.json` with a
29
+ `fix` suggestion; the Markdown report lists them under **Findings** with a Fix line
30
30
 
31
31
  ## CLI
32
32