@fulldotdev/scan 0.1.0 → 0.2.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/README.md CHANGED
@@ -26,7 +26,7 @@ Options:
26
26
  | Flag | Meaning |
27
27
  | --- | --- |
28
28
  | `--out <dir>` | Output directory, default `./scan` |
29
- | `--quick` | Crawl only: no browser pass, no Lighthouse |
29
+ | `--quick` | The daily scan: crawl only, no browser pass, Lighthouse or external sources |
30
30
  | `--deep` | Deeper measurement: rendering observation and Lighthouse repeats |
31
31
  | `--lighthouse <mode>` | `templates` (default: homepage plus one rotating page per template), `all`, or `off` |
32
32
  | `--no-browser` | Skip the browser pass and Lighthouse |
@@ -48,9 +48,16 @@ Local targets (localhost, private addresses, any port) are allowed automatically
48
48
 
49
49
  `GOOGLE_API_KEY` (a Google Cloud key with the Chrome UX Report API and Safe Browsing API enabled) enables real-user vitals and the Safe Browsing check.
50
50
 
51
+ ## In a repository
52
+
53
+ Two rhythms: a daily crawl (`--quick`, seconds to minutes) and a weekly full scan (browser pass on every page, Lighthouse per template, external sources). The workflow in [`templates/github-workflow.yml`](templates/github-workflow.yml) does both, scans every pull request on its Netlify deploy preview, comments the result on the pull request, fails on critical findings and pushes scans of the live site to scan.full.dev. Copy it to `.github/workflows/scan.yml`, set `SITE_URL`, and add the secret `FULLSCAN_KEY`.
54
+
55
+ For an agent working on a site: run `fullscan http://localhost:3000`, read `scan/report.md`, change the site, and run again with `--previous scan/result.json` until nothing is left.
56
+
51
57
  ## Output
52
58
 
53
59
  - `result.json`: `scan` (options, coverage, status), `report` (overview, findings per topic, checks with outcome, coverage, changes), `findings` (every finding with all affected items), `pages` (one compact row per page: status, type, language, indexable, depth, word count, template, Lighthouse scores, finding counts), `assets` (every file and external destination with status, size and the pages that use it).
60
+ - `summary.md`: counts, changes and the most important problems, short enough for a pull request comment.
54
61
  - `report.md`: the same report as readable Markdown, made for people and language models.
55
62
  - `records.ndjson`: every raw observation (page extraction, rendered comparison, axe results, Lighthouse evidence, DNS, TLS, sitemaps and so on).
56
63
  - `artifacts/`: screenshots, retrieved sitemaps, robots.txt and agent files, with `index.json`.
package/SKILL.md ADDED
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: fullscan
3
+ description: Scan a website or a local dev server for problems with @fulldotdev/scan and read the result. Use when asked to check, audit or scan a site, or to verify a change did not introduce problems.
4
+ ---
5
+
6
+ # fullscan
7
+
8
+ Run it against a live URL or the local dev server:
9
+
10
+ ```sh
11
+ npx @fulldotdev/scan http://localhost:3000 --quick --out scan
12
+ npx @fulldotdev/scan https://example.com --out scan
13
+ ```
14
+
15
+ `--quick` crawls every page without a browser (fast, seconds). Without it the run also renders every page, runs axe-core, and runs Lighthouse on one page per template (minutes).
16
+
17
+ With an account on the platform, `--push` stores the result there as well:
18
+
19
+ ```sh
20
+ npx @fulldotdev/scan https://example.com --push --key $FULLSCAN_KEY
21
+ ```
22
+
23
+ It prints the site and run id it stored, and creates the site on the first push.
24
+
25
+ Read `scan/report.md` first. It lists the problems per topic with severity, confidence, problem, impact and evidence. For the full list of affected pages per finding read `findings[]` in `scan/result.json`; for per-page rows read `pages[]`; for files and external destinations read `assets[]`. `report.checks[]` tells which rules ran and which did not.
26
+
27
+ The tool reports problems and their impact only. Deciding what, if anything, to change is up to you and the person you work for. Run it again with `--previous scan/result.json` after a change to see what is new, resolved or unverified.
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ per template, and every problem found is written as JSON and Markdown.
16
16
 
