@goflag/cli 0.1.3 → 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
@@ -1,12 +1,17 @@
1
1
  # Goflag
2
2
 
3
3
  > **A lean CLI that flags the site problems humans can't catch at scale.**
4
- > Point it at a URL and get three things: broken links, missing translation pages, and missing/misconfigured SEO metadata. JSON-first.
4
+ > Point it at a URL and get four things: broken links, missing translation pages, a robots.txt that contradicts your pages, and missing or misconfigured SEO metadata. JSON-first.
5
5
 
6
- [![node](https://img.shields.io/badge/node-%3E%3D20.11-339933?logo=nodedotjs&logoColor=white)](./.nvmrc)
6
+ [![node](https://img.shields.io/badge/node-%3E%3D22-339933?logo=nodedotjs&logoColor=white)](./package.json)
7
7
  [![license: MIT](https://img.shields.io/badge/license-MIT-green)](#license)
8
8
 
9
- Goflag crawls a site once and runs three focused static checks over it. It is deliberately small: no dashboard, no config system, no social-preview gallery. Just the findings, as a machine-readable report you can pipe, diff, or gate CI on.
9
+ Goflag crawls a site once and judges what it found. It is deliberately small: no dashboard, no config system, no social-preview gallery. Just the findings, as a machine-readable report you can pipe, diff, or gate CI on.
10
+
11
+ It is built for the mistakes that are invisible while browsing and expensive in
12
+ search: a `hreflang` that points at a 404, a canonical that silently
13
+ de-indexes a page, a `robots.txt` that contradicts every `<meta name="robots">`
14
+ on the site. A human cannot check those on 400 pages. That is the whole job.
10
15
 
11
16
  ## What it checks
12
17
 
@@ -17,30 +22,88 @@ Goflag crawls a site once and runs three focused static checks over it. It is de
17
22
 
18
23
  Pages that declare a `<link rel="canonical">` pointing at another crawled page are excluded from the checks — the site has said they are duplicates, so linting them would multiply every finding by the number of filtered variants. The count is reported as `diagnostics.duplicatePages`.
19
24
 
20
- SPA support is built in: pages that look client-rendered are re-rendered in headless Chromium automatically (pass `--static` to skip it).
25
+ **Client-rendered pages.** By default, a page whose `<head>` looks empty — no
26
+ title, no description, no canonical, no OG, no JSON-LD — is re-rendered in
27
+ headless Chromium before being judged, so a SPA is not reported as missing
28
+ everything. `--static` turns that off, including the detection: with it, a
29
+ client-rendered page is judged on its unhydrated shell.
21
30
 
22
- ## Repository layout
31
+ That direction is deliberate. Static mode over-reports rather than under-reports
32
+ — the metadata is genuinely absent from the HTML a crawler receives — so it
33
+ fails loudly rather than passing quietly. It is also why `--static` is the right
34
+ default in CI: it needs no browser, and it cannot mistake a broken page for a
35
+ fine one.
36
+
37
+ ## Install
23
38
 
39
+ Requires Node `>=22`.
40
+
41
+ ```sh
42
+ npx @goflag/cli https://example.com # one-off, nothing installed
43
+ pnpm add -D @goflag/cli # then: pnpm goflag https://example.com
24
44
  ```
25
- packages/cli/ @goflag/cli — the CLI (installs the `goflag` command)
26
- packages/next/ @goflag/next — the Next.js library (not built yet)
27
- apps/website/ documentation and marketing site (not built yet)
45
+
46
+ Install it as a dev dependency once you run it more than once — in a script, in
47
+ CI, in a git hook. `npx` is for trying it.
48
+
49
+ The package is `@goflag/cli`; the command it installs is `goflag`. Everything
50
+ under the brand lives in the `@goflag` scope; the bare `goflag` name on npm is a
51
+ deprecated signpost pointing here and carries no code.
52
+
53
+ Headless rendering needs Chromium, which is **not** installed with goflag —
54
+ `playwright` is an optional peer dependency. Add it only if you audit
55
+ client-rendered pages without `--static`:
56
+
57
+ ```sh
58
+ pnpm add -D playwright && pnpm exec playwright install chromium
28
59
  ```
29
60
 
30
- The library and the apps must not import from the CLI: the two products stay
31
- independently useful, and a lint rule enforces it rather than trusting memory.
61
+ ## Getting started
32
62
 
33
- ## Quick start
63
+ Five steps, in this order. Each one is explained in full further down; this is
64
+ the sequence.
34
65
 
35
- Requires Node `>=20.11`.
66
+ **1. Look at your site.** No flags, no gate — find out what is there.
36
67
 
37
68
  ```sh
38
69
  npx @goflag/cli https://example.com
39
70
  ```
40
71
 
41
- The package is `@goflag/cli`; the command it installs is `goflag`. The bare
42
- `goflag` name on npm is not claimed yet — see the note in
43
- [docs/spec-and-lib-plan.md](docs/spec-and-lib-plan.md#une-marque-des-outils-nommés).
72
+ Expect it to be red. Almost every site that has never been audited has a
73
+ backlog, and that is not a reason to fix everything before continuing.
74
+
75
+ **2. Decide what is real.** Some findings are deliberate — a legal page that
76
+ exists in one jurisdiction, a route you chose not to translate. Tell goflag,
77
+ rather than living with noise:
78
+
79
+ ```sh
80
+ npx @goflag/cli https://example.com --ignore-holes /legal
81
+ ```
82
+
83
+ **3. Capture a baseline.** This freezes today's findings as "known", so the gate
84
+ can ask _did this change make it worse?_ instead of _is this site perfect?_
85
+
86
+ ```sh
87
+ npx @goflag/cli https://example.com --static --no-external \
88
+ --baseline .goflag/baseline.json --update-baseline
89
+ ```
90
+
91
+ It prints how many findings it grandfathered and the `--max-debt` to set next.
92
+ Commit `.goflag/baseline.json`.
93
+
94
+ **4. Gate CI on regressions.** See [In CI](#in-ci) for a job you can copy.
95
+
96
+ ```sh
97
+ goflag https://example.com --static --no-external \
98
+ --baseline .goflag/baseline.json --regressions-only --max-debt 41
99
+ ```
100
+
101
+ **5. Lower the ratchet.** Fix a finding, drop `--max-debt` by one, commit both.
102
+ This is the only part that makes the backlog shrink; without it a baseline
103
+ fossilises behind a passing build.
104
+
105
+ Steps 3 to 5 are the whole method. The rest of this README is why each flag
106
+ behaves the way it does.
44
107
 
45
108
  You get a coloured terminal report and an exit code:
46
109
 
@@ -48,6 +111,11 @@ You get a coloured terminal report and an exit code:
48
111
  - `1` — findings present (yellow/red flag) — use this as a CI gate
49
112
  - `2` — fatal error (bad URL, unexpected failure)
50
113
 
114
+ ## Using it
115
+
116
+ Everything below is reference: what each flag does and why it behaves that way.
117
+ The five steps above are the path; this is the map.
118
+
51
119
  ### JSON is the source of truth
52
120
 
53
121
  ```sh
@@ -70,11 +138,19 @@ The terminal view is just a render of that JSON.
70
138
  --ignore-holes <glob> Route deliberately not translated everywhere
71
139
  (repeatable).
72
140
  --no-sitemap Do not discover the sitemap; crawl from <url> only.
141
+ --profile <name> Policy overlay on the rule set: default, strict,
142
+ spec-only, marketing.
143
+ --conformance Report every rule's status on every page, not just
144
+ the violations.
145
+ --advisories Attach the prose rules — the judgment calls goflag
146
+ refuses to fake — with the facts to judge them by.
73
147
  --fail-on <level> Exit 1 at or above this severity: warning (default),
74
148
  error, or never.
75
149
  --regressions-only Weaken the gate: fail only on NEW findings relative
76
150
  to --baseline. Requires --baseline.
77
151
  --baseline <file> Stored report to compare against.
152
+ --update-baseline Write this run to --baseline and exit 0, instead of
153
+ judging against it.
78
154
  --max-debt <n> Fail when the site carries more than <n> findings in
79
155
  total, new or known.
80
156
  --start <cmd> Boot <cmd>, wait for <url>, audit, then stop it.
@@ -90,7 +166,101 @@ The terminal view is just a render of that JSON.
90
166
  -v, --version Show the version.
91
167
  ```
92
168
 
93
- Headless mode needs Chromium. It ships as an optional dependency; if it's missing, install it with `npx playwright install chromium` (or just run with `--static`).
169
+ Headless mode needs Chromium. `playwright` is an optional peer dependency; if it's missing, install it with `npx playwright install chromium` (or just run with `--static`).
170
+
171
+ ### Profiles: how much each rule matters to you
172
+
173
+ Every rule records how authoritative it is — whether a published spec requires
174
+ it, a vendor documents it, or it is widely-repeated folklore. That is a fact
175
+ about the rule, and goflag never fudges it. What your build should _do_ about
176
+ each one is a separate question, and that is what `--profile` answers:
177
+
178
+ ```sh
179
+ goflag https://example.com --profile spec-only # only what a spec backs
180
+ goflag https://example.com --profile strict # spec-backed rules fail the build
181
+ goflag https://example.com --profile marketing # metadata gaps are errors
182
+ ```
183
+
184
+ | Profile | What it does |
185
+ | ----------- | ------------------------------------------------------------------------------ |
186
+ | `default` | Each rule's own severity. No overlay. |
187
+ | `strict` | Every spec-backed rule becomes an `error`. Heuristics stay warnings. |
188
+ | `spec-only` | Heuristic rules (title/description length) are switched off entirely. |
189
+ | `marketing` | The snippet and unfurl metadata — description, `og:title`, `og:image` — error. |
190
+
191
+ A profile only changes how loudly a rule fires and whether it runs at all. It
192
+ never changes what a rule observes, and it never rewrites a rule's stated
193
+ authority — so `strict` can make a finding fail your build, but it cannot turn
194
+ folklore into a spec requirement.
195
+
196
+ The report records which profile produced it (`"profile": "strict"`), and the
197
+ terminal output names any non-default one. A run under `spec-only` reporting
198
+ zero issues means "nothing a spec backs is wrong here", not "nothing is wrong".
199
+
200
+ That record is load-bearing: comparing a run against a baseline captured under
201
+ a different profile still works, but "0 new findings" no longer means what it
202
+ appears to, so goflag says so rather than letting the number speak for itself:
203
+
204
+ ```
205
+ REGRESSION GATE 0 new · 19 known findings NOT gating this build
206
+ baseline https://example.com — taken 2026-08-06 (today)
207
+ note: baseline was captured under profile `strict`, this run used `spec-only` — the two are not like-for-like.
208
+ ```
209
+
210
+ It is a warning, never a gate — a `--profile spec-only` investigation against a
211
+ `strict` baseline is a legitimate thing to do.
212
+
213
+ ### Where do we stand? (`--conformance`)
214
+
215
+ A list of violations cannot tell you the difference between a rule that
216
+ passes everywhere and a rule that never applied to a single page. Both look
217
+ like silence. `--conformance` reports every rule's status on every page:
218
+
219
+ ```sh
220
+ goflag https://example.com --conformance
221
+ ```
222
+
223
+ ```
224
+ Conformance
225
+ 6 pages × every rule
226
+ canonical.absolute 6 fail [vendor-spec]
227
+ canonical.missing 6 pass [vendor-spec]
228
+ description.length 6 n/a [heuristic]
229
+ title.missing 6 pass [spec-required]
230
+ ```
231
+
232
+ The terminal shows the tally; `--json` carries the full rule × page grid,
233
+ with each rule's rigor and sources in a legend rather than repeated per
234
+ cell. `pass + fail + warn + n/a + crashed` always equals the page count, so
235
+ the coverage claim is checkable.
236
+
237
+ ### The questions goflag will not answer for you (`--advisories`)
238
+
239
+ Whether a title _describes_ the page is not a thing a linter can decide.
240
+ goflag could fake it — count words, match boilerplate, emit a confident
241
+ verdict — and the result would be unfalsifiable noise. So it states the
242
+ question, cites what makes it a real requirement, attaches the observed
243
+ facts, and stops:
244
+
245
+ ```sh
246
+ goflag https://example.com --advisories --json
247
+ ```
248
+
249
+ ```json
250
+ {
251
+ "ruleId": "description.accurate",
252
+ "prose": "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?",
253
+ "rigor": "guideline",
254
+ "sources": ["google-snippet", "moz-meta-description"],
255
+ "evidence": { "meta.description": { "value": "…", "origin": { "kind": "meta" } } },
256
+ "verdict": "needs-judgment"
257
+ }
258
+ ```
259
+
260
+ Advisories are asked only where the subject exists — no question about a
261
+ description on a page that has none, because `description.missing` already
262
+ says that. They never count toward the summary, the verdict, or the exit
263
+ code: nobody has judged them yet.
94
264
 
95
265
  ### Gate on regressions, not on perfection
96
266
 
@@ -99,7 +269,7 @@ yet — so it gets switched off, or ignored. Capture a baseline once and gate on
99
269
  "did this change make it worse?" instead:
100
270
 
101
271
  ```sh
102
- goflag https://example.com --report baseline.json --json # once
272
+ goflag https://example.com --baseline baseline.json --update-baseline # once
103
273
  goflag https://example.com --regressions-only --baseline baseline.json
104
274
  ```
105
275
 
@@ -129,6 +299,28 @@ goflag https://example.com --regressions-only --baseline baseline.json --max-deb
129
299
  Keeping `baseline.json` in the repository helps for the same reason: adding a
130
300
  finding to it then shows up in a diff someone reviews.
131
301
 
302
+ Capture it with the run that will later judge it, minus one flag:
303
+
304
+ ```sh
305
+ goflag https://example.com --baseline baseline.json --update-baseline
306
+ ```
307
+
308
+ That matters more than it looks. A baseline captured with different options than
309
+ the gate — a forgotten `--static`, another `--max-pages` — does not compare
310
+ cleanly, and the mismatch surfaces as findings that appear from nowhere. Using
311
+ the same command for both makes that impossible.
312
+
313
+ The same flag accepts findings you have decided to live with, and it says what
314
+ it accepted:
315
+
316
+ ```
317
+ goflag: baseline updated — 3 newly accepted, 0 resolved, 44 findings now
318
+ grandfathered in baseline.json
319
+ ```
320
+
321
+ Refreshing a baseline is taking on debt. It prints the number so the commit that
322
+ does it can be read as what it is.
323
+
132
324
  ### Gate a merge before it ships
133
325
 
134
326
  `--start` boots your app, waits for it to answer, audits it, and stops it — so
@@ -175,10 +367,121 @@ goflag https://example.com --ignore-holes /legal --ignore-holes "/blog/**"
175
367
  Suppressed gaps are counted in `diagnostics.ignoredHoles`, so a quiet report
176
368
  never means "nothing was wrong" by accident.
177
369
 
370
+ ## In CI
371
+
372
+ Two moments are worth auditing, and they answer different questions.
373
+
374
+ | When | Against | Answers |
375
+ | -------------------- | ------------------------------------------- | ---------------------------------------- |
376
+ | On the merge request | the branch's own build, booted by `--start` | does **this change** regress? |
377
+ | After deploying | the running environment | is what is **actually serving** correct? |
378
+
379
+ Neither replaces the other. Only the deployed run sees what the environment
380
+ injects — the real base URL, whatever the proxy serves for `robots.txt`,
381
+ redirects. A canonical broken by a misconfigured environment variable is
382
+ invisible in a local build.
383
+
384
+ Three rules, whichever you use:
385
+
386
+ - **Pin the version.** A floating one turns someone else's release into a red
387
+ pipeline on a commit that touched nothing, and the job is only worth having if
388
+ red means "you broke something".
389
+ - **Commit the baseline.** Grandfathering a finding then shows up in a diff
390
+ somebody reviews, instead of vanishing into a cache.
391
+ - **Build the same site the baseline was captured against.** Frameworks bake
392
+ build-time environment into the output; a build that differs produces
393
+ differences that are real in the report and meaningless as findings.
394
+
395
+ ### GitLab CI
396
+
397
+ ```yaml
398
+ stages: [build, deploy, audit]
399
+
400
+ variables:
401
+ GOFLAG_VERSION: "0.1.4"
402
+
403
+ # Before the merge: build the branch, let goflag boot it, audit that.
404
+ seo:mr:
405
+ stage: audit
406
+ image: node:24-alpine
407
+ rules:
408
+ - if: $CI_MERGE_REQUEST_IID
409
+ script:
410
+ - corepack enable && pnpm install --frozen-lockfile
411
+ - pnpm build
412
+ - npx --yes "@goflag/cli@$GOFLAG_VERSION" http://localhost:3000
413
+ --start "pnpm start" --static --no-external
414
+ --baseline .goflag/baseline.json --regressions-only --max-debt 41
415
+ artifacts:
416
+ when: always
417
+ paths: [goflag-report.json]
418
+
419
+ # After the deploy: audit what is serving.
420
+ seo:
421
+ stage: audit
422
+ image: node:24-alpine
423
+ needs: [{ job: deploy, artifacts: false }]
424
+ rules:
425
+ - if: $CI_COMMIT_BRANCH == "develop"
426
+ script:
427
+ - npx --yes "@goflag/cli@$GOFLAG_VERSION" https://develop.example.com
428
+ --static --no-external
429
+ --baseline .goflag/baseline.json --regressions-only --max-debt 41
430
+ --report goflag-report.json
431
+ artifacts:
432
+ when: always
433
+ paths: [goflag-report.json]
434
+ ```
435
+
436
+ ### GitHub Actions
437
+
438
+ ```yaml
439
+ name: seo
440
+ on: [pull_request]
441
+
442
+ jobs:
443
+ audit:
444
+ runs-on: ubuntu-latest
445
+ steps:
446
+ - uses: actions/checkout@v4
447
+ - uses: actions/setup-node@v4
448
+ with: { node-version: 24 }
449
+ - run: corepack enable && pnpm install --frozen-lockfile
450
+ - run: pnpm build
451
+ - run: |
452
+ npx --yes @goflag/cli@0.1.4 http://localhost:3000 \
453
+ --start "pnpm start" --static --no-external \
454
+ --baseline .goflag/baseline.json --regressions-only --max-debt 41
455
+ - uses: actions/upload-artifact@v4
456
+ if: always()
457
+ with:
458
+ name: goflag-report
459
+ path: goflag-report.json
460
+ ```
461
+
462
+ ### Why `--static --no-external` in CI
463
+
464
+ `--static` needs no browser, so the job runs on a plain Node image and cannot
465
+ be slowed by a Chromium download. `--no-external` skips off-origin links, which
466
+ are the ones you cannot fix and whose failures are somebody else's outage — a
467
+ gate that goes red because a third party is down teaches people to ignore it.
468
+
469
+ Audit external links on a schedule instead, where nobody is waiting.
470
+
471
+ ### Reading a red build
472
+
473
+ The report artefact is the thing to open. It is the same JSON as `--json`, and
474
+ it is kept whether the job passed or failed, because it is most wanted when it
475
+ is red.
476
+
477
+ If the finding is one you have decided to accept, refresh the baseline with the
478
+ same command minus `--regressions-only`, plus `--update-baseline`. It will tell
479
+ you what it accepted, and that number belongs in the commit message.
480
+
178
481
  ## Programmatic API
179
482
 
180
483
  ```ts
181
- import { runAudit } from "goflag";
484
+ import { runAudit } from "@goflag/cli";
182
485
 
183
486
  const report = await runAudit("https://example.com", { depth: 2 });
184
487
  console.log(report.summary); // { brokenLinks, missingTranslations, seoIssues, verdict }
@@ -186,8 +489,27 @@ console.log(report.summary); // { brokenLinks, missingTranslations, seoIssues, v
186
489
 
187
490
  `runAudit` returns the same `GoflagReport` object the CLI emits.
188
491
 
492
+ ## Repository layout
493
+
494
+ ```
495
+ packages/cli/ @goflag/cli — the CLI (installs the `goflag` command)
496
+ tools/name-holder/ the bare `goflag` name on npm, a signpost to @goflag/cli
497
+ ```
498
+
499
+ That is the whole tree. `@goflag/next` (the Next.js library) and the
500
+ documentation site are planned but deliberately absent: the workspace globs
501
+ (`packages/*`, `apps/*`) are ready for them, and empty stubs for things that do
502
+ not exist are how a repository accumulates code nobody calls.
503
+
504
+ When they land, they must not import from the CLI: the two products stay
505
+ independently useful, and an ESLint rule enforces it rather than trusting
506
+ memory.
507
+
189
508
  ## Develop locally
190
509
 
510
+ Requires pnpm `11.18.0` (pinned via `packageManager` — `corepack enable` picks
511
+ it up). Run these from the repository root:
512
+
191
513
  ```sh
192
514
  pnpm install
193
515
  pnpm dev https://example.com # run the CLI from source (tsx)
@@ -200,7 +522,11 @@ pnpm typecheck # tsc --noEmit
200
522
  pnpm format # prettier
201
523
  ```
202
524
 
203
- The engine is framework-agnostic and lives in [`src/lib/core/`](src/lib/core) (crawl, fetch/extract, link audit, i18n) and [`src/lib/rules/`](src/lib/rules) (SEO checks). The CLI shell — orchestration, the `GoflagReport` schema, and rendering — lives in [`src/report/`](src/report) and [`src/cli.ts`](src/cli.ts).
525
+ `lint`, `format` and `format:check` run once over the whole repository; the
526
+ rest fan out across workspace packages with `pnpm -r`. To work on one package,
527
+ filter: `pnpm --filter @goflag/cli test`.
528
+
529
+ The engine is framework-agnostic and lives in [`packages/cli/src/lib/core/`](packages/cli/src/lib/core) (crawl, fetch/extract, link audit, i18n) and [`packages/cli/src/lib/rules/`](packages/cli/src/lib/rules) (SEO checks). The CLI shell — orchestration, the `GoflagReport` schema, and rendering — lives in [`packages/cli/src/report/`](packages/cli/src/report) and [`packages/cli/src/cli.ts`](packages/cli/src/cli.ts).
204
530
 
205
531
  ## License
206
532