feat(loops): regeluppdat + docs-freshness scans (#1417) (#1478)

* feat(loops): regeluppdat + docs-freshness scans (#1417)

Two new local loops per .claude/loops.md conventions:

- loop-regeluppdat (monthly): sweeps official Swedish sources (Skatteverket,
  BFN, Bolagsverket, regeringen/riksdagen, BAS, DIGG/ViDA) for regulatory
  changes, verifies each against the codebase anchors, and files deduped
  tickets for gaps. Tickets only, never code: regulatory changes touch money
  math and compliance surfaces.
- loop-docs-freshness (weekly): runs scripts/check-docs-freshness.mts, which
  builds every docs page from source and diffs it against the live .md
  mirrors on docs.accounted.se; files one deduped drift issue and proposes
  the re-export PR in the gnubok-website repo.

Both self-gate on run markers so any invocation is idempotent; loop-ignite
now runs them when due (session crons cannot express weekly/monthly).
Labels loop:docs and loop:regeluppdat created on the repo.

Closes #1417

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(loops): explicit types for closure-captured docs-content imports

next build's type check rejects the bare let-in-try pattern when the
variables are read inside a nested function (implicit any).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-10 09:13:05 +02:00
committed by GitHub
parent ce1e0b7642
commit 4ccebd3645
6 changed files with 454 additions and 3 deletions
+12 -2
View File
@@ -37,7 +37,8 @@ the PR is opened.** No exceptions.
**Destination:** GitHub Issues + PRs in `erp-mafia/accounted` (via `gh`). Not Linear.
**Labels:** `loop:auto` (always, on anything a loop creates), `loop:vercel`, `loop:triage`,
`loop:design`, `loop:needs-human` (a loop tried and could not safely proceed).
`loop:design`, `loop:docs`, `loop:regeluppdat`, `loop:needs-human` (a loop tried and could not
safely proceed).
**Idempotency / anti-spam: MANDATORY.** Before filing anything:
1. Compute a stable **fingerprint** (error signature, file:line, rule id, never a timestamp).
@@ -62,6 +63,8 @@ comment what was tried. Never retry the same failing action in a cycle.
| 2 | Vercel errors → tickets | `loop-vercel-errors` | **Local** (Vercel MCP) | daily / on-demand | ≤8 issues, ≤2 PRs |
| 3 | Issue triage + easy-fix | `loop-issue-triage` | **Local** (session cron / `/loop`) | ~2x/day while a session is open | triage all; ≤2 PRs |
| 4 | UI/UX + design scan | `loop-design-scan` | **Local** (`/loop`) | on-demand | ≤1 area, ≤6 findings |
| 5 | Docs freshness (docs.accounted.se) | `loop-docs-freshness` | **Local** (`/loop` / ignite-when-due) | weekly | 1 issue, 1 website PR |
| 6 | Regeluppdat (Swedish regulatory watch) | `loop-regeluppdat` | **Local** (`/loop` / ignite-when-due) | monthly | ≤6 issues, **0 PRs** |
**All loops are local.** Cloud routines were retired 2026-07-20: the three claude.ai triggers
(`trig_01J2nG7eB9gsdAb9YSGBVwa8`, `trig_014CmE3gTJ7ErnvL2trPYymu`, `trig_017hB94ieGVwreJqHpGRDVoM`)
@@ -70,6 +73,12 @@ to disable them rather than provision. Do not re-enable or re-create them. Loop
the Vercel MCP (local-only; Sentry is not used). Loop 4 needs `npm run dev` + Chrome.
The `loop-ignite` skill audits and (re)schedules the local cadence each session.
Loops 5 and 6 self-gate on a due check (a run marker in their tracking issues), so any invocation
is idempotent: session crons cannot outlive 7 days, which makes weekly/monthly cadences
unschedulable directly. Instead `loop-ignite` runs them **when due** at the start of a session.
Loop 6 files tickets only, never PRs: regulatory changes touch money math and compliance logic,
which the autonomy policy forbids auto-fixing.
---
## The verification gate (`loop-verify`)
@@ -95,7 +104,8 @@ of that deliberately, treat cloud as retired: see issue #993 for the full histor
## Operating the loops
- **Audit / (re)ignite each session:** `/loop-ignite` (audits evidence, schedules session-local crons).
- **Run on-demand:** `/loop-pr-ci-triage`, `/loop-issue-triage`, `/loop-vercel-errors`,
`/loop-design-scan <area>`. Wrap in `/loop <interval>` to repeat locally; `/goal` for a hard exit.
`/loop-design-scan <area>`, `/loop-docs-freshness`, `/loop-regeluppdat`. Wrap in
`/loop <interval>` to repeat locally; `/goal` for a hard exit.
- **Cost:** route mechanical steps to cheaper models; reserve judgment for the strong model. `/usage`.
Don't run more often than the watched thing changes.
@@ -0,0 +1,97 @@
---
name: loop-docs-freshness
description: Weekly loop that checks the public API docs (docs.accounted.se) against what this repo generates today, files one deduped drift issue, and proposes the re-export PR in the gnubok-website repo. Use on a weekly cadence or on-demand via /loop-docs-freshness. Follows .claude/loops.md (propose-don't-merge).
---
# loop-docs-freshness
**Goal:** what docs.accounted.se serves is byte-identical to what this repo would generate today.
The docs site lives in the separate `jakobwennberg/gnubok-website` repo and is fed by manual
snapshot exports, so it silently drifts whenever an endpoint, error code, or content page changes
here. **Never merge.** Read `.claude/loops.md` first.
## Preflight
`gh auth status`, repo `erp-mafia/accounted`. If it fails, stop and report "environment not provisioned".
## 1. Due check (weekly semantics)
Find the most recent trace: `gh issue list --label loop:docs --state all --limit 5 --json number,title,updatedAt`
plus the newest `<!-- loop-docs-freshness-run: YYYY-MM-DD -->` marker in those issues' comments.
If a run marker is dated **less than 6 days ago**, report "not due" and stop, unless the user
invoked `/loop-docs-freshness` explicitly (explicit invocation always runs).
## 2. Run the deterministic check
From an up-to-date `main` checkout (the comparison is "what main generates" vs "what the site
serves"; a feature branch would produce false drift):
```bash
npx tsx scripts/check-docs-freshness.mts
```
- **Exit 0** (in sync): if an open `loop:docs` drift issue exists, comment that the site is back
in sync and close it. Post the run marker (step 5). Done.
- **Exit 2** (fetch/build failure): the site is unreachable or the content modules changed shape.
Do NOT file a drift issue. If this is the second consecutive exit-2 run, file/update one issue
labeled `loop:auto`, `loop:docs`, `loop:needs-human` describing the failure. Stop.
- **Exit 1** (drift/missing): continue.
## 3. File or update ONE drift issue (cap: 1 per run)
The script prints a `FINGERPRINT` per drifted page. Compute the run fingerprint = first 12 hex
chars of sha256 over the sorted page fingerprints, then dedupe per `.claude/loops.md`:
- `gh issue list --search "<run-fingerprint> in:body" --state all` : match open → comment the fresh
script summary instead of filing; match closed → reopen with a note.
- Otherwise file **one** issue (never one per page):
- Title: `docs drift: docs.accounted.se out of sync (<N> pages)`
- Body: the script's non-ok output verbatim, the remediation steps below, and
`<!-- loop-fingerprint: <run-fingerprint> -->`.
- Labels: `loop:auto`, `loop:docs`, `documentation`.
If an *older* open `loop:docs` issue has a different fingerprint (the drift changed), comment on
it with a pointer to the new issue and close it: one open drift issue at a time.
## 4. Propose the fix (cap: 1 website PR per run)
Two failure classes, two remedies:
- **Generated snapshots stale** (`reference*`, `errors.md`, `connect-claude.md`): run
`npx tsx scripts/export-docs-to-website.mts`. It writes `*.generated.ts` into
`~/gnubok-website`.
- **Hand-copied pages stale** (`changelog.md`, `versioning.md`, `webhooks.md`, `cookbook/*`):
copy the exported markdown constants from `lib/docs/content/` in erp-base over the website
repo's duplicates in `lib/docs/content/`. Mechanical copy only; if the two sides have diverged
in structure (not just content), escalate with `loop:needs-human` instead.
The `~/gnubok-website` checkout is often on a human's WIP branch. **Never commit on it.** Work in
a fresh worktree from origin/main:
```bash
git -C ~/gnubok-website fetch origin
git -C ~/gnubok-website worktree add /tmp/loop-docs-freshness-wt -b loop/docs-freshness-<run-fingerprint> origin/main
```
Re-run the export/copy against the worktree, commit, push, and open a PR **in
`jakobwennberg/gnubok-website`** whose body links the erp-base drift issue and embeds the same
fingerprint marker. Then remove the worktree. Never merge; the founder deploys the website.
**404s on pages whose routes exist on the website's origin/main** (seen 2026-08-09 for every
`reference/<slug>.md` and `cookbook/<slug>.md` mirror) are a deploy/runtime problem, not a
snapshot problem: a re-export PR will not fix them. Label the issue `loop:needs-human` and say
so explicitly.
## 5. Run marker + report
Comment `<!-- loop-docs-freshness-run: YYYY-MM-DD -->` plus a one-line summary on the drift issue
touched this run (or, when in sync and nothing is open, skip the marker comment and rely on the
closed issue's timestamp). Summarize: pages in sync / drifted / missing, issue filed or updated,
website PR opened, escalations. That summary is the completion notification.
## Anti-thrash
If a previous run already opened a website PR for the **same run fingerprint** and it is still
open, do not open another: comment on the existing PR/issue instead. If the same fingerprint
drifts again after its PR merged and deployed, something upstream regenerates differently:
`loop:needs-human`.
+9 -1
View File
@@ -21,12 +21,20 @@ audits. All loops run locally now.
- Local schedule: `CronList` for session-local jobs invoking `/loop-*` skills.
- Evidence of firing: recent `loop/*` branches, PR comments by the loop, `loop:needs-human` labels
(`gh pr list`, `gh issue list --label loop:needs-human`).
2. **Report a one-screen status table**: loop, expected cadence, last observed run, verdict (LIVE / DEAD / NEVER RAN).
- Slow loops: last `<!-- loop-docs-freshness-run: ... -->` marker on `loop:docs` issues (due
weekly) and last `<!-- loop-regeluppdat-run: YYYY-MM -->` marker on the `Regeluppdat scan log`
issue (due monthly).
2. **Report a one-screen status table**: loop, expected cadence, last observed run, verdict (LIVE / DEAD / NEVER RAN / NOT DUE).
3. **Ignite what is dead.** For each non-live loop:
- Run the loop skill once NOW (`/loop-pr-ci-triage`, `/loop-issue-triage`, `/loop-vercel-errors`)
so the backlog is cleared this session.
- Then schedule a session-local cadence (`CronCreate` invoking the matching `/loop-*` skill) and
say clearly that it only runs while this session is alive (7-day auto-expiry).
- **Slow loops run when due, not on a cron**: session crons cannot outlive 7 days, so weekly and
monthly cadences cannot be scheduled directly. If the docs-freshness marker is older than 6
days (or absent), run `/loop-docs-freshness` once now; if the regeluppdat marker is not from
the current calendar month, run `/loop-regeluppdat` once now. Both self-gate, so running them
again is a cheap no-op.
- If a loop cannot run at all, file the blocker as a GitHub issue so the gap is visible instead of silent.
4. **Switch-on check (mandatory):** after igniting, verify via `CronList` that the jobs exist.
End with either "ALL LOOPS LIVE (session-local)" or "NOT SWITCHED ON YET: <loop> - <what remains, who flips it>".
+106
View File
@@ -0,0 +1,106 @@
---
name: loop-regeluppdat
description: Monthly loop that scans official Swedish sources for new or changed laws and rules affecting accounting/finance software (VAT, payroll, BFN standards, deadlines, BAS chart, e-invoicing), checks whether the codebase already handles each change, and files deduped GitHub issues for the gaps. Use monthly or on-demand via /loop-regeluppdat. Follows .claude/loops.md (propose-don't-merge, tickets only, no auto-fix).
---
# loop-regeluppdat
**Goal:** no Swedish regulatory change that affects Accounted lands as a surprise. Each month:
sweep the official sources, keep only what touches our product surface, verify against the code,
and file a well-formed ticket for every real gap. **This loop never writes code.** Regulatory
changes touch money math, tax logic, and compliance surfaces, which the autonomy policy forbids
auto-fixing: tickets only. Read `.claude/loops.md` first.
## Preflight
`gh auth status`, repo `erp-mafia/accounted`. If it fails, stop and report "environment not
provisioned". Web access (WebSearch/WebFetch) must be available; without it, stop and report.
## 1. Due check (monthly semantics)
The scan log lives on a single pinned issue titled `Regeluppdat scan log` labeled
`loop:regeluppdat` (create it on the first ever run). Each run ends by commenting
`<!-- loop-regeluppdat-run: YYYY-MM -->` plus the run summary on it.
If a marker for the **current calendar month** already exists, report "not due" and stop, unless
the user invoked `/loop-regeluppdat` explicitly.
## 2. Sweep the sources
Cover every row; a source with nothing new is still reported as swept (silent truncation reads
as "covered everything"). Window: since the previous run marker (default: the last 45 days),
plus anything already announced with a future effective date.
| Source | What to look for |
|---|---|
| skatteverket.se (nyheter + rättslig vägledning, ställningstaganden) | VAT rates/rules, arbetsgivaravgifter, traktamente/milersättning amounts, basbelopp, deadline changes, AGI changes |
| bfn.se (beslut, nya/ändrade BFNAR, remisser) | K2/K3 changes, bokföringsregler, verifikationskrav |
| bolagsverket.se (nyheter) | filing mandates (digital årsredovisning), fees, form changes |
| regeringen.se + riksdagen.se (propositioner, SFS) | changes to BFL, ÅRL, ML (moms), SFL, ABL, IL that touch bookkeeping, invoicing, payroll, or retention |
| bas.se (BAS-kontogruppen) | new BAS chart year, new/removed/renamed accounts, SRU mapping changes |
| digg.se + EU ViDA track | e-invoicing/Peppol mandates and timelines |
| Secondary sweep: srfkonsulterna.se, far.se news | catch anything the primary sources buried |
Also sweep for date-triggered knowns: every January 1 and July 1 batch (rate years, basbelopp,
skiktgränser, avgifter) and any transition our own docs promise (search the repo for hardcoded
years/rates near their expiry).
**Relevance filter.** Keep a finding only if it affects: bookkeeping/verifikation rules, VAT
(rates, rutor, reverse charge, OSS), employer contributions or payroll (AGI, förmåner,
traktamente, semester), tax rates or deadlines, chart of accounts/SRU, year-end or
årsredovisning, SIE/INK2/NE formats, e-invoicing, document retention, or company law that our
flows encode. General business news is out of scope.
## 3. Verify against the codebase (the actual value of this loop)
For each surviving finding, before filing anything:
1. **Load the matching `swedish-*` skill** for the domain (vat, payroll, year-end-closing,
financial-reporting, e-invoicing, ...). Never assess Swedish domain questions from training
data.
2. **Grep the anchors** to see whether the change is already handled:
- VAT: `lib/vat/`, `lib/bookkeeping/vat-entries.ts`, `lib/reports/` (moms), momsdeklaration rutor
- Payroll/rates: `lib/salary/calculation-engine.ts`, `lib/salary/lonevaxling.ts`, `lib/salary/semesterberedning.ts`
- Deadlines: `lib/tax/deadline-config.ts`, `lib/deadlines/`, `lib/tax/swedish-holidays.ts`
- Chart of accounts: `lib/bookkeeping/bas-data/` (currently BAS 2026), `bas-data/sru-mapping.ts`
- Year-end/bokslut: `lib/bokslut/`, `lib/core/bookkeeping/year-end-service.ts` (bolagsskatt)
- Formats: `lib/reports/` (SIE, INK2, NE-bilaga, SRU), `lib/skatteverket/`
- E-invoicing/Peppol: the `swedish-e-invoicing` skill documents our current stance
3. Check `DECISIONS.md` and search issues **state:all** for the same change: it may already be
tracked, done, or explicitly declined.
4. Classify: **handled** (code already correct, note it in the run summary), **gap** (file a
ticket), or **unclear** (file a ticket flagged for founder judgment; do not guess).
The `.claude/skills/swedish-*` skills are themselves a compliance surface: if a law changed,
the skill text is now stale. A confirmed change therefore usually yields a ticket with two
checkboxes: update the code AND update the affected skill.
## 4. File tickets (cap: 6 new issues per run)
Fingerprint = the change's official identifier (SFS number, BFNAR number, Skatteverket dnr, or
`<source-domain>/<slug>` when nothing better exists) plus the effective year, never a timestamp.
Dedupe per `.claude/loops.md` (search state:all, comment instead of duplicate, reopen if
recurring). Then file:
- Title: `regeluppdat: <short description> (effective <date>)`
- Body: what changed (with source links), who it affects (EF/AB, which flows), what the code
does today (file:line evidence from step 3), suggested change, effective date, and
`<!-- loop-fingerprint: regeluppdat-<id> -->`.
- Labels: `loop:auto`, `loop:regeluppdat`, plus `bug` if we are already non-compliant today or
`enhancement` if the change is upcoming.
- Anything already in force that we get wrong also gets `loop:needs-human` (compliance exposure
is a founder call, not a backlog item).
If the sweep yields more than 6 gaps, file the 6 with the earliest effective dates and list the
rest in the run summary so the next run (or a human) picks them up.
## 5. Run marker + report
Comment on the `Regeluppdat scan log` issue: `<!-- loop-regeluppdat-run: YYYY-MM -->`, sources
swept, findings kept/discarded by the relevance filter, handled vs gap vs unclear counts, issues
filed (numbers), overflow list. That summary is the completion notification.
## Anti-thrash
Never re-file a fingerprint that a human closed without action: closed + `wontfix` (or a closing
comment declining it) means the decision is made; note it in the run summary instead. If the
same finding keeps resurfacing across sources, one ticket, many source links.
+1
View File
@@ -847,3 +847,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-08] extensions.schema.json enum also gained "stripe" while adding "shopify": the enum had drifted (stripe was enabled in extensions.config.json but missing from the schema, failing editor validation); fixed in the same touch since the file had to change anyway.
[2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt.
[2026-08-09] Upsell FAB dismissal is session-scoped (sessionStorage), not persisted: closing the paywalled sheet or the pill's X hides all floating assistant UI for non-payers until the next browser session; a permanent dismissal would let one click silence the conversion surface forever, and payer/collapsed FAB behavior stays untouched.
[2026-08-09] Regeluppdat + docs-freshness scans (#1417) built as local loop skills with due-date self-gating, not cloud crons: cloud routines were retired 2026-07-20, and session crons die at 7 days, so weekly/monthly cadence is achieved by loop-ignite running each loop when its run marker says it is due. loop-regeluppdat files tickets only (no auto-fix PRs): regulatory changes touch money math and compliance logic, which .claude/loops.md forbids loops from changing. Docs check diffs the live .md mirror routes against repo-built markdown (exact, canonicalised both sides) instead of diffing the gnubok-website checkout, so it also catches deployed-but-stale and route-404 states.
+229
View File
@@ -0,0 +1,229 @@
/**
* Docs-freshness check: verifies that the public API docs on
* https://docs.accounted.se match what this repo would generate today.
*
* The docs site is served by the separate gnubok-website repo from snapshot
* files (`*.generated.ts` exported via scripts/export-docs-to-website.mts,
* plus hand-copied duplicates for changelog/versioning/webhooks/cookbook).
* Nothing re-syncs them automatically, so they drift whenever an endpoint,
* error code, or content page changes here. Every docs page has a raw
* markdown mirror route (`/<page>.md`), which makes the check exact: build
* each page from source, fetch the live mirror, diff.
*
* Run with `npx tsx scripts/check-docs-freshness.mts`.
* Exit codes: 0 = in sync, 1 = drift or missing pages, 2 = build/fetch failure.
* Used by the weekly `loop-docs-freshness` skill; keep the output format
* stable (the loop parses the FINGERPRINT lines for issue dedupe).
*/
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module'
// `server-only` throws on import outside a Next.js server-component graph.
// The reference builder pulls it in transitively (lib/api/v1/load-routes ->
// every v1 route -> lib/init). Nothing here executes request-time code: it
// only reads exported markdown builders, so a no-op stub is the honest
// resolution. Same pattern as scripts/export-docs-to-website.mts.
const require = createRequire(import.meta.url)
const ModuleCtor = require('node:module') as {
_load: (request: string, ...rest: unknown[]) => unknown
}
const originalLoad = ModuleCtor._load
ModuleCtor._load = function (request: string, ...rest: unknown[]) {
if (request === 'server-only') return {}
return originalLoad.call(this, request, ...rest)
}
// Explicit types: these are read inside buildExpectedPages(), and TS cannot
// infer a closure-captured let assigned in a try block (implicit-any error
// under next build's type check; the export script gets away with the bare
// pattern only because it reads the variables at top level).
let errors!: typeof import('@/lib/docs/content/errors')
let reference!: typeof import('@/lib/docs/content/reference')
let connectClaude!: typeof import('@/lib/docs/content/connect-claude')
let changelog!: typeof import('@/lib/docs/content/changelog')
let versioning!: typeof import('@/lib/docs/content/versioning')
let webhooks!: typeof import('@/lib/docs/content/webhooks')
let cookbook!: typeof import('@/lib/docs/content/cookbook')
try {
errors = await import('@/lib/docs/content/errors')
reference = await import('@/lib/docs/content/reference')
connectClaude = await import('@/lib/docs/content/connect-claude')
changelog = await import('@/lib/docs/content/changelog')
versioning = await import('@/lib/docs/content/versioning')
webhooks = await import('@/lib/docs/content/webhooks')
cookbook = await import('@/lib/docs/content/cookbook')
} finally {
ModuleCtor._load = originalLoad
}
const DOCS_BASE = process.env.DOCS_BASE_URL ?? 'https://docs.accounted.se'
/**
* Same link-absolutising transform the export script applies: the website
* cannot serve root-relative /api/v1 links. Applied to BOTH sides before
* comparing so the check is insensitive to whether a hand-copied page was
* adapted or not.
*/
const APP_ORIGIN = 'https://app.gnubok.se'
function canonicalise(md: string): string {
return md
.replaceAll('](/api/v1/', `](${APP_ORIGIN}/api/v1/`)
.replaceAll('](/.well-known/', `](${APP_ORIGIN}/.well-known/`)
.replaceAll('\r\n', '\n')
.split('\n')
.map((l) => l.trimEnd())
.join('\n')
.trim()
}
function sha12(s: string): string {
return createHash('sha256').update(s).digest('hex').slice(0, 12)
}
interface PageCheck {
/** Path of the raw-markdown mirror on the docs site, no leading slash. */
path: string
expected: string
}
function buildExpectedPages(): PageCheck[] {
const buildErrorReferenceMd = errors.buildErrorReferenceMd
const buildResourcePages = reference.buildResourcePages
const buildReferenceOverviewMd = reference.buildReferenceOverviewMd
const { COOKBOOK, buildPlaceholderMd } = cookbook
if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd || !COOKBOOK) {
console.error('Missing builder exports; the content modules changed shape.')
process.exit(2)
}
const pages: PageCheck[] = [
{ path: 'reference.md', expected: buildReferenceOverviewMd() },
{ path: 'errors.md', expected: buildErrorReferenceMd() },
{ path: 'connect-claude.md', expected: connectClaude.CONNECT_CLAUDE_MD },
{ path: 'changelog.md', expected: changelog.CHANGELOG_MD },
{ path: 'versioning.md', expected: versioning.VERSIONING_MD },
{ path: 'webhooks.md', expected: webhooks.WEBHOOKS_MD },
]
for (const page of buildResourcePages()) {
pages.push({ path: `reference/${page.slug}.md`, expected: page.markdown })
}
for (const entry of COOKBOOK) {
pages.push({
path: `cookbook/${entry.slug}.md`,
expected: entry.markdown ?? buildPlaceholderMd(entry),
})
}
return pages
}
type Verdict =
| { path: string; status: 'ok' }
| { path: string; status: 'missing'; fingerprint: string }
| {
path: string
status: 'drift'
fingerprint: string
firstDiffLine: number
expectedLine: string
liveLine: string
changedLines: number
}
| { path: string; status: 'fetch-error'; detail: string }
async function checkPage(page: PageCheck): Promise<Verdict> {
const url = `${DOCS_BASE}/${page.path}`
let res: Response
try {
res = await fetch(url, { redirect: 'follow' })
} catch (e) {
return { path: page.path, status: 'fetch-error', detail: String(e) }
}
if (res.status === 404) {
// A page this repo generates that the docs site does not serve at all:
// typically a new API resource whose generated snapshot + nav entry were
// never exported to the website repo.
return { path: page.path, status: 'missing', fingerprint: sha12(`missing:${page.path}`) }
}
if (!res.ok) {
return { path: page.path, status: 'fetch-error', detail: `HTTP ${res.status}` }
}
const live = canonicalise(await res.text())
const expected = canonicalise(page.expected)
if (live === expected) return { path: page.path, status: 'ok' }
const expectedLines = expected.split('\n')
const liveLines = live.split('\n')
let firstDiffLine = 0
while (
firstDiffLine < Math.min(expectedLines.length, liveLines.length) &&
expectedLines[firstDiffLine] === liveLines[firstDiffLine]
) {
firstDiffLine++
}
const maxLen = Math.max(expectedLines.length, liveLines.length)
let changedLines = Math.abs(expectedLines.length - liveLines.length)
for (let i = 0; i < Math.min(expectedLines.length, liveLines.length); i++) {
if (expectedLines[i] !== liveLines[i]) changedLines++
}
return {
path: page.path,
status: 'drift',
// Stable while the same pair of contents drifts; changes when either
// side changes, so a NEW drift after a fix reads as a new finding.
fingerprint: sha12(`${page.path}:${sha12(expected)}:${sha12(live)}`),
firstDiffLine: firstDiffLine + 1,
expectedLine: expectedLines[firstDiffLine] ?? '<end of file>',
liveLine: liveLines[firstDiffLine] ?? '<end of file>',
changedLines: Math.min(changedLines, maxLen),
}
}
async function main() {
const pages = buildExpectedPages()
console.log(`Checking ${pages.length} docs pages against ${DOCS_BASE} ...\n`)
const verdicts: Verdict[] = []
const CONCURRENCY = 6
for (let i = 0; i < pages.length; i += CONCURRENCY) {
const batch = pages.slice(i, i + CONCURRENCY)
verdicts.push(...(await Promise.all(batch.map(checkPage))))
}
const ok = verdicts.filter((v) => v.status === 'ok')
const drift = verdicts.filter((v) => v.status === 'drift')
const missing = verdicts.filter((v) => v.status === 'missing')
const failed = verdicts.filter((v) => v.status === 'fetch-error')
for (const v of verdicts) {
if (v.status === 'ok') continue
if (v.status === 'missing') {
console.log(`MISSING ${v.path}`)
console.log(` page exists in erp-base but the docs site returns 404`)
console.log(` FINGERPRINT ${v.fingerprint}`)
} else if (v.status === 'drift') {
console.log(`DRIFT ${v.path}`)
console.log(` ~${v.changedLines} differing line(s), first at line ${v.firstDiffLine}:`)
console.log(` repo builds: ${v.expectedLine.slice(0, 160)}`)
console.log(` site serves: ${v.liveLine.slice(0, 160)}`)
console.log(` FINGERPRINT ${v.fingerprint}`)
} else {
console.log(`ERROR ${v.path}: ${v.detail}`)
}
console.log('')
}
console.log(
`Summary: ${ok.length} in sync, ${drift.length} drifted, ${missing.length} missing, ${failed.length} fetch errors.`,
)
if (failed.length > 0) process.exit(2)
if (drift.length > 0 || missing.length > 0) {
console.log('\nTo fix: run `npx tsx scripts/export-docs-to-website.mts`, then port any')
console.log('hand-copied pages (changelog/versioning/webhooks/cookbook) and nav entries')
console.log('in the gnubok-website repo, and deploy it. See loop-docs-freshness skill.')
process.exit(1)
}
console.log('Docs are up to date.')
}
await main()