@casomoltd/tooling 0.20.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 ADDED
@@ -0,0 +1,281 @@
1
+ # @casomoltd/tooling
2
+
3
+ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
4
+
5
+ ## What's included
6
+
7
+ ### Exports
8
+
9
+ | Export | Description |
10
+ |---|---|
11
+ | `@casomoltd/tooling/eslint` | `createBaseConfig()` — ESLint flat config with Next.js, TypeScript, and SonarJS |
12
+ | `@casomoltd/tooling/eslint-lib` | ESLint config variant for library packages |
13
+ | `@casomoltd/tooling/prettier` | Prettier config object |
14
+ | `@casomoltd/tooling/commitlint` | commitlint config with `no-ai-attribution` plugin |
15
+ | `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
16
+ | `@casomoltd/tooling/jscpd` | Copy-paste detection config |
17
+ | `@casomoltd/tooling/readability` | `scoreText()` — readability scoring for page content |
18
+
19
+ ### Bin commands
20
+
21
+ | Command | Description |
22
+ |---|---|
23
+ | `check-version` | Pre-push guard — rejects push if `package.json` version hasn't changed vs `origin/main` |
24
+ | `pre-push` | Husky pre-push hook — runs check, build, then version/tag guards |
25
+ | `pre-commit` | Husky pre-commit hook — runs `npm run check` |
26
+ | `commit-msg` | Husky commit-msg hook — runs commitlint |
27
+ | `readability` | Measure reading difficulty of page content |
28
+ | `screenshot` | Capture a dev server page via Puppeteer |
29
+
30
+ ## Install
31
+
32
+ Consumed as a **git dependency** — it's shared config + CLI scripts,
33
+ not a registry package. Add it to a consumer's `devDependencies`:
34
+
35
+ ```json
36
+ "@casomoltd/tooling": "github:casomoltd/tooling#semver:^0.10.0"
37
+ ```
38
+
39
+ The repo is public, so installs need no token. The `files` allowlist
40
+ plus the `verify-pack` leak-gate keep the installed surface to
41
+ configs/CLI only.
42
+
43
+ ## Husky hooks
44
+
45
+ The package provides shared hook commands so all repos enforce
46
+ the same standards. Wire them up in `.husky/`:
47
+
48
+ **.husky/pre-commit**
49
+ ```
50
+ pre-commit
51
+ ```
52
+
53
+ **.husky/commit-msg**
54
+ ```
55
+ commit-msg $1
56
+ ```
57
+
58
+ **.husky/pre-push**
59
+ ```
60
+ pre-push
61
+ ```
62
+
63
+ Each repo defines its own `check` script in `package.json` —
64
+ the hooks call `npm run check` which runs whatever checks that
65
+ repo needs (lint, typecheck, spell, etc.).
66
+
67
+ **Note:** The tooling repo itself calls scripts by path in its
68
+ hooks (e.g. `./bin/check-version.sh`) because it can't resolve
69
+ its own bin commands via `node_modules/.bin`.
70
+
71
+ ## Markdown / skills lint
72
+
73
+ `bin/skills-lint.config.mjs` is a stock remark config (`remark-frontmatter` +
74
+ `remark-validate-links` + `remark-lint-frontmatter-schema`) that validates a
75
+ Claude Code skills/agents/docs tree: every `SKILL.md` / `agents/*.md` has
76
+ parseable YAML frontmatter with `name` + `description`, and every relative link
77
+ and `#anchor` resolves. tooling runs it over its own `skills/` in `npm run
78
+ check` (`lint:skills`) — catching the breakage a rename or hand-edit leaves that
79
+ Claude Code's loader silently swallows.
80
+
81
+ To gate a consumer repo's docs (link/anchor integrity) at its own pre-commit,
82
+ install the remark toolchain and point it at the shipped config:
83
+
84
+ ```bash
85
+ npm i -D remark-cli remark-frontmatter remark-validate-links remark-lint-frontmatter-schema
86
+ ```
87
+
88
+ ```json
89
+ "lint:md": "remark --frail --quiet --no-stdout --rc-path node_modules/@casomoltd/tooling/bin/skills-lint.config.mjs docs"
90
+ ```
91
+
92
+ Append `&& npm run lint:md` to your `check` script. The config resolves the
93
+ plugins from your repo's `node_modules` and the schema (shipped beside it)
94
+ relative to your cwd; the frontmatter schema only matches `SKILL.md`/`agents`
95
+ files, so for plain docs it acts as a link/anchor check. Requires a tooling
96
+ version that ships the config.
97
+
98
+ ## Screenshot tool
99
+
100
+ Capture the running dev server for visual inspection:
101
+
102
+ ```bash
103
+ npm run ss # 1280×800 desktop capture
104
+ npm run ss contact # desktop capture of /contact
105
+ npm run ss -- --width 390 --height 844 # mobile capture
106
+ npm run ss contact --width 390 # mobile /contact
107
+ ```
108
+
109
+ **Options:**
110
+
111
+ | Flag | Default | Description |
112
+ |------|---------|-------------|
113
+ | `--width <n>` | 1280 | Viewport width in pixels |
114
+ | `--height <n>` | 800 | Viewport height in pixels |
115
+
116
+ Add these scripts to your `package.json`:
117
+
118
+ ```json
119
+ {
120
+ "screenshot": "screenshot",
121
+ "ss": "npm run screenshot --"
122
+ }
123
+ ```
124
+
125
+ Screenshots are saved to `.claude/screenshots/`. Set
126
+ `SCREENSHOT_URL` to override the default `http://localhost:3000`.
127
+
128
+ ## Quality gates
129
+
130
+ There is no CI — all quality gates run locally via git hooks.
131
+ Code should be deployment-ready by the time it's pushed to
132
+ the remote.
133
+
134
+ - **pre-commit**: Runs commitlint only (fast).
135
+ - **pre-push**: Runs the full suite — `npm run check`,
136
+ `npm run build`, then version and tag guards. This ensures
137
+ lint, typecheck, spelling, and version bumps are all verified
138
+ before code reaches the remote.
139
+
140
+ ## Usage
141
+
142
+ **.prettierrc.js**
143
+ ```js
144
+ module.exports = require('@casomoltd/tooling/prettier');
145
+ ```
146
+
147
+ **commitlint.config.cjs**
148
+ ```cjs
149
+ module.exports = require('@casomoltd/tooling/commitlint');
150
+ ```
151
+
152
+ **eslint.config.mjs**
153
+ ```js
154
+ import { createBaseConfig } from '@casomoltd/tooling/eslint';
155
+ ```
156
+
157
+ The ESLint config uses a factory function that receives
158
+ resolved imports from the consumer to avoid module resolution
159
+ issues across package boundaries.
160
+
161
+ ## Local development
162
+
163
+ Changes to tooling require a push to `main` to publish (the
164
+ workflow runs automatically). Consuming repos then pick up
165
+ changes with `npm update @casomoltd/tooling`.
166
+
167
+ For fast iteration while editing tooling config locally, use
168
+ `npm link` to temporarily symlink your local checkout:
169
+
170
+ ```bash
171
+ # in the consuming repo
172
+ npm link ../tooling
173
+ ```
174
+
175
+ This overrides the published version until the next
176
+ `npm install`, which restores the registry version.
177
+
178
+ ## Claude Code skills (plugin)
179
+
180
+ This repo is a Claude Code plugin (`.claude-plugin/`) shipping the **generic,
181
+ public engineering standards** we work to across three agent-facing surfaces —
182
+ **skills**, **hooks**, and **agents**. Business-specific ones live in private
183
+ workspace config, not here.
184
+
185
+ The tooling serves three consumers: **Claude** (the editing workflow) → these
186
+ plugin surfaces; **CI + git-hooks** (automation) → the npm `bin` scripts; manual
187
+ human CLI is no longer a design target.
188
+
189
+ **Authoring standard:** creating or editing a skill/agent follows
190
+ [`docs/skill-agent-schema.md`](docs/skill-agent-schema.md) — the interface schema
191
+ (three profiles — procedural skill · agent · standard/rubric — plus a frontmatter
192
+ decision table) that keeps these units composing without overlap.
193
+
194
+ | Skill | Description |
195
+ |---|---|
196
+ | `/casomoltd:commit` | Run checks and commit cleanly (no bump, no push) |
197
+ | `/casomoltd:release-version` | Bump → push → CI publish/deploy (the release tail) |
198
+ | `/casomoltd:frontend-design` | Distinctive, production-grade frontend UI |
199
+ | `/casomoltd:python-style` | Python code-generation style rules |
200
+ | `/casomoltd:typescript` | TypeScript data modelling and type design |
201
+ | `/casomoltd:screenshot` | Capture and analyse a dev server page |
202
+ | `/casomoltd:design-pass` | Map → review → refactor a package (drives `design-xray` + `code-review`) |
203
+ | `/casomoltd:draft-design-spec` | Author a browser-reviewable HTML design spec from a brief and iterate on it before writing code (drives `design-xray`) |
204
+
205
+ Enable the plugin by adding this repo as a marketplace and installing it:
206
+
207
+ ```bash
208
+ /plugin marketplace add casomoltd/tooling
209
+ /plugin install casomoltd@casomo-tooling
210
+ ```
211
+
212
+ Skills then load namespaced as `/casomoltd:<name>`.
213
+
214
+ ### Hooks
215
+
216
+ The plugin also ships generic **PreToolUse hooks** (`hooks/`) that guard
217
+ command/shell intent no linter or commit hook can see: confirm before
218
+ `git push`, hard-block destructive git, `npm version major`, global installs,
219
+ and edits that weaken TypeScript strictness. (They deliberately don't duplicate
220
+ what commitlint/eslint already enforce — see `hooks/README.md`.) The guards are
221
+ advisory — they ask you to confirm when they can't read a call's input, and
222
+ never hard-block on their own malfunction. They require **Node ≥ 22.18 / ≥ 24**
223
+ (they run as TypeScript `.mts` via native type-stripping — no build step).
224
+
225
+ See [`hooks/README.md`](hooks/README.md) for the full rule set, the file map,
226
+ how the hooks relate to `settings.json` permissions and skill `allowed-tools`,
227
+ and the TypeScript / vendored-types design rationale.
228
+
229
+ ### Agents
230
+
231
+ Three namespaced agents (`agents/`), each read-only on what it inspects and
232
+ preloading the relevant house standard as its rubric — `typescript` for
233
+ `.ts/.tsx`, `python-style` for `.py`, `docs-style` for markdown. `design-xray` and
234
+ `docs-xray` persist only their own report (see *Report output*, below).
235
+
236
+ **`casomoltd:code-review`** enforces the *judgment-level* half of the standards —
237
+ the design calls a linter can't make (typed identifiers, static/varying
238
+ separation, swallowed exceptions; class design, polymorphism over type-codes,
239
+ value objects, EAFP, framework-first, test naming). It **complements, never
240
+ duplicates**: eslint/ruff own the mechanical rules, the built-in `/code-review`
241
+ owns correctness bugs, this owns the house design standards. Namespaced so it
242
+ doesn't collide with the bundled `/code-review`. Invoke with
243
+ `@agent-casomoltd:code-review` (or let Claude auto-delegate); it reports findings
244
+ and never edits.
245
+
246
+ **`casomoltd:design-xray`** takes a package or diff and returns the *structural*
247
+ picture: a doc-ready module inventory + mermaid class-hierarchy diagram, a weight
248
+ table (which modules/classes are too heavy or thin), prioritized design findings,
249
+ a ranked handoff of refactor targets, and a verdict on whether a heavier pattern
250
+ (e.g. a state machine) is warranted yet or premature. It feeds forward — its map
251
+ lifts straight into package docs, and its targets drive `code-review` and a
252
+ refactor pass. The `/casomoltd:design-pass` skill chains the three
253
+ (x-ray → code-review → refactor), and `/casomoltd:draft-design-spec` reuses its
254
+ current-state map as the "before" picture when authoring a pre-implementation
255
+ design spec. Like its sibling it visualizes and judges structure only — no
256
+ correctness bugs (that's `/code-review`), no lint (eslint/ruff).
257
+
258
+ **`casomoltd:docs-xray`** is the same idea for a *documentation* corpus: it walks
259
+ every markdown doc (README, `CLAUDE.md`, `docs/`, skill/agent definitions) and
260
+ returns a map — each doc's heading tree + outbound pointers, plus a mermaid
261
+ reference-graph of how the docs link — and a coherence report: orphan docs nothing
262
+ links to, stale cross-references whose summary has drifted from the target,
263
+ duplicated coverage, and missing back-links. It judges structure and
264
+ cross-reference coherence against `docs-style` — not mechanical broken links
265
+ (that's a markdown link linter) or prose voice (a content reviewer).
266
+
267
+ **Report output & `SCRATCH_DIR`.** `design-xray` and `docs-xray` persist their
268
+ report — the `.md` plus an `.html` rendered by `bin/render-report.mjs` — so it
269
+ outlives the run and opens in a browser. Each writes to a gitignored
270
+ `<repo-root>/scratch/<agent>/` by default. Set the optional **`SCRATCH_DIR`** env
271
+ var to pool every report under one shared location instead (`$SCRATCH_DIR/<agent>/`)
272
+ — e.g. a multi-repo workspace collecting reports in one place rather than scattering
273
+ them per-repo. Publishing a report to a hosted claude.ai artifact is orchestrator-
274
+ only and on explicit request; the agents never do it.
275
+
276
+ ## Package distribution
277
+
278
+ The product libraries (`@casomoltd/paye-calc`, `@casomoltd/nhs-pay`)
279
+ publish to the public npm registry via OIDC trusted publishing — install
280
+ with no auth or `.npmrc`. `@casomoltd/tooling` is **not** published; it's
281
+ consumed as a git dependency from this (public) repo.
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ # Ensure push.followTags is enabled when pushing to main.
3
+ # Non-main branches skip this check.
4
+
5
+ PUSHING_MAIN=false
6
+ while read -r _ _ REMOTE_REF _; do
7
+ case "$REMOTE_REF" in
8
+ refs/heads/main) PUSHING_MAIN=true ;;
9
+ esac
10
+ done
11
+
12
+ if [ "$PUSHING_MAIN" = false ]; then
13
+ exit 0
14
+ fi
15
+
16
+ FOLLOW=$(git config --get push.followTags 2>/dev/null)
17
+
18
+ if [ "$FOLLOW" != "true" ]; then
19
+ echo "push.followTags is not enabled."
20
+ echo "Run: git config push.followTags true"
21
+ exit 1
22
+ fi
@@ -0,0 +1,29 @@
1
+ #!/bin/sh
2
+ # Reject push to main if version hasn't changed vs origin/main.
3
+ # Non-main branches skip this check.
4
+
5
+ PUSHING_MAIN=false
6
+ while read -r _ _ REMOTE_REF _; do
7
+ case "$REMOTE_REF" in
8
+ refs/heads/main) PUSHING_MAIN=true ;;
9
+ esac
10
+ done
11
+
12
+ if [ "$PUSHING_MAIN" = false ]; then
13
+ exit 0
14
+ fi
15
+
16
+ LOCAL_VERSION=$(node -p "require('./package.json').version")
17
+ REMOTE_VERSION=$(git show origin/main:package.json 2>/dev/null \
18
+ | node -p \
19
+ "JSON.parse(require('fs').readFileSync(0)).version" \
20
+ 2>/dev/null)
21
+
22
+ if [ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]; then
23
+ echo \
24
+ "Version unchanged ($LOCAL_VERSION)." \
25
+ "Run: npm version patch|minor|major"
26
+ exit 1
27
+ fi
28
+
29
+ echo "Version: $REMOTE_VERSION -> $LOCAL_VERSION"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ npx --no -- commitlint --edit "$1"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ npm run check
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # Capture ref info from git — sub-scripts need it to detect
3
+ # whether we're pushing to main.
4
+ PRE_PUSH_REFS=$(cat)
5
+ export PRE_PUSH_REFS
6
+
7
+ # Uniform pre-push for every repo: check, build, then the version/tag gates.
8
+ # `npm run build` must exist in EVERY repo so this line never has to special-case
9
+ # one. Where a repo produces no build output (a source-only repo like tooling,
10
+ # consumed as a git-dep), define build as a no-op (`"build": "echo ..."`) rather
11
+ # than omitting it — a missing script would abort the push with "Missing script:
12
+ # build", which once made it ambiguous whether a source-only repo was even
13
+ # pushable. No-op keeps the step honest and uniform.
14
+ npm run check \
15
+ && npm run build \
16
+ && echo "$PRE_PUSH_REFS" | check-version \
17
+ && echo "$PRE_PUSH_REFS" | check-tags
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Measure reading difficulty of site pages from source files.
5
+ *
6
+ * Usage:
7
+ * npm run readability [-- slug] [-- --pretty] [-- --max-grade <n>]
8
+ *
9
+ * Arguments:
10
+ * slug Optional path segment (e.g. "finance/mortgage-calculator").
11
+ * If omitted, all pages discovered in src/app/ are checked.
12
+ * --pretty Print a human-readable table alongside JSON output.
13
+ * --max-grade Maximum consensus grade level (default: 12).
14
+ *
15
+ * Per-repo config:
16
+ * Place a readability.config.mjs in the project root to override
17
+ * defaults. Exports: wordSubs (object), maxGrade (number).
18
+ *
19
+ * Exit codes:
20
+ * 0 All pages pass the grade threshold
21
+ * 1 One or more pages exceed the threshold
22
+ */
23
+
24
+ import {parseArgs} from "node:util";
25
+ import path from "node:path";
26
+ import {loadConfig} from "./utils.mjs";
27
+ import {
28
+ discoverSlugs,
29
+ extractText,
30
+ scoreText,
31
+ formatPretty,
32
+ DEFAULT_MAX_GRADE,
33
+ DEFAULT_WORD_SUBS,
34
+ } from "../readability/index.mjs";
35
+
36
+ const PROJECT_ROOT = process.cwd();
37
+ const SRC_APP = path.join(PROJECT_ROOT, "src", "app");
38
+
39
+ // ---------------------------------------------------------------
40
+ // Main
41
+ // ---------------------------------------------------------------
42
+
43
+ async function main() {
44
+ const {values, positionals} = parseArgs({
45
+ options: {
46
+ pretty: {type: "boolean", default: false},
47
+ "max-grade": {type: "string"},
48
+ },
49
+ allowPositionals: true,
50
+ });
51
+
52
+ const config = await loadConfig("readability.config.mjs");
53
+
54
+ const wordSubs = config.wordSubs ?? DEFAULT_WORD_SUBS;
55
+ const maxGrade =
56
+ values["max-grade"] != null
57
+ ? Number(values["max-grade"])
58
+ : (config.maxGrade ?? DEFAULT_MAX_GRADE);
59
+
60
+ const slug = positionals[0];
61
+ const slugs = slug
62
+ ? [`/${slug.replace(/^\//, "")}`]
63
+ : discoverSlugs(SRC_APP);
64
+
65
+ const pages = [];
66
+ for (const s of slugs) {
67
+ try {
68
+ const rel = s === "/" ? "" : s.slice(1);
69
+ const filePath = path.join(SRC_APP, rel, "page.tsx");
70
+ const text = extractText(filePath);
71
+ const result = scoreText(text, wordSubs);
72
+ const pass =
73
+ result.consensusGrade == null ||
74
+ result.consensusGrade <= maxGrade;
75
+ pages.push({slug: s, ...result, pass});
76
+ } catch (err) {
77
+ pages.push({
78
+ slug: s,
79
+ wordCount: 0,
80
+ scores: null,
81
+ consensusGrade: null,
82
+ pass: true,
83
+ error: err.message,
84
+ });
85
+ }
86
+ }
87
+
88
+ const allPassed = pages.every((p) => p.pass);
89
+ const threshold = {maxGrade};
90
+ const results = {pages, threshold, allPassed};
91
+
92
+ if (values.pretty) {
93
+ console.error(formatPretty(results, threshold));
94
+ }
95
+ console.log(JSON.stringify(results, null, 2));
96
+
97
+ process.exit(allPassed ? 0 : 1);
98
+ }
99
+
100
+ main();
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Render a markdown + mermaid report to a self-contained HTML file and open it
5
+ * in the browser — the reliable renderer for agent reports (docs-xray,
6
+ * design-xray) whose mermaid diagrams render unreliably in editor previews.
7
+ *
8
+ * The report `.md` is inlined into an HTML harness that loads `marked` and
9
+ * `mermaid` from CDN, so the output is one portable file — no build step, no
10
+ * editor extension. Write reports into a repo-local dir (e.g. `scratch/`), not
11
+ * `/tmp`: sandboxed browsers (snap/flatpak) often can't read outside $HOME.
12
+ *
13
+ * Usage:
14
+ * node bin/render-report.mjs <report.md> [--no-open]
15
+ *
16
+ * Arguments:
17
+ * <report.md> Path to the markdown report. The HTML is written next to it as
18
+ * <report>.html.
19
+ * --no-open Write the HTML but don't launch a browser (print the path only).
20
+ *
21
+ * Exit codes:
22
+ * 0 HTML written (and opened unless --no-open)
23
+ * 1 Bad arguments or the input file is missing
24
+ */
25
+ import {spawn} from "node:child_process";
26
+ import {existsSync, readFileSync, writeFileSync} from "node:fs";
27
+ import {resolve} from "node:path";
28
+
29
+ const args = process.argv.slice(2);
30
+ const noOpen = args.includes("--no-open");
31
+ const mdPath = args.find((a) => !a.startsWith("--"));
32
+
33
+ if (!mdPath || !existsSync(mdPath)) {
34
+ console.error("usage: render-report.mjs <report.md> [--no-open]");
35
+ process.exit(1);
36
+ }
37
+
38
+ const htmlPath = mdPath.replace(/\.md$/, "") + ".html";
39
+
40
+ // A mermaid classDiagram rejects union/bracket types in a member line
41
+ // (`date|None`, `list[Item]`) — soften them inside class bodies so an imperfect
42
+ // source (e.g. a design-xray map) still renders. Scoped to classDiagram fences
43
+ // only, so other diagrams (docs-xray flowcharts) are left untouched.
44
+ const softenClassDiagrams = (text) =>
45
+ text.replace(/```mermaid\n([\s\S]*?)```/g, (block, body) =>
46
+ /\bclassDiagram\b/.test(body)
47
+ ? "```mermaid\n" +
48
+ body.replace(/\{[^{}]*\}/g, (b) =>
49
+ b.replace(/\|/g, " or ").replace(/[[\]]/g, ""),
50
+ ) +
51
+ "```"
52
+ : block,
53
+ );
54
+
55
+ // Inline the markdown in a <script type="text/markdown"> so its backticks and
56
+ // fences aren't parsed as HTML. The only hazard is a literal </script> in the
57
+ // source, which we defuse.
58
+ const md = softenClassDiagrams(readFileSync(mdPath, "utf8")).replaceAll(
59
+ "</script>",
60
+ "<\\/script>",
61
+ );
62
+
63
+ const html = `<!doctype html>
64
+ <html lang="en">
65
+ <head>
66
+ <meta charset="utf-8">
67
+ <meta name="viewport" content="width=device-width, initial-scale=1">
68
+ <title>${mdPath}</title>
69
+ <style>
70
+ body { max-width: 1200px; margin: 2rem auto; padding: 0 1.5rem;
71
+ font: 15px/1.6 system-ui, sans-serif; color: #1a1a1a; }
72
+ h1, h2, h3 { line-height: 1.25; }
73
+ h2 { border-bottom: 1px solid #eee; padding-bottom: .3rem; margin-top: 2.5rem; }
74
+ table { border-collapse: collapse; margin: 1rem 0; font-size: 14px; }
75
+ th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; vertical-align: top; }
76
+ th { background: #f7f7f7; }
77
+ code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: .9em; }
78
+ pre { background: #f7f7f7; padding: 1rem; border-radius: 6px; overflow-x: auto; }
79
+ pre.mermaid { background: #fff; }
80
+ a { color: #0b66c3; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div id="content">Rendering…</div>
85
+ <script id="md" type="text/markdown">
86
+ ${md}
87
+ </script>
88
+ <script type="module">
89
+ import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
90
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
91
+ mermaid.initialize({ startOnLoad: false, theme: "neutral" });
92
+ const el = document.getElementById("content");
93
+ el.innerHTML = marked.parse(document.getElementById("md").textContent);
94
+ // marked emits mermaid fences as <code class="language-mermaid">; mermaid wants
95
+ // <pre class="mermaid">.
96
+ el.querySelectorAll("code.language-mermaid").forEach((c) => {
97
+ const pre = document.createElement("pre");
98
+ pre.className = "mermaid";
99
+ pre.textContent = c.textContent;
100
+ c.closest("pre").replaceWith(pre);
101
+ });
102
+ await mermaid.run({ querySelector: ".mermaid" });
103
+ </script>
104
+ </body>
105
+ </html>
106
+ `;
107
+
108
+ writeFileSync(htmlPath, html);
109
+ const url = `file://${resolve(htmlPath)}`;
110
+ console.log(url);
111
+
112
+ if (!noOpen) {
113
+ // text/html must be associated with a browser, not a mail client; if the
114
+ // opener mis-routes on Linux, run e.g.
115
+ // xdg-mime default firefox_firefox.desktop text/html
116
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
117
+ spawn(opener, [url], {detached: true, stdio: "ignore"}).unref();
118
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Claude Code skill/agent frontmatter",
4
+ "description": "The loader needs name + description; anything else (tools, model, user-invocable, argument-hint, allowed-tools…) is allowed. Validating this catches the case Claude Code swallows silently — malformed YAML loads with empty metadata.",
5
+ "type": "object",
6
+ "required": ["name", "description"],
7
+ "properties": {
8
+ "name": {"type": "string", "minLength": 1},
9
+ "description": {"type": "string", "minLength": 1}
10
+ }
11
+ }
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Capture a screenshot of the running dev server.
5
+ *
6
+ * Usage:
7
+ * npm run ss [page] [--width <n>] [--height <n>]
8
+ *
9
+ * Arguments:
10
+ * page Optional path segment appended to the base URL. Also used as the
11
+ * filename prefix. Defaults to "base" (captures the root page).
12
+ *
13
+ * Options:
14
+ * --preset <name> Viewport preset: mobile (390×844),
15
+ * tablet (768×1024), desktop (1280×800)
16
+ * --width <n> Viewport width in pixels (default: 1280)
17
+ * --height <n> Viewport height in pixels (default: 800)
18
+ *
19
+ * When --preset is given it overrides --width and --height.
20
+ *
21
+ * Examples:
22
+ * npm run ss # 1280×800 desktop
23
+ * npm run ss --preset mobile # 390×844 mobile
24
+ * npm run ss contact --preset tablet # /contact at 768×1024
25
+ *
26
+ * Environment:
27
+ * SCREENSHOT_URL Base URL to capture (default: http://localhost:3000)
28
+ *
29
+ * Output:
30
+ * .claude/screenshots/{page}-{timestamp}.png Timestamped capture
31
+ * .claude/screenshots/latest.png Always the most recent capture
32
+ */
33
+
34
+ import { parseArgs } from "node:util";
35
+ import path from "path";
36
+ import fs from "fs";
37
+
38
+ // puppeteer is an optional peer dependency (it pulls Chromium, ~200MB), so
39
+ // consumers that never screenshot don't install it. Load it lazily and fail
40
+ // loud if the screenshot bin is invoked without it.
41
+ async function loadPuppeteer() {
42
+ try {
43
+ return (await import("puppeteer")).default;
44
+ } catch {
45
+ throw new Error(
46
+ "puppeteer is not installed — add it as a devDependency " +
47
+ "(npm i -D puppeteer) to use the screenshot bin.",
48
+ );
49
+ }
50
+ }
51
+
52
+ const BASE_URL =
53
+ process.env.SCREENSHOT_URL || "http://localhost:3000";
54
+ const OUTPUT_DIR = path.join(
55
+ process.cwd(),
56
+ ".claude",
57
+ "screenshots",
58
+ );
59
+
60
+ const PRESETS = {
61
+ mobile: { width: 390, height: 844 },
62
+ tablet: { width: 768, height: 1024 },
63
+ desktop: { width: 1280, height: 800 },
64
+ };
65
+
66
+ const { values, positionals } = parseArgs({
67
+ options: {
68
+ preset: { type: "string" },
69
+ width: { type: "string", default: "1280" },
70
+ height: { type: "string", default: "800" },
71
+ },
72
+ allowPositionals: true,
73
+ });
74
+
75
+ async function serverIsUp(url) {
76
+ try {
77
+ await fetch(url, {signal: AbortSignal.timeout(3000)});
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ async function screenshot() {
85
+ if (!(await serverIsUp(BASE_URL))) {
86
+ console.error(
87
+ `screenshot: server not reachable at ${BASE_URL}\n` +
88
+ `Start the dev server first (npm run dev).`,
89
+ );
90
+ process.exit(1);
91
+ }
92
+
93
+ const pageName = positionals[0] || "base";
94
+ const preset = values.preset
95
+ ? PRESETS[values.preset]
96
+ : undefined;
97
+ if (values.preset && !preset) {
98
+ const names = Object.keys(PRESETS).join(", ");
99
+ throw new Error(
100
+ `Unknown preset "${values.preset}". ` +
101
+ `Choose from: ${names}`,
102
+ );
103
+ }
104
+ const width = preset?.width ?? Number(values.width);
105
+ const height = preset?.height ?? Number(values.height);
106
+ const safePrefix = pageName.replace(/\//g, "-");
107
+ const timestamp = new Date()
108
+ .toISOString()
109
+ .replace(/[:.]/g, "-");
110
+ const filename = `${safePrefix}-${timestamp}.png`;
111
+
112
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
113
+
114
+ const puppeteer = await loadPuppeteer();
115
+ const browser = await puppeteer.launch({
116
+ headless: true,
117
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
118
+ });
119
+
120
+ try {
121
+ const page = await browser.newPage();
122
+ await page.setViewport({ width, height });
123
+
124
+ const url =
125
+ pageName === "base"
126
+ ? BASE_URL
127
+ : `${BASE_URL}/${pageName}`;
128
+ console.log(`Navigating to ${url}...`);
129
+
130
+ await page.goto(url, {
131
+ waitUntil: "networkidle0",
132
+ timeout: 30000,
133
+ });
134
+
135
+ const filepath = path.join(OUTPUT_DIR, filename);
136
+ const latestPath = path.join(OUTPUT_DIR, "latest.png");
137
+
138
+ await page.screenshot({ path: filepath, fullPage: true });
139
+ fs.copyFileSync(filepath, latestPath);
140
+
141
+ console.log(`Screenshot saved: ${filepath}`);
142
+ console.log(`Latest alias: ${latestPath}`);
143
+ } finally {
144
+ await browser.close();
145
+ }
146
+ }
147
+
148
+ screenshot().catch((err) => {
149
+ console.error("Screenshot failed:", err.message);
150
+ process.exit(1);
151
+ });
@@ -0,0 +1,44 @@
1
+ // remark config for linting a Claude Code skills/agents/docs tree — the basic
2
+ // smoke test that catches most breakage a rename or hand-edit leaves behind:
3
+ // invalid YAML frontmatter and broken markdown links/anchors. Nothing custom,
4
+ // just three stock remark plugins.
5
+ //
6
+ // remark-frontmatter recognise the `---` YAML block
7
+ // remark-validate-links every relative link + #anchor resolves
8
+ // remark-lint-frontmatter-schema frontmatter parses and has name+description
9
+ //
10
+ // Run with cwd = the repo being linted, pointing remark at this file:
11
+ // remark --frail --quiet --no-stdout --rc-path <this> <paths…>
12
+ // (`--frail` turns findings into a non-zero exit — the gate.) Both the file
13
+ // globs and the schema path are resolved relative to cwd by the plugin, so the
14
+ // schema — which lives here in `tooling` — is expressed relative to whatever
15
+ // cwd remark runs in, letting one config serve tooling's CI and a consumer
16
+ // repo's hook alike.
17
+ import path from "node:path";
18
+ import {fileURLToPath} from "node:url";
19
+ import remarkFrontmatter from "remark-frontmatter";
20
+ import remarkValidateLinks from "remark-validate-links";
21
+ import remarkLintFrontmatterSchema from "remark-lint-frontmatter-schema";
22
+
23
+ const schemaAbs = fileURLToPath(
24
+ new URL("./schemas/frontmatter.schema.json", import.meta.url),
25
+ );
26
+ const schema = path.relative(process.cwd(), schemaAbs);
27
+
28
+ // `**` skips dot-directories, so the plain globs match a non-dot tree (tooling's
29
+ // own `skills/`) while the `.claude/**` variants reach a repo's `.claude/` tree
30
+ // (where every consumer's skills + agents actually live). One config, both.
31
+ const targets = [
32
+ "**/SKILL.md",
33
+ ".claude/**/SKILL.md",
34
+ "**/agents/*.md",
35
+ ".claude/**/agents/*.md",
36
+ ];
37
+
38
+ export default {
39
+ plugins: [
40
+ remarkFrontmatter,
41
+ [remarkValidateLinks, {repository: false}],
42
+ [remarkLintFrontmatterSchema, {schemas: {[schema]: targets}}],
43
+ ],
44
+ };
package/bin/utils.mjs ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared helpers for CLI bin scripts.
3
+ */
4
+
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+ import {pathToFileURL} from "node:url";
8
+
9
+ /**
10
+ * Load an optional per-repo config file from cwd.
11
+ *
12
+ * Each CLI tool looks for `<tool>.config.mjs` (e.g.
13
+ * `readability.config.mjs`) in the consumer's project root.
14
+ */
15
+ export async function loadConfig(filename) {
16
+ const cfgPath = path.join(process.cwd(), filename);
17
+ if (!fs.existsSync(cfgPath)) return {};
18
+ return import(pathToFileURL(cfgPath).href);
19
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Hard leak-gate: assert the files a consumer would receive (via the
3
+ // `files` allowlist, on publish OR git-dependency install) never escape
4
+ // the allowlist and never look like a secret. Defence-in-depth on top of
5
+ // package.json "files" — this fails CI if that allowlist is ever loosened
6
+ // or a secret-bearing file sneaks in. The tooling repo is public, so the
7
+ // published/installed surface must stay configs + CLI only.
8
+ import {execSync} from "node:child_process";
9
+
10
+ // Dirs/files a consumer is allowed to receive. npm always adds
11
+ // package.json + LICENSE regardless of the allowlist, so permit those too.
12
+ const ALLOWED_PREFIXES = [
13
+ "bin/",
14
+ "eslint/",
15
+ "prettier/",
16
+ "commitlint/",
17
+ "knip/",
18
+ "jscpd/",
19
+ "readability/",
20
+ ];
21
+ const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
22
+
23
+ // Anything matching these must never ship, even from an allowed dir.
24
+ const SECRET_PATTERN =
25
+ /(^|\/)(\.env(\..*)?|.*settings\.local\.json|.*\.pem|id_[rd]sa|.*\.key|\.npmrc)$/i;
26
+
27
+ const raw = execSync("npm pack --dry-run --json", {encoding: "utf8"});
28
+ const files = JSON.parse(raw)[0].files.map((entry) => entry.path);
29
+
30
+ const escaped = files.filter(
31
+ (f) =>
32
+ !ALLOWED_FILES.includes(f) &&
33
+ !ALLOWED_PREFIXES.some((prefix) => f.startsWith(prefix)),
34
+ );
35
+ const secrets = files.filter((f) => SECRET_PATTERN.test(f));
36
+
37
+ if (escaped.length || secrets.length) {
38
+ if (escaped.length) {
39
+ console.error("✖ files outside the allowlist:\n " + escaped.join("\n "));
40
+ }
41
+ if (secrets.length) {
42
+ console.error("✖ secret-like files in package:\n " + secrets.join("\n "));
43
+ }
44
+ process.exit(1);
45
+ }
46
+
47
+ console.log(`verify-pack OK — ${files.length} files, all within allowlist`);
@@ -0,0 +1,22 @@
1
+ module.exports = {
2
+ rules: {
3
+ 'header-max-length': [2, 'always', 50],
4
+ 'body-max-line-length': [2, 'always', 72],
5
+ 'no-ai-attribution': [2, 'always'],
6
+ },
7
+ plugins: [
8
+ {
9
+ rules: {
10
+ 'no-ai-attribution': ({ header, body }) => {
11
+ const fullMessage = `${header}\n${body || ''}`.toLowerCase();
12
+ const forbidden = ['anthropic', 'claude', 'co-authored-by'];
13
+ const found = forbidden.filter((term) => fullMessage.includes(term));
14
+ return [
15
+ found.length === 0,
16
+ `Commit must not mention AI tools: found "${found.join(', ')}"`,
17
+ ];
18
+ },
19
+ },
20
+ },
21
+ ],
22
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Creates the shared ESLint base config.
3
+ *
4
+ * Consumers must pass the resolved imports since this package
5
+ * has no dependencies of its own.
6
+ *
7
+ * @param {{ defineConfig, globalIgnores }} eslintConfig - from "eslint/config"
8
+ * @param {Array} nextVitals - from "eslint-config-next/core-web-vitals"
9
+ * @param {Array} nextTs - from "eslint-config-next/typescript"
10
+ * @param {{ configs: { recommended: object } }} sonarjs - from "eslint-plugin-sonarjs"
11
+ */
12
+ export function createBaseConfig({
13
+ defineConfig,
14
+ globalIgnores,
15
+ nextVitals,
16
+ nextTs,
17
+ sonarjs,
18
+ }) {
19
+ return defineConfig([
20
+ ...nextVitals,
21
+ ...nextTs,
22
+ sonarjs.configs.recommended,
23
+ globalIgnores([
24
+ ".next/**",
25
+ "out/**",
26
+ "build/**",
27
+ "next-env.d.ts",
28
+ ".prettierrc.js",
29
+ ]),
30
+ {
31
+ files: ["**/*.cjs"],
32
+ rules: {
33
+ "@typescript-eslint/no-require-imports": "off",
34
+ },
35
+ },
36
+ {
37
+ rules: {
38
+ "max-len": [
39
+ "error",
40
+ {
41
+ code: 88,
42
+ ignoreUrls: true,
43
+ ignoreStrings: true,
44
+ ignoreTemplateLiterals: true,
45
+ ignoreRegExpLiterals: true,
46
+ },
47
+ ],
48
+ },
49
+ },
50
+ ]);
51
+ }
package/eslint/lib.mjs ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Creates a shared ESLint config for TypeScript libraries
3
+ * (non-Next.js projects).
4
+ *
5
+ * Consumers must pass the resolved imports since this package
6
+ * has no dependencies of its own.
7
+ *
8
+ * @param {{ config: Function }} tseslint - from "typescript-eslint"
9
+ * @param {{ configs: { recommended: object } }} sonarjs
10
+ * - from "eslint-plugin-sonarjs"
11
+ */
12
+ export function createLibConfig({tseslint, sonarjs}) {
13
+ return tseslint.config(
14
+ ...tseslint.configs.recommended,
15
+ sonarjs.configs.recommended,
16
+ {
17
+ ignores: ['dist/**', 'node_modules/**'],
18
+ },
19
+ {
20
+ files: ['**/*.cjs'],
21
+ rules: {
22
+ '@typescript-eslint/no-require-imports': 'off',
23
+ },
24
+ },
25
+ {
26
+ rules: {
27
+ 'max-len': [
28
+ 'error',
29
+ {
30
+ code: 88,
31
+ ignoreUrls: true,
32
+ ignoreStrings: true,
33
+ ignoreTemplateLiterals: true,
34
+ ignoreRegExpLiterals: true,
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ );
40
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "threshold": 5,
3
+ "reporters": ["console"],
4
+ "ignore": ["**/dist/**", "**/node_modules/**", "**/*.lock"]
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "ignore": ["dist/**"],
3
+ "ignoreDependencies": []
4
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@casomoltd/tooling",
3
+ "version": "0.20.0",
4
+ "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots)",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/casomoltd/tooling.git"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "eslint/",
12
+ "prettier/",
13
+ "commitlint/",
14
+ "knip/",
15
+ "jscpd/",
16
+ "readability/",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "check": "node bin/verify-pack.mjs && tsc --noEmit -p hooks/tsconfig.json && node hooks/test.mts && npm run lint:skills",
21
+ "lint:skills": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills docs",
22
+ "build": "echo 'no-op: source-only repo (consumed as a git-dep), nothing to build'",
23
+ "prepare": "husky || true"
24
+ },
25
+ "exports": {
26
+ "./eslint": "./eslint/index.mjs",
27
+ "./eslint-lib": "./eslint/lib.mjs",
28
+ "./prettier": "./prettier/index.js",
29
+ "./commitlint": "./commitlint/index.cjs",
30
+ "./knip": "./knip/index.json",
31
+ "./jscpd": "./jscpd/index.json",
32
+ "./readability": "./readability/index.mjs"
33
+ },
34
+ "bin": {
35
+ "check-tags": "./bin/check-tags.sh",
36
+ "check-version": "./bin/check-version.sh",
37
+ "commit-msg": "./bin/commit-msg.sh",
38
+ "pre-commit": "./bin/pre-commit.sh",
39
+ "pre-push": "./bin/pre-push.sh",
40
+ "readability": "./bin/readability.mjs",
41
+ "screenshot": "./bin/screenshot.mjs"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "dependencies": {
47
+ "text-readability": "^1.1.1"
48
+ },
49
+ "peerDependencies": {
50
+ "@commitlint/cli": ">=20",
51
+ "eslint": ">=9",
52
+ "eslint-config-next": ">=16",
53
+ "eslint-plugin-sonarjs": ">=3",
54
+ "jscpd": ">=4",
55
+ "knip": ">=5",
56
+ "puppeteer": ">=24"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "puppeteer": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@types/node": "^26.0.0",
65
+ "husky": "^9.1.7",
66
+ "remark-cli": "^12.0.1",
67
+ "remark-frontmatter": "^5.0.0",
68
+ "remark-lint-frontmatter-schema": "^3.15.4",
69
+ "remark-validate-links": "^13.1.0",
70
+ "typescript": "^6.0.3"
71
+ }
72
+ }
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ arrowParens: 'avoid',
3
+ bracketSameLine: true,
4
+ bracketSpacing: false,
5
+ singleQuote: true,
6
+ trailingComma: 'all',
7
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Readability scoring for Next.js page content.
3
+ *
4
+ * Extracts prose from JSX source files and computes scores
5
+ * against 7 readability formulas. Designed to be called from
6
+ * the CLI wrapper or programmatically.
7
+ */
8
+
9
+ import rs from "text-readability";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const DEFAULT_MAX_GRADE = 12;
14
+
15
+ export const DEFAULT_WORD_SUBS = {
16
+ calculators: "tools",
17
+ calculator: "tool",
18
+ };
19
+
20
+ // -------------------------------------------------------------------
21
+ // Discover page slugs from an app-router directory
22
+ // -------------------------------------------------------------------
23
+
24
+ export function discoverSlugs(srcAppDir) {
25
+ const entries = fs.readdirSync(srcAppDir, {recursive: true});
26
+ return entries
27
+ .filter((e) => e.endsWith("page.tsx"))
28
+ .map((e) => {
29
+ const dir = path.dirname(e);
30
+ return dir === "." ? "/" : `/${dir}`;
31
+ })
32
+ .sort();
33
+ }
34
+
35
+ // -------------------------------------------------------------------
36
+ // Extract prose text from a page.tsx source file
37
+ // -------------------------------------------------------------------
38
+
39
+ export function extractText(filePath) {
40
+ const source = fs.readFileSync(filePath, "utf-8");
41
+
42
+ const jsxText = [];
43
+ const tagContentRe = />([^<>{]+)</g;
44
+ let match;
45
+ while ((match = tagContentRe.exec(source)) !== null) {
46
+ const text = match[1].trim();
47
+ if (text) jsxText.push(text);
48
+ }
49
+
50
+ // String literals that look like readable prose
51
+ const stringRe = /['"`]([A-Z][^'"`]{20,})['"`]/g;
52
+ while ((match = stringRe.exec(source)) !== null) {
53
+ jsxText.push(match[1].trim());
54
+ }
55
+
56
+ // Terminate fragments so formulas count separate sentences
57
+ const SENT_END = /[.!?]$/;
58
+ const terminated = jsxText.map((t) =>
59
+ SENT_END.test(t) ? t : t + "."
60
+ );
61
+
62
+ let text = terminated.join(" ");
63
+ text = text.replace(/\{[^}]*\}/g, " ");
64
+ text = text.replace(/&nbsp;/g, " ");
65
+ text = text.replace(/&amp;/g, "&");
66
+ text = text.replace(/&lt;/g, "<");
67
+ text = text.replace(/&gt;/g, ">");
68
+ text = text.replace(/&[a-z]+;/g, " ");
69
+ text = text.replace(/\s+/g, " ").trim();
70
+
71
+ return text;
72
+ }
73
+
74
+ // -------------------------------------------------------------------
75
+ // Compute readability scores
76
+ // -------------------------------------------------------------------
77
+
78
+ function round(n) {
79
+ return Math.round(n * 10) / 10;
80
+ }
81
+
82
+ function applyWordSubs(text, wordSubs) {
83
+ let out = text;
84
+ for (const [word, sub] of Object.entries(wordSubs)) {
85
+ out = out.replace(
86
+ new RegExp(`\\b${word}\\b`, "gi"),
87
+ sub,
88
+ );
89
+ }
90
+ return out;
91
+ }
92
+
93
+ export function scoreText(text, wordSubs = DEFAULT_WORD_SUBS) {
94
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
95
+ if (wordCount < 100) {
96
+ return {wordCount, scores: null, consensusGrade: null};
97
+ }
98
+ const normalized = applyWordSubs(text, wordSubs);
99
+ const scores = {
100
+ fleschReadingEase: round(
101
+ rs.fleschReadingEase(normalized),
102
+ ),
103
+ fleschKincaidGrade: round(
104
+ rs.fleschKincaidGrade(normalized),
105
+ ),
106
+ gunningFog: round(rs.gunningFog(normalized)),
107
+ colemanLiau: round(rs.colemanLiauIndex(normalized)),
108
+ smog: round(rs.smogIndex(normalized)),
109
+ ari: round(
110
+ rs.automatedReadabilityIndex(normalized),
111
+ ),
112
+ daleChall: round(
113
+ rs.daleChallReadabilityScore(normalized),
114
+ ),
115
+ };
116
+ const gradeScores = [
117
+ scores.fleschKincaidGrade,
118
+ scores.gunningFog,
119
+ scores.colemanLiau,
120
+ scores.smog,
121
+ scores.ari,
122
+ ];
123
+ gradeScores.sort((a, b) => a - b);
124
+ const consensusGrade = round(
125
+ gradeScores[Math.floor(gradeScores.length / 2)],
126
+ );
127
+ return {wordCount, scores, consensusGrade};
128
+ }
129
+
130
+ // -------------------------------------------------------------------
131
+ // Pretty-print a human-readable table
132
+ // -------------------------------------------------------------------
133
+
134
+ function pad(n) {
135
+ return n == null ? "N/A" : String(n).padStart(6);
136
+ }
137
+
138
+ export function formatPretty(results, threshold) {
139
+ const lines = [];
140
+ for (const r of results.pages) {
141
+ const status = r.pass ? "PASS" : "FAIL";
142
+ lines.push(`\n${status} ${r.slug}`);
143
+ lines.push("─".repeat(44));
144
+ if (!r.scores) {
145
+ lines.push(" (too few words to score)");
146
+ continue;
147
+ }
148
+ lines.push(
149
+ ` Flesch Reading Ease ${pad(r.scores.fleschReadingEase)}`,
150
+ );
151
+ lines.push(
152
+ ` Flesch-Kincaid Grade ${pad(r.scores.fleschKincaidGrade)}`,
153
+ );
154
+ lines.push(
155
+ ` Gunning Fog ${pad(r.scores.gunningFog)}`,
156
+ );
157
+ lines.push(
158
+ ` Coleman-Liau ${pad(r.scores.colemanLiau)}`,
159
+ );
160
+ lines.push(
161
+ ` SMOG ${pad(r.scores.smog)}`,
162
+ );
163
+ lines.push(
164
+ ` ARI ${pad(r.scores.ari)}`,
165
+ );
166
+ lines.push(
167
+ ` Dale-Chall ${pad(r.scores.daleChall)}`,
168
+ );
169
+ lines.push("─".repeat(44));
170
+ lines.push(
171
+ ` Consensus Grade Level ${pad(r.consensusGrade)}`,
172
+ );
173
+ lines.push(
174
+ ` Word Count ${r.wordCount.toLocaleString()}`,
175
+ );
176
+ }
177
+ lines.push(
178
+ `\nThreshold: grade ${threshold.maxGrade} | ` +
179
+ `${results.allPassed ? "All passed" : "FAILURES DETECTED"}`,
180
+ );
181
+ return lines.join("\n");
182
+ }