17
17
  Options:
18
18
  --out <dir> Output directory (default: ./scan)
19
- --quick Crawl only: no browser pass, no Lighthouse
19
+ --quick The daily scan: crawl only, no browser, Lighthouse or external sources
20
20
  --deep Deeper measurement: rendering observation, Lighthouse repeats
21
21
  --lighthouse <mode> templates (default), all, or off
22
22
  --no-browser Skip the browser pass and Lighthouse
@@ -76,7 +76,7 @@ const options = {
76
76
  ? "off"
77
77
  : (values.lighthouse ??
78
78
  "templates"),
79
- external: values.external,
79
+ external: values.external && !values.quick,
80
80
  allowLocal: local,
81
81
  ...(values.pages ? { maxPages: Number(values.pages) } : {}),
82
82
  };
@@ -154,6 +154,7 @@ const out = values.out;
154
154
  await mkdir(join(out, "artifacts"), { recursive: true });
155
155
  await writeFile(join(out, "result.json"), JSON.stringify(result, null, 2));
156
156
  await writeFile(join(out, "report.md"), formatReport(report) + "\n");
157
+ await writeFile(join(out, "summary.md"), summary() + "\n");
157
158
  await writeFile(join(out, "records.ndjson"), records.map((r) => JSON.stringify(r)).join("\n") + "\n");
158
159
  const index = {};
