diff --git a/.claude/loops.md b/.claude/loops.md index 09f4b550..70b02f6f 100644 --- a/.claude/loops.md +++ b/.claude/loops.md @@ -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 `. Wrap in `/loop ` to repeat locally; `/goal` for a hard exit. + `/loop-design-scan `, `/loop-docs-freshness`, `/loop-regeluppdat`. Wrap in + `/loop ` 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. diff --git a/.claude/skills/loop-docs-freshness/SKILL.md b/.claude/skills/loop-docs-freshness/SKILL.md new file mode 100644 index 00000000..c09652e6 --- /dev/null +++ b/.claude/skills/loop-docs-freshness/SKILL.md @@ -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 `` 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 " 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 ( pages)` + - Body: the script's non-ok output verbatim, the remediation steps below, and + ``. + - 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- 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/.md` and `cookbook/.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 `` 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`. diff --git a/.claude/skills/loop-ignite/SKILL.md b/.claude/skills/loop-ignite/SKILL.md index e51b3814..bf69743d 100644 --- a/.claude/skills/loop-ignite/SKILL.md +++ b/.claude/skills/loop-ignite/SKILL.md @@ -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 `` marker on `loop:docs` issues (due + weekly) and last `` 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: - ". diff --git a/.claude/skills/loop-regeluppdat/SKILL.md b/.claude/skills/loop-regeluppdat/SKILL.md new file mode 100644 index 00000000..42145478 --- /dev/null +++ b/.claude/skills/loop-regeluppdat/SKILL.md @@ -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 +`` 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 +`/` 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: (effective )` +- 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 + ``. +- 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: ``, 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. diff --git a/DECISIONS.md b/DECISIONS.md index 333aa9d0..9d222b81 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -847,3 +847,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/scripts/check-docs-freshness.mts b/scripts/check-docs-freshness.mts new file mode 100644 index 00000000..3add955a --- /dev/null +++ b/scripts/check-docs-freshness.mts @@ -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 (`/.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 { + 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] ?? '', + liveLine: liveLines[firstDiffLine] ?? '', + 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()