@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 +281 -0
- package/bin/check-tags.sh +22 -0
- package/bin/check-version.sh +29 -0
- package/bin/commit-msg.sh +2 -0
- package/bin/pre-commit.sh +2 -0
- package/bin/pre-push.sh +17 -0
- package/bin/readability.mjs +100 -0
- package/bin/render-report.mjs +118 -0
- package/bin/schemas/frontmatter.schema.json +11 -0
- package/bin/screenshot.mjs +151 -0
- package/bin/skills-lint.config.mjs +44 -0
- package/bin/utils.mjs +19 -0
- package/bin/verify-pack.mjs +47 -0
- package/commitlint/index.cjs +22 -0
- package/eslint/index.mjs +51 -0
- package/eslint/lib.mjs +40 -0
- package/jscpd/index.json +5 -0
- package/knip/index.json +4 -0
- package/package.json +72 -0
- package/prettier/index.js +7 -0
- package/readability/index.mjs +182 -0
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"
|
package/bin/pre-push.sh
ADDED
|
@@ -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
|
+
};
|
package/eslint/index.mjs
ADDED
|
@@ -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
|
+
}
|
package/jscpd/index.json
ADDED
package/knip/index.json
ADDED
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,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(/ /g, " ");
|
|
65
|
+
text = text.replace(/&/g, "&");
|
|
66
|
+
text = text.replace(/</g, "<");
|
|
67
|
+
text = text.replace(/>/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
|
+
}
|