@casomoltd/tooling 0.36.0 → 0.37.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 CHANGED
@@ -8,8 +8,8 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
8
8
 
9
9
  | Export | Description |
10
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 |
11
+ | `@casomoltd/tooling/eslint` | `createBaseConfig()` — ESLint flat config with Next.js, TypeScript, SonarJS and the house rules ([docs/lint.md](docs/lint.md)) |
12
+ | `@casomoltd/tooling/eslint-lib` | `createLibConfig()` — the same house rules for a TypeScript library ([docs/lint.md](docs/lint.md)) |
13
13
  | `@casomoltd/tooling/prettier` | Prettier config object |
14
14
  | `@casomoltd/tooling/commitlint` | commitlint config with `no-ai-attribution` plugin |
15
15
  | `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
@@ -25,7 +25,8 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
25
25
  | `check-linear` | Pre-push guard — rejects a push that would put a merge commit on the remote. History is linear; a branch catches up by rebasing. Only what the push adds is judged, so merges already on the remote stay |
26
26
  | `pre-push` | Husky pre-push hook — runs check, then the version, tag and linear-history guards |
27
27
  | `pre-commit` | Husky pre-commit hook — runs `npm run check` |
28
- | `commit-msg` | Husky commit-msg hook — runs commitlint |
28
+ | `commit-msg` | Husky commit-msg hook — runs commitlint, then `check-listed-files` |
29
+ | `check-listed-files` | Refuse a commit that changes a file still listed in `eslint-suppressions.json`, unless a `Lint-deferred:` trailer says why ([docs/lint.md](docs/lint.md)) |
29
30
  | `readability` | Measure reading difficulty of page content |
30
31
  | `screenshot` | Capture a dev server page via Playwright — an agent's visual feedback loop |
31
32
  | `build-report` | Compile a Typst client report to PDF with the house template |
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refuse a commit that changes a file still listed in the repo's
4
+ * `eslint-suppressions.json`, unless the message says why it is deferred.
5
+ *
6
+ * The house size limits are lint errors, and a file that already broke one
7
+ * when the limit was switched on is listed there instead of failing. The
8
+ * list only shrinks if touching a file is the moment it is tidied: tidy it
9
+ * in its own commit (fix it, then `eslint --prune-suppressions`), and the
10
+ * change that needed it follows. ESLint itself fails while a fixed file
11
+ * keeps its entry, so a tidied file always comes off the list. A commit that
12
+ * has to change a listed file anyway carries a trailer naming why, so the
13
+ * deferral shows in `git log`:
14
+ *
15
+ * Lint-deferred: a one-line hotfix; the tidy follows in its own commit
16
+ *
17
+ * What it reads, and what it assumes:
18
+ *
19
+ * - **The list as staged**, so the commit that tidies a file and drops its
20
+ * entry passes.
21
+ * - **The files staged now**, renames counted under their old path too. An
22
+ * amend is checked on what it newly stages, not on the whole commit.
23
+ * - **One list, at the repo root**, keyed by root-relative path, which is
24
+ * where ESLint writes it when run from the root. A list anywhere else
25
+ * fails the check rather than being silently missed.
26
+ * - **No merge commits:** a merge carries changes each branch already
27
+ * committed and was checked for.
28
+ *
29
+ * Runs from the shared commit-msg hook, the first point the message exists.
30
+ *
31
+ * Usage:
32
+ * check-listed-files <commit-message-file>
33
+ */
34
+ import {execFileSync} from "node:child_process";
35
+ import {readFileSync, realpathSync} from "node:fs";
36
+ import {pathToFileURL} from "node:url";
37
+
38
+ /** ESLint's default suppressions file, at the repo root. */
39
+ export const SUPPRESSIONS_FILE = "eslint-suppressions.json";
40
+ /** The trailer that lets a listed file through, with a reason on the same
41
+ * line. Git reads trailer keys without regard to case. */
42
+ export const DEFERRED_TRAILER = /^Lint-deferred:[ \t]*\S/im;
43
+
44
+ /**
45
+ * The staged files that are listed, each with the rules it is listed for.
46
+ *
47
+ * @param {readonly string[]} staged - repo-relative paths in the commit
48
+ * @param {Record<string, Record<string, {count: number}>>} listed - the
49
+ * staged suppressions file, keyed by repo-relative path
50
+ * @returns {{file: string, rules: string[]}[]}
51
+ */
52
+ export function listedInCommit(staged, listed) {
53
+ return staged
54
+ .filter((file) => Object.hasOwn(listed, file))
55
+ .map((file) => ({file, rules: Object.keys(listed[file])}));
56
+ }
57
+
58
+ /** Whether the message defers the tidy with a reason. */
59
+ export function defers(message) {
60
+ return DEFERRED_TRAILER.test(message);
61
+ }
62
+
63
+ const git = (...args) => execFileSync("git", args,
64
+ {encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]});
65
+
66
+ /** Whether git can resolve a ref or object, without throwing. */
67
+ function resolves(ref) {
68
+ try {
69
+ git("rev-parse", "-q", "--verify", ref);
70
+ return true;
71
+ } catch {
72
+ // Not resolvable is the answer asked for, not a failure.
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /** The repo's suppressions as staged; none where the repo stages no list.
78
+ * A list that is staged but unreadable throws: it is not an empty list. */
79
+ function stagedSuppressions() {
80
+ if (!resolves(`:${SUPPRESSIONS_FILE}`)) return {};
81
+ return JSON.parse(git("show", `:${SUPPRESSIONS_FILE}`));
82
+ }
83
+
84
+ /** Suppressions files tracked anywhere but the repo root. */
85
+ function listsBelowRoot() {
86
+ return git("ls-files", `*${SUPPRESSIONS_FILE}`).split("\n")
87
+ .filter((path) => path && path !== SUPPRESSIONS_FILE);
88
+ }
89
+
90
+ /** The paths the commit touches, a rename under both its old and new path
91
+ * and a deletion under its own. */
92
+ function stagedPaths() {
93
+ return git("diff", "--cached", "--name-only", "--no-renames",
94
+ "--diff-filter=ACMRD").split("\n").filter(Boolean);
95
+ }
96
+
97
+ function main(messageFile) {
98
+ if (resolves("MERGE_HEAD")) return;
99
+ const elsewhere = listsBelowRoot();
100
+ if (elsewhere.length > 0) {
101
+ console.error("check-listed-files: suppressions lists below the repo "
102
+ + `root are not read, so this check cannot hold: ${elsewhere.join(", ")}`);
103
+ process.exit(1);
104
+ }
105
+ const hits = listedInCommit(stagedPaths(), stagedSuppressions());
106
+ if (hits.length === 0) return;
107
+ if (defers(readFileSync(messageFile, "utf8"))) {
108
+ for (const {file} of hits) {
109
+ console.warn(`check-listed-files: ${file} is listed; tidy deferred`);
110
+ }
111
+ return;
112
+ }
113
+ console.error("check-listed-files: this commit changes files listed in "
114
+ + `${SUPPRESSIONS_FILE}. Tidy each in its own commit first (fix it, `
115
+ + "then eslint --prune-suppressions), or add a 'Lint-deferred: <why>' "
116
+ + "trailer:");
117
+ for (const {file, rules} of hits) {
118
+ console.error(` ${file}: ${rules.join(", ")}`);
119
+ }
120
+ process.exit(1);
121
+ }
122
+
123
+ // Run through npm's bin symlink, argv[1] is the link and import.meta.url
124
+ // the real file, so compare real paths.
125
+ if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
126
+ main(process.argv[2]);
127
+ }
package/bin/commit-msg.sh CHANGED
@@ -1,2 +1,6 @@
1
1
  #!/bin/sh
2
+ # Stop at the first check that fails, so a later one cannot mask it.
3
+ set -e
2
4
  npx --no -- commitlint --edit "$1"
5
+ # Resolve the npm bin symlink so the check is found beside this script.
6
+ node "$(dirname "$(realpath "$0")")/check-listed-files.mjs" "$1"
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The house rules every repo's ESLint config shares: line length; the size
3
+ * of a function, its parameter list, a file, its branching and nesting;
4
+ * named numbers; no non-null assertions; and exhaustive switches. Both the
5
+ * Next config (`index.mjs`) and the library config (`lib.mjs`) spread these
6
+ * in, so each rule is stated once. Every rule is on unless a reason is
7
+ * written beside the exception.
8
+ *
9
+ * They are errors, never warnings: a warning nobody has to act on is how a
10
+ * module grows past what anyone can read. A file that already breaks one is
11
+ * recorded in the repo's `eslint-suppressions.json` (`eslint
12
+ * --suppress-all`), and `check-listed-files` fails a commit that touches it
13
+ * until it is tidied, unless the message carries a `Lint-deferred:` reason.
14
+ * A repo whose files are transcribed reference data (published tables,
15
+ * cited at the data) exempts those paths from
16
+ * `@typescript-eslint/no-magic-numbers` in its own config, saying why.
17
+ * What each rule is for, and every exemption, is in `docs/lint.md`.
18
+ */
19
+
20
+ /** Lines a function may run to, blank lines and comments not counted:
21
+ * ESLint's default. */
22
+ const FUNCTION_LINES = 50;
23
+ /** Parameters a function may take; past this, pass a value object.
24
+ * Proposed here; no external source (ESLint's default is 3). */
25
+ const PARAMS = 4;
26
+ /** Lines a file may run to, blank lines and comments not counted.
27
+ * Proposed here; no external source (ESLint's default is 300). */
28
+ const FILE_LINES = 400;
29
+ /** Cyclomatic complexity a function may reach. Proposed here; no external
30
+ * source (ESLint's default is 20). */
31
+ const COMPLEXITY = 10;
32
+
33
+ /** How deep blocks may nest before the code should be flattened.
34
+ * Proposed here; no external source (ESLint's default is 4). */
35
+ const DEPTH = 3;
36
+
37
+ const SKIP_BLANKS_AND_COMMENTS = { skipBlankLines: true, skipComments: true };
38
+
39
+ /** The rules, as one flat-config block for every file. */
40
+ export const HOUSE_RULES = {
41
+ rules: {
42
+ // URLs and regex literals are the deviations from 88: neither can be
43
+ // broken without changing it. No other long line is allowed, so strings
44
+ // and template literals are not exempt.
45
+ 'max-len': [
46
+ 'error',
47
+ { code: 88, ignoreUrls: true, ignoreRegExpLiterals: true },
48
+ ],
49
+ 'max-lines-per-function': [
50
+ 'error', { max: FUNCTION_LINES, ...SKIP_BLANKS_AND_COMMENTS },
51
+ ],
52
+ // The TypeScript version does not count a `this:` type annotation.
53
+ 'max-params': 'off',
54
+ '@typescript-eslint/max-params': ['error', { max: PARAMS }],
55
+ 'max-lines': ['error', { max: FILE_LINES, ...SKIP_BLANKS_AND_COMMENTS }],
56
+ complexity: ['error', COMPLEXITY],
57
+ 'max-depth': ['error', DEPTH],
58
+ // A number in code is named, so the reader sees what it is. Counting
59
+ // and indexing numbers, enums and type positions are not magic, nor a
60
+ // parameter's default or a readonly class property, which already name
61
+ // the number they hold. The TypeScript version replaces the base rule.
62
+ 'no-magic-numbers': 'off',
63
+ '@typescript-eslint/no-magic-numbers': ['error', {
64
+ ignore: [-1, 0, 1],
65
+ ignoreArrayIndexes: true,
66
+ ignoreDefaultValues: true,
67
+ ignoreEnums: true,
68
+ ignoreNumericLiteralTypes: true,
69
+ ignoreReadonlyClassProperties: true,
70
+ ignoreTypeIndexes: true,
71
+ }],
72
+ // A `!` tells the compiler a value exists without checking that it does.
73
+ '@typescript-eslint/no-non-null-assertion': 'error',
74
+ },
75
+ };
76
+
77
+ /** Rules that need the type checker, for TypeScript files only. A switch
78
+ * over a union names every member, so a new member is a lint error at each
79
+ * switch rather than a silent fall-through. Type information also brings on the
80
+ * type-aware rules in sonarjs's recommended set, which are silent
81
+ * without it. */
82
+ export const TYPED_RULES = {
83
+ files: ['**/*.ts', '**/*.tsx', '**/*.mts'],
84
+ // A declaration file holds no code to switch on.
85
+ ignores: ['**/*.d.ts', '**/*.d.mts'],
86
+ languageOptions: { parserOptions: { projectService: true } },
87
+ rules: { '@typescript-eslint/switch-exhaustiveness-check': 'error' },
88
+ };
89
+
90
+ /** A test file's cases often sit in one top-level function by design (a
91
+ * `describe` block, an assert script), and its expected figures are
92
+ * literals by design, so function length and magic numbers are not held
93
+ * against test files; every other limit is. */
94
+ export const TEST_FILES_EXEMPT = {
95
+ files: [
96
+ '**/*.test.*', '**/*.spec.*', '**/tests/**', '**/test/**', '**/__tests__/**',
97
+ ],
98
+ rules: {
99
+ 'max-lines-per-function': 'off',
100
+ '@typescript-eslint/no-magic-numbers': 'off',
101
+ },
102
+ };
package/eslint/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {HOUSE_RULES, TEST_FILES_EXEMPT, TYPED_RULES} from "./house-rules.mjs";
2
+
1
3
  /**
2
4
  * Creates the shared ESLint base config.
3
5
  *
@@ -33,19 +35,8 @@ export function createBaseConfig({
33
35
  "@typescript-eslint/no-require-imports": "off",
34
36
  },
35
37
  },
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
- },
38
+ HOUSE_RULES,
39
+ TYPED_RULES,
40
+ TEST_FILES_EXEMPT,
50
41
  ]);
51
42
  }
package/eslint/lib.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {HOUSE_RULES, TEST_FILES_EXEMPT, TYPED_RULES} from './house-rules.mjs';
2
+
1
3
  /**
2
4
  * Creates a shared ESLint config for TypeScript libraries
3
5
  * (non-Next.js projects).
@@ -22,19 +24,19 @@ export function createLibConfig({tseslint, sonarjs}) {
22
24
  '@typescript-eslint/no-require-imports': 'off',
23
25
  },
24
26
  },
27
+ HOUSE_RULES,
28
+ TYPED_RULES,
25
29
  {
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
- ],
30
+ // A library's tsconfig includes only its sources and tests, so a root
31
+ // config file (vitest.config.ts) sits outside every project.
32
+ files: TYPED_RULES.files,
33
+ ignores: TYPED_RULES.ignores,
34
+ languageOptions: {
35
+ parserOptions: {
36
+ projectService: {allowDefaultProject: ['*.config.ts', '*.config.mts']},
37
+ },
37
38
  },
38
39
  },
40
+ TEST_FILES_EXEMPT,
39
41
  );
40
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,9 +22,10 @@
22
22
  "!**/*.test.mjs"
23
23
  ],
24
24
  "scripts": {
25
- "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run typecheck && npm run test && npm run lint:md",
25
+ "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run lint && npm run typecheck && npm run test && npm run lint:md",
26
+ "lint": "eslint .",
26
27
  "typecheck": "tsc --noEmit -p hooks/tsconfig.json",
27
- "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-personal-data.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs",
28
+ "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-listed-files.test.mjs && node bin/check-personal-data.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs",
28
29
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
29
30
  "build": "echo 'no-op: source-only repo, nothing to build'",
30
31
  "prepare": "husky || true",
@@ -33,7 +34,6 @@
33
34
  },
34
35
  "casomo": {
35
36
  "gates": {
36
- "lint": "publishes the shared eslint config but has none of its own yet",
37
37
  "knip": "no knip config for this repo's mixed .mts/.mjs layout yet",
38
38
  "jscpd": "no jscpd config for this repo yet",
39
39
  "spell": "no shared cspell config exists yet",
@@ -58,6 +58,7 @@
58
58
  "buttondown": "./bin/buttondown.mjs",
59
59
  "check-gates": "./bin/check-gates.mjs",
60
60
  "check-hooks": "./bin/check-hooks.sh",
61
+ "check-listed-files": "./bin/check-listed-files.mjs",
61
62
  "check-linear": "./bin/check-linear.sh",
62
63
  "check-personal-data": "./bin/check-personal-data.mjs",
63
64
  "check-private-refs": "./bin/check-private-refs.mjs",
@@ -80,20 +81,24 @@
80
81
  },
81
82
  "peerDependencies": {
82
83
  "@commitlint/cli": ">=20",
83
- "eslint": ">=9",
84
+ "eslint": ">=9.24",
84
85
  "eslint-config-next": ">=16",
85
86
  "eslint-plugin-sonarjs": ">=3",
86
87
  "jscpd": ">=4",
87
- "knip": ">=5"
88
+ "knip": ">=5",
89
+ "typescript": ">=5"
88
90
  },
89
91
  "devDependencies": {
90
92
  "@types/node": "^26.0.0",
93
+ "eslint": "^9.39.5",
94
+ "eslint-plugin-sonarjs": "^4.2.2",
91
95
  "husky": "^9.1.7",
92
96
  "remark-cli": "^12.0.1",
93
97
  "remark-frontmatter": "^5.0.0",
94
98
  "remark-lint-frontmatter-schema": "^3.15.4",
95
99
  "remark-validate-links": "^13.1.0",
96
- "typescript": "^6.0.3"
100
+ "typescript": "^6.0.3",
101
+ "typescript-eslint": "^8.72.0"
97
102
  },
98
103
  "overrides": {
99
104
  "remark-lint-frontmatter-schema": {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import rs from "text-readability";
10
+ import ts from "typescript";
10
11
  import fs from "node:fs";
11
12
  import path from "node:path";
12
13
 
@@ -17,6 +18,34 @@ export const DEFAULT_WORD_SUBS = {
17
18
  calculator: "tool",
18
19
  };
19
20
 
21
+ const IMPORT_RE = /import\s+(?:type\s+)?(?:[\w*{},\s]+from\s+)?["']([^"']+)["']/g;
22
+
23
+ /** Elements that sit inside a sentence rather than starting one: their
24
+ * text joins the block around them. Everything else is a block, whose
25
+ * text is a sentence or more of its own. Matched on the tag's own text,
26
+ * so a member tag (`<Foo.Link>`) counts as a block. */
27
+ const INLINE_TAGS = new Set([
28
+ "a", "Link", "strong", "em", "b", "i", "u", "span", "code", "abbr",
29
+ "sup", "sub", "small", "mark", "time", "br", "wbr",
30
+ ]);
31
+
32
+ /** What an interpolated value reads as inside a sentence: a figure. */
33
+ const FIGURE = "1";
34
+
35
+ /** The shortest string literal read as prose: shorter ones are labels. */
36
+ const MIN_PROSE_CHARS = 20;
37
+
38
+ /** A run of at least two letters: the least that reads as a word. */
39
+ const HAS_WORD = /[A-Za-z]{2,}/;
40
+
41
+ /** Typographic entities, read as the characters they stand for. */
42
+ const ENTITIES = {
43
+ "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">",
44
+ "&rsquo;": "\u2019", "&lsquo;": "\u2018", "&rdquo;": "\u201d",
45
+ "&ldquo;": "\u201c", "&ndash;": "\u2013", "&mdash;": "\u2014",
46
+ "&hellip;": "\u2026", "&#39;": "'", "&quot;": "\"",
47
+ };
48
+
20
49
  // -------------------------------------------------------------------
21
50
  // Discover page slugs from an app-router directory
22
51
  // -------------------------------------------------------------------
@@ -72,37 +101,121 @@ function srcDirOf(filePath) {
72
101
  return i === -1 ? null : filePath.slice(0, i + marker.length - 1);
73
102
  }
74
103
 
75
- const IMPORT_RE = /import\s+(?:type\s+)?(?:[\w*{},\s]+from\s+)?["']([^"']+)["']/g;
104
+ /**
105
+ * Prose lifted from ONE file's JSX, without following its imports.
106
+ *
107
+ * Parsed, because a JSX file is mostly code and code is full of `<` and
108
+ * `>` (generics, arrow functions, comparisons): only a parser can tell
109
+ * JSX text from it. What comes back is each block's text, with its links
110
+ * and emphasis folded into the sentence they sit in and each
111
+ * interpolated value read as a figure, plus string literals that read as
112
+ * prose. Those include attribute values, because a component's
113
+ * `description="…"` is copy on the page, and they also include strings a
114
+ * reader never sees (a metadata title, an error message): an
115
+ * approximation of what the page shows, close enough to grade it.
116
+ */
117
+ function extractOwnText(source, fileName) {
118
+ const sf = ts.createSourceFile(
119
+ fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX,
120
+ );
121
+ const out = [];
76
122
 
77
- /** Prose lifted from ONE file's JSX, without following its imports. */
78
- function extractOwnText(source) {
79
- const jsxText = [];
80
-
81
- // Tag content. `{` is ALLOWED inside the match and the expressions
82
- // are stripped further down. Excluding it dropped the whole sentence
83
- // rather than the interpolation, so any served line carrying a
84
- // figure — most of the prose on a pay page — was invisible here.
85
- const tagContentRe = /&gt;([^&lt;&gt;]+)&lt;/g;
86
- let match;
87
- while ((match = tagContentRe.exec(source)) !== null) {
88
- const text = match[1].replace(/\{[^}]*\}/g, " ").trim();
89
- if (text) jsxText.push(text);
90
- }
123
+ const tagOf = (el) => {
124
+ const opening = ts.isJsxElement(el) ? el.openingElement : el;
125
+ return opening.tagName.getText(sf);
126
+ };
127
+ const isInline = (el) =>
128
+ (ts.isJsxElement(el) || ts.isJsxSelfClosingElement(el))
129
+ && INLINE_TAGS.has(tagOf(el));
91
130
 
92
- // String literals that look like readable prose. "Look like" is
93
- // doing real work: an SVG `d` attribute begins with a capital M and
94
- // runs for thousands of characters, and counting one as a sentence
95
- // put a whole article three grades above its true score.
96
- const stringRe = /['"`]([A-Z][^'"`]{20,})['"`]/g;
97
- while ((match = stringRe.exec(source)) !== null) {
98
- const candidate = match[1].trim();
99
- if (isProse(candidate)) jsxText.push(candidate);
100
- }
101
- // NOT filtered as a whole: tag content is legitimately fragmentary,
102
- // because a formatter wraps one served sentence across several
103
- // lines. Only string literals need the prose test, and only they can
104
- // contain an SVG path.
105
- return jsxText;
131
+ // The text a block reads as: its own text, its inline children's,
132
+ // and a figure for each interpolation. Block children are left out:
133
+ // the walk below scores each as its own block.
134
+ const inlineText = (children) => children.map((child) => {
135
+ if (ts.isJsxText(child)) return child.text;
136
+ if (ts.isJsxExpression(child)) {
137
+ return child.expression ? expressionText(child.expression) : "";
138
+ }
139
+ if (isInline(child)) {
140
+ return ts.isJsxElement(child) ? inlineText(child.children) : " ";
141
+ }
142
+ return "";
143
+ }).join("");
144
+
145
+ // What an interpolation reads as. A string is its text; a conditional
146
+ // reads as its first branch, the one a reader sees by default; inline
147
+ // JSX joins the sentence; block JSX is scored on its own by the walk
148
+ // below; anything else is a value, read as a figure.
149
+ const expressionText = (e) => {
150
+ if (ts.isParenthesizedExpression(e)) return expressionText(e.expression);
151
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) {
152
+ return e.text;
153
+ }
154
+ if (ts.isConditionalExpression(e)) return expressionText(e.whenTrue);
155
+ if (ts.isBinaryExpression(e)
156
+ && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
157
+ return expressionText(e.right);
158
+ }
159
+ if (isInline(e)) {
160
+ return ts.isJsxElement(e) ? inlineText(e.children) : " ";
161
+ }
162
+ return containsJsx(e) ? "" : ` ${FIGURE} `;
163
+ };
164
+
165
+ const containsJsx = (node) => {
166
+ let found = false;
167
+ const look = (n) => {
168
+ if (found) return;
169
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)
170
+ || ts.isJsxFragment(n)) {
171
+ found = true;
172
+ return;
173
+ }
174
+ ts.forEachChild(n, look);
175
+ };
176
+ look(node);
177
+ return found;
178
+ };
179
+
180
+ // A run of strings joined with `+`, read as the one sentence it is.
181
+ const stringOf = (node) => {
182
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
183
+ return node.text;
184
+ }
185
+ if (ts.isTemplateExpression(node)) {
186
+ return node.head.text + node.templateSpans
187
+ .map((span) => ` ${FIGURE} ${span.literal.text}`).join("");
188
+ }
189
+ if (ts.isParenthesizedExpression(node)) return stringOf(node.expression);
190
+ if (ts.isBinaryExpression(node)
191
+ && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
192
+ const left = stringOf(node.left);
193
+ const right = stringOf(node.right);
194
+ return left === null || right === null ? null : left + right;
195
+ }
196
+ return null;
197
+ };
198
+
199
+ const visit = (node) => {
200
+ const isBlock = (ts.isJsxElement(node) && !isInline(node))
201
+ || ts.isJsxFragment(node);
202
+ if (isBlock) {
203
+ const text = inlineText(node.children).replace(/\s+/g, " ").trim();
204
+ if (HAS_WORD.test(text)) out.push(text);
205
+ } else {
206
+ const text = stringOf(node);
207
+ if (text !== null) {
208
+ const prose = text.replace(/\s+/g, " ").trim();
209
+ const readsAsProse = /^[A-Z]/.test(prose)
210
+ && prose.length > MIN_PROSE_CHARS && isProse(prose);
211
+ if (readsAsProse) out.push(prose);
212
+ return;
213
+ }
214
+ }
215
+ ts.forEachChild(node, visit);
216
+ };
217
+ visit(sf);
218
+ return out;
106
219
  }
