* 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:
+12
-2
@@ -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`.
|
||||
@@ -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>".
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user