159
160
  for (const artifact of state.artifacts.values()) {
@@ -222,6 +223,30 @@ if (values["fail-on"]) {
222
223
  : c.critical > 0 || c.warning > 0;
223
224
  process.exit(failed ? 1 : 0);
224
225
  }
226
+ // A short version of the report for a pull request comment or a job summary.
227
+ function summary() {
228
+ const c = report.counts;
229
+ const top = report.findings
230
+ .filter((f) => f.severity !== "hint")
231
+ .slice(0, 10)
232
+ .map((f) => `- **${f.title}** (${f.severity}, ${f.count} on ${f.pages} pages). ${f.impact}`);
233
+ const changes = report.changes
234
+ ? [
235
+ "",
236
+ `Since the previous scan: ${report.changes.newFindings.length} new, ${(report.changes.resolvedFindings ?? []).length} resolved, ${(report.changes.worsenedFindings ?? []).length} worse.`,
237
+ ...report.changes.newFindings
238
+ .slice(0, 10)
239
+ .map((f) => `- New: **${f.title}** (${f.severity}, ${f.count})`),
240
+ ]
241
+ : [];
242
+ return [
243
+ `### Scan of ${new URL(scan.url).hostname}`,
244
+ "",
245
+ `${report.pages.observed} pages, **${c.critical} critical**, **${c.warning} warnings**, ${c.hint ?? 0} hints. Status ${scan.status}.`,
246
+ ...changes,
247
+ ...(top.length ? ["", "Most important problems:", ...top] : []),
248
+ ].join("\n");
249
+ }
225
250
  function extension(contentType) {
226
251
  if (/png/.test(contentType))
227
252
  return ".png";
@@ -24,6 +24,8 @@ export async function openBrowserSession() {
24
24
  ];
25
25
  const executablePath = env("CHROME_PATH") || localPaths.find(existsSync) || undefined;
26
26
  const args = [
27
+ // Extra flags for environments that need them (a serverless Chrome).
28
+ ...JSON.parse(env("CHROME_ARGS") || "[]"),
27
29
  "--no-sandbox",
28
30
  "--disable-dev-shm-usage",
29
31
  `--proxy-server=http://127.0.0.1:${proxy.port}`,
@@ -1,4 +1,4 @@
1
- import { ScanState } from "./state.js";
1
+ import { ScanState, type ScanSnapshot } from "./state.js";
2
2
  import { type JobKind, type Options, type Scan } from "./types.js";
3
3
  import { type PreviousScan, type Report, type StoredFinding } from "../report/build.js";
4
4
  export type ScanEvent = {
@@ -26,6 +26,10 @@ export interface RunInput {
26
26
  rotation?: number;
27
27
  previous?: PreviousScan;
28
28
  budgetMs?: number;
29
+ resume?: ScanSnapshot;
30
+ pauseAtBudget?: boolean;
31
+ checkpointEveryMs?: number;
32
+ onCheckpoint?: (snapshot: ScanSnapshot) => Promise<void>;
29
33
  signal?: AbortSignal;
30
34
  onEvent?: (event: ScanEvent) => void;
31
35
  }
@@ -36,3 +40,6 @@ export interface RunOutput {
36
40
  state: ScanState;
37
41
  }
38
42
  export declare function runScan(input: RunInput): Promise<RunOutput>;
43
+ export declare function runScanStep(input: RunInput): Promise<RunOutput | {
44
+ paused: ScanSnapshot;
45
+ }>;
@@ -16,11 +16,21 @@ const crawlKinds = [
16
16
  ];
17
17
  const maxJobMs = 300000;
18
18
  export async function runScan(input) {
19
- const options = optionsSchema.parse(input.options ?? {});
19
+ const result = await runScanStep({ ...input, pauseAtBudget: false });
20
+ if ("paused" in result)
21
+ throw new Error("Scan paused unexpectedly");
22
+ return result;
23
+ }
24
+ // One stretch of a scan: runs until it is done or, with `pauseAtBudget`,
25
+ // until the budget runs out, and then hands back a snapshot.
26
+ export async function runScanStep(input) {
27
+ const options = input.resume
28
+ ? input.resume.scan.options
29
+ : optionsSchema.parse(input.options ?? {});
20
30
  setLocalTargetsAllowed(options.allowLocal);
21
- const url = normalizeUrl(input.url);
31
+ const url = input.resume ? input.resume.scan.url : normalizeUrl(input.url);
22
32
  await resolvePublic(new URL(url).hostname);
23
- const scan = {
33
+ const scan = input.resume?.scan ?? {
24
34
  id: randomUUID(),
25
35
  url,
26
36
  status: "running",
@@ -29,7 +39,10 @@ export async function runScan(input) {
29
39
  rotation: input.rotation ?? 0,
30
40
  coverage: { omittedCandidates: 0 },
31
41
  };
32
- const state = new ScanState(scan);
42
+ const state = input.resume
43
+ ? ScanState.restore(input.resume)
44
+ : new ScanState(scan);
45
+ let phase = input.resume?.phase ?? "crawl";
33
46
  const emit = (event) => input.onEvent?.(event);
34
47
  emit({ type: "start", scan });
35
48
  const session = new HttpSession();
@@ -107,7 +120,8 @@ export async function runScan(input) {
107
120
  job.error = message;
108
121
  job.status = job.attempts < limit ? "queued" : "failed";
109
122
  job.durationMs = Math.round(performance.now() - started);
110
- if (job.status === "failed" && ["browser", "lighthouse"].includes(job.kind))
123
+ if (job.status === "failed" &&
124
+ ["browser", "lighthouse"].includes(job.kind))
111
125
  state.record({
112
126
  observations: [
113
127
  {
@@ -137,46 +151,69 @@ export async function runScan(input) {
137
151
  // next page is cheaper than the process growing without bound.
138
152
  if (["browser", "lighthouse"].includes(job.kind) &&
139
153
  browser &&
140
- process.memoryUsage().rss > 1500 * 1024 * 1024) {
154
+ process.memoryUsage().rss > 1200 * 1024 * 1024) {
141
155
  await browser.close();
142
156
  browser = undefined;
143
157
  }
144
158
  };
159
+ let lastCheckpoint = Date.now();
160
+ // True when the budget ran out and the scan should pause here.
145
161
  const drain = async (kinds) => {
146
162
  let job;
147
163
  while ((job = state.next(kinds))) {
148
164
  if (Date.now() > deadline || input.signal?.aborted) {
165
+ if (input.pauseAtBudget)
166
+ return true;
149
167
  state.cancelQueued();
150
168
  scan.coverage.expired = true;
151
169
  break;
152
170
  }
153
171
  await execute(job, kinds);
172
+ if (input.onCheckpoint &&
173
+ input.checkpointEveryMs &&
174
+ Date.now() - lastCheckpoint > input.checkpointEveryMs) {
175
+ lastCheckpoint = Date.now();
176
+ await input.onCheckpoint(state.snapshot(phase));
177
+ }
154
178
  }
179
+ return false;
155
180
  };
156
181
  try {
157
- emit({ type: "phase", phase: "crawl" });
158
- state.enqueue("discover", "site");
159
- await drain(crawlKinds);
160
- emit({ type: "phase", phase: "lighthouse" });
161
- const pages = state
162
- .records(["page"])
163
- .filter((p) => p.data?.http?.outcome === "ok" && p.data?.html !== false)
164
- .sort((a, b) => a.key.localeCompare(b.key));
165
- const { selected, templates } = selectLighthousePages(pages, options.browser && !scan.coverage.expired
166
- ? options
167
- : { lighthouse: "off", lighthousePerTemplate: 1 }, scan.rotation, scan.url);
168
- scan.coverage.templates = templates;
169
- scan.coverage.lighthouseEligible = pages.length;
170
- scan.coverage.lighthouseSelected = selected;
171
- scan.coverage.lighthouseSelection =
172
- options.lighthouse === "all"
173
- ? "Every eligible HTML page"
174
- : options.lighthouse === "templates"
175
- ? `Homepage plus ${options.lighthousePerTemplate} rotating page per template (${templates} templates)`
176
- : "Lighthouse disabled";
177
- for (const url of selected)
178
- state.enqueue("lighthouse", url);
179
- await drain(["lighthouse"]);
182
+ if (phase === "crawl") {
183
+ emit({ type: "phase", phase: "crawl" });
184
+ if (!input.resume)
185
+ state.enqueue("discover", "site");
186
+ if (await drain(crawlKinds))
187
+ return { paused: state.snapshot("crawl") };
188
+ }
189
+ if (phase !== "analysis")
190
+ emit({ type: "phase", phase: "lighthouse" });
191
+ if (phase === "crawl") {
192
+ const pages = state
193
+ .records(["page"])
194
+ .filter((p) => p.data?.http?.outcome === "ok" && p.data?.html !== false)
195
+ .sort((a, b) => a.key.localeCompare(b.key));
196
+ const { selected, templates } = selectLighthousePages(pages, options.browser && !scan.coverage.expired
197
+ ? options
198
+ : { lighthouse: "off", lighthousePerTemplate: 1 }, scan.rotation, scan.url);
199
+ scan.coverage.templates = templates;
200
+ scan.coverage.lighthouseEligible = pages.length;
201
+ scan.coverage.lighthouseSelected = selected;
202
+ scan.coverage.lighthouseSelection =
203
+ options.lighthouse === "all"
204
+ ? "Every eligible HTML page"
205
+ : options.lighthouse === "templates"
206
+ ? `Homepage plus ${options.lighthousePerTemplate} rotating page per template (${templates} templates)`
207
+ : "Lighthouse disabled";
208
+ for (const url of selected)
209
+ state.enqueue("lighthouse", url);
210
+ phase = "lighthouse";
211
+ }
212
+ if (phase === "lighthouse") {
213
+ if (await drain(["lighthouse"]))
214
+ return { paused: state.snapshot("lighthouse") };
215
+ phase = "analysis";
216
+ }
180
217
  }
181
218
  finally {
182
219
  await browser?.close();
@@ -4,6 +4,17 @@ export interface StoredArtifact extends Artifact {
4
4
  sha256: string;
5
5
  bytes: number;
6
6
  }
7
+ export interface ScanSnapshot {
8
+ version: 1;
9
+ phase: "crawl" | "lighthouse" | "analysis";
10
+ scan: Scan;
11
+ observations: Observation[];
12
+ urls: UrlRow[];
13
+ jobs: Job[];
14
+ artifacts: (Omit<StoredArtifact, "body"> & {
15
+ body: string;
16
+ })[];
17
+ }
7
18
  export declare class ScanState {
8
19
  readonly scan: Scan;
9
20
  readonly observations: Map<string, Observation>;
@@ -12,6 +23,8 @@ export declare class ScanState {
12
23
  readonly jobs: Job[];
13
24
  private readonly jobIndex;
14
25
  constructor(scan: Scan);
26
+ snapshot(phase: ScanSnapshot["phase"]): ScanSnapshot;
27
+ static restore(snapshot: ScanSnapshot): ScanState;
15
28
  enqueue(kind: JobKind, name: string): void;
16
29
  next(kinds: JobKind[]): Job | undefined;
17
30
  pending(kinds: JobKind[]): number;
@@ -16,6 +16,40 @@ export class ScanState {
16
16
  constructor(scan) {
17
17
  this.scan = scan;
18
18
  }
19
+ snapshot(phase) {
20
+ return {
21
+ version: 1,
22
+ phase,
23
+ scan: this.scan,
24
+ observations: [...this.observations.values()],
25
+ urls: [...this.urls.values()],
26
+ jobs: this.jobs,
27
+ artifacts: [...this.artifacts.values()].map((a) => ({
28
+ ...a,
29
+ body: a.body.toString("base64"),
30
+ })),
31
+ };
32
+ }
33
+ static restore(snapshot) {
34
+ const state = new ScanState(snapshot.scan);
35
+ for (const o of snapshot.observations)
36
+ state.observations.set(key(o.kind, o.key), o);
37
+ for (const u of snapshot.urls)
38
+ state.urls.set(key(u.kind, u.url), u);
39
+ for (const job of snapshot.jobs) {
40
+ // A job that was running when the process stopped starts over.
41
+ if (job.status === "running")
42
+ job.status = "queued";
43
+ state.jobs.push(job);
44
+ state.jobIndex.set(key(job.kind, job.key), job);
45
+ }
46
+ for (const a of snapshot.artifacts)
47
+ state.artifacts.set(key(a.kind, a.key), {
48
+ ...a,
49
+ body: Buffer.from(a.body, "base64"),
50
+ });
51
+ return state;
52
+ }
19
53
  enqueue(kind, name) {
20
54
  const id = key(kind, name);
21
55
  if (this.jobIndex.has(id))
package/dist/engine.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { runScan, type RunInput, type RunOutput, type ScanEvent } from "./engine/run.js";
1
+ export { runScan, runScanStep, type RunInput, type RunOutput, type ScanEvent, } from "./engine/run.js";
2
+ export type { ScanSnapshot } from "./engine/state.js";
package/dist/engine.js CHANGED
@@ -1 +1 @@
1
- export { runScan } from "./engine/run.js";
1
+ export { runScan, runScanStep, } from "./engine/run.js";
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@fulldotdev/scan",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Whole-site website scanner: crawls every page, renders it, runs Lighthouse and axe, and reports every problem it finds.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "repository": "github:fulldotdev/scan",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "github:fulldotdev/scan"
10
+ },
8
11
  "homepage": "https://scan.full.dev",
9
12
  "bin": {
10
13
  "fullscan": "./bin/fullscan.js"
@@ -17,18 +20,14 @@
17
20
  },
18
21
  "types": "./dist/index.d.ts",
19
22
  "files": [
23
+ "SKILL.md",
20
24
  "bin",
21
- "dist"
25
+ "dist",
26
+ "templates"
22
27
  ],
23
28
  "engines": {
24
29
  "node": ">=22"
25
30
  },
26
- "scripts": {
27
- "build": "tsc -p tsconfig.build.json",
28
- "typecheck": "tsc --noEmit",
29
- "test": "tsx --test tests/*.test.ts",
30
- "scan": "tsx src/cli.ts"
31
- },
32
31
  "dependencies": {
33
32
  "axe-core": "^4.13.0",
34
33
  "cheerio": "^1.2.0",
@@ -48,5 +47,11 @@
48
47
  "@types/node": "^24.13.4",
49
48
  "tsx": "^4.23.13",
50
49
  "typescript": "^7.0.2"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "typecheck": "tsc --noEmit",
54
+ "test": "tsx --test tests/*.test.ts",
55
+ "scan": "tsx src/cli.ts"
51
56
  }
52
- }
57
+ }
@@ -0,0 +1,83 @@
1
+ # Scan: copy to .github/workflows/scan.yml and add the repository secret
2
+ # FULLSCAN_KEY (an API key from the account page on scan.full.dev).
3
+ # Set SITE_URL to the live site.
4
+ name: Scan
5
+
6
+ on:
7
+ pull_request:
8
+ schedule:
9
+ - cron: "30 2 * * *" # daily crawl
10
+ - cron: "30 3 * * 0" # weekly full scan: browser pass, Lighthouse, external sources
11
+ workflow_dispatch:
12
+
13
+ env:
14
+ SITE_URL: https://example.com
15
+
16
+ permissions:
17
+ contents: read
18
+ pull-requests: write
19
+ statuses: read
20
+
21
+ concurrency:
22
+ group: scan-${{ github.ref }}
23
+ cancel-in-progress: true
24
+
25
+ jobs:
26
+ scan:
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 120
29
+ steps:
30
+ - uses: actions/setup-node@v6
31
+ with:
32
+ node-version: 24
33
+
34
+ # A pull request is scanned on its Netlify deploy preview, which Netlify
35
+ # reports as a commit status once the preview is ready.
36
+ - name: Find the URL to scan
37
+ id: target
38
+ env:
39
+ GH_TOKEN: ${{ github.token }}
40
+ run: |
41
+ if [ "${{ github.event_name }}" != "pull_request" ]; then
42
+ echo "url=$SITE_URL" >> "$GITHUB_OUTPUT"; exit 0
43
+ fi
44
+ for i in $(seq 1 60); do
45
+ url=$(gh api "repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/status" \
46
+ -q '.statuses[] | select(.context | test("^netlify/.+/deploy-preview$")) | select(.state == "success") | .target_url' | head -1)
47
+ [ -n "$url" ] && break
48
+ sleep 10
49
+ done
50
+ [ -n "$url" ] || { echo "No deploy preview found"; exit 1; }
51
+ echo "url=$url" >> "$GITHUB_OUTPUT"
52
+
53
+ - name: Scan
54
+ env:
55
+ FULLSCAN_KEY: ${{ secrets.FULLSCAN_KEY }}
56
+ run: |
57
+ mode=""
58
+ # Daily runs and pull requests crawl only; the weekly run is the full scan.
59
+ if [ "${{ github.event.schedule }}" = "30 2 * * *" ]; then mode="--quick"; fi
60
+ push=""
61
+ # Only scans of the live site go to the platform's history.
62
+ if [ "${{ github.event_name }}" != "pull_request" ] && [ -n "$FULLSCAN_KEY" ]; then push="--push"; fi
63
+ npx --yes @fulldotdev/scan "${{ steps.target.outputs.url }}" $mode $push --fail-on critical
64
+ - name: Summary
65
+ if: always()
66
+ run: cat scan/summary.md >> "$GITHUB_STEP_SUMMARY"
67
+
68
+ - name: Comment on the pull request
69
+ if: always() && github.event_name == 'pull_request'
70
+ env:
71
+ GH_TOKEN: ${{ github.token }}
72
+ run: |
73
+ gh pr comment ${{ github.event.pull_request.number }} -R ${{ github.repository }} --edit-last --body-file scan/summary.md \
74
+ || gh pr comment ${{ github.event.pull_request.number }} -R ${{ github.repository }} --body-file scan/summary.md
75
+
76
+ - uses: actions/upload-artifact@v4
77
+ if: always()
78
+ with:
79
+ name: scan
80
+ path: |
81
+ scan/report.md
82
+ scan/result.json
83
+ scan/summary.md