107
220
 
108
221
  /**
@@ -119,7 +232,7 @@ function isProse(text) {
119
232
  if (compact.length === 0) return false;
120
233
  const letters = (compact.match(/[A-Za-z]/g) ?? []).length;
121
234
  if (letters / compact.length < 0.6) return false;
122
- const words = text.split(/\s+/).filter((w) => /[A-Za-z]{2,}/.test(w));
235
+ const words = text.split(/\s+/).filter((w) => HAS_WORD.test(w));
123
236
  return words.length >= 3;
124
237
  }
125
238
 
@@ -127,11 +240,8 @@ function isProse(text) {
127
240
  * Prose a reader actually sees on a page, following the components it
128
241
  * imports.
129
242
  *
130
- * Import-following is the point. Until 3 Sep 2026 this read one file
131
- * and stopped, so every shared prose component was unmeasured — a
132
- * notice component scoring grade 12.6 while all five pages rendering
133
- * it reported PASS. A page's score has to cover what it renders, not
134
- * what happens to be written in its own file.
243
+ * Import-following is the point: a page's score has to cover the
244
+ * components it renders, not only what is written in its own file.
135
245
  *
136
246
  * Depth-limited and cycle-safe. Only project-local imports are
137
247
  * followed; a package's prose is not this site's prose.
@@ -144,9 +254,16 @@ export function extractText(filePath, {maxDepth = 3} = {}) {
144
254
  const walk = (file, depth) => {
145
255
  const resolved = path.resolve(file);
146
256
  if (seen.has(resolved) || depth > maxDepth) return;
257
+ // Components only. A `.ts` module's strings are mostly data (role
258
+ // lists, labels, the company statement) that the page shows only in
259
+ // part or elsewhere, and scoring them graded every page on the same
260
+ // shared lists. The cost: prose a page renders from a `.ts` module
261
+ // goes unread.
262
+ const importedNonComponent = depth > 0 && !resolved.endsWith(".tsx");
263
+ if (importedNonComponent) return;
147
264
  seen.add(resolved);
148
265
  const source = fs.readFileSync(resolved, "utf-8");
149
- collected.push(...extractOwnText(source));
266
+ collected.push(...extractOwnText(source, resolved));
150
267
  if (depth === maxDepth) return;
151
268
  for (const m of source.matchAll(IMPORT_RE)) {
152
269
  const target = resolveLocalImport(m[1], resolved, srcDir);
@@ -162,12 +279,7 @@ export function extractText(filePath, {maxDepth = 3} = {}) {
162
279
  );
163
280
 
164
281
  let text = terminated.join(" ");
165
- text = text.replace(/\{[^}]*\}/g, " ");
166
- text = text.replace(/&amp;nbsp;/g, " ");
167
- text = text.replace(/&amp;amp;/g, "&amp;");
168
- text = text.replace(/&amp;lt;/g, "&lt;");
169
- text = text.replace(/&amp;gt;/g, "&gt;");
170
- text = text.replace(/&amp;[a-z]+;/g, " ");
282
+ text = text.replace(/&#?\w+;/g, (entity) => ENTITIES[entity] ?? " ");
171
283
  text = text.replace(/\s+/g, " ").trim();
172
284
 
173
285
  return text;
@@ -195,10 +307,8 @@ function applyWordSubs(text, wordSubs) {
195
307
  /**
196
308
  * The floor below which the formulas are not meaningful.
197
309
  *
198
- * A page under it is UNSCORED, not passing. The two used to be
199
- * indistinguishable — the CLI read a null grade as a pass — so eight
200
- * of forty-five pages were reported green on the strength of no
201
- * measurement at all.
310
+ * A page under it is UNSCORED, not passing: a score that measured
311
+ * nothing is not evidence the page reads well.
202
312
  */
203
313
  export const MIN_SCORABLE_WORDS = 100;
204
314