@casomoltd/tooling 0.23.5 → 0.24.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/bin/build-report.mjs +0 -0
- package/bin/check-gates.mjs +0 -0
- package/bin/check-private-refs.mjs +175 -0
- package/bin/readability.mjs +20 -2
- package/package.json +5 -3
- package/readability/index.mjs +131 -14
package/bin/build-report.mjs
CHANGED
|
File without changes
|
package/bin/check-gates.mjs
CHANGED
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fail a PUBLIC repo that names something only its authors can reach.
|
|
4
|
+
*
|
|
5
|
+
* A private tracker link, an internal hostname, the name of a repo
|
|
6
|
+
* nobody outside can clone — each is a disclosure, and none of them is
|
|
7
|
+
* a syntax error. Existing cover is guidance and a human reviewer, both
|
|
8
|
+
* fallible, for a mistake that is unrecoverable the moment it is
|
|
9
|
+
* pushed: the commit is public even if the file is deleted afterwards.
|
|
10
|
+
*
|
|
11
|
+
* The failure this exists for is not carelessness. It is a CORRECT rule
|
|
12
|
+
* followed into the wrong repo — "link the tracking task" is good
|
|
13
|
+
* advice, and doing it in a published package is how the reference gets
|
|
14
|
+
* out. So the check runs on the repo's visibility, not on the author's
|
|
15
|
+
* intent.
|
|
16
|
+
*
|
|
17
|
+
* VISIBILITY FIRST, always. A private repo SHOULD carry tracker links;
|
|
18
|
+
* flagging them there would train everyone to ignore this.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately configurable rather than opinionated. This file ships in
|
|
21
|
+
* a public package, so it cannot itself name anybody's private repos,
|
|
22
|
+
* boards or hosts — that would be the disclosure it exists to prevent.
|
|
23
|
+
* The defaults cover hosted trackers that are private by default; a
|
|
24
|
+
* consuming repo adds its own names in `check-private-refs.config.mjs`:
|
|
25
|
+
*
|
|
26
|
+
* export default {
|
|
27
|
+
* visibility: 'public', // optional; skips the gh lookup
|
|
28
|
+
* forbidden: [ // strings or RegExp
|
|
29
|
+
* {pattern: /internal\.example\.net/, why: 'internal hostname'},
|
|
30
|
+
* ],
|
|
31
|
+
* allow: [/CHANGELOG\.md$/], // paths exempted, with care
|
|
32
|
+
* };
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {execFileSync} from "node:child_process";
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import {loadConfig} from "./utils.mjs";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hosted trackers whose links are private by default.
|
|
42
|
+
*
|
|
43
|
+
* Generic on purpose: every one of these is a product many teams use,
|
|
44
|
+
* so naming them discloses nothing about who is running this check.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_FORBIDDEN = [
|
|
47
|
+
{pattern: /\bnotion\.so\//i, why: "Notion link"},
|
|
48
|
+
{pattern: /\bapp\.notion\.com\//i, why: "Notion link"},
|
|
49
|
+
{pattern: /\blinear\.app\/[\w-]+\/issue\//i, why: "Linear issue link"},
|
|
50
|
+
{pattern: /\b[\w-]+\.atlassian\.net\//i, why: "Jira/Confluence link"},
|
|
51
|
+
{pattern: /\bapp\.shortcut\.com\//i, why: "Shortcut story link"},
|
|
52
|
+
{pattern: /\bapp\.asana\.com\//i, why: "Asana task link"},
|
|
53
|
+
{pattern: /\btrello\.com\/c\//i, why: "Trello card link"},
|
|
54
|
+
{pattern: /\bheight\.app\//i, why: "Height task link"},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Text files only. A match inside a binary is noise, not a leak. */
|
|
58
|
+
const SKIP_EXT = new Set([
|
|
59
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".svg",
|
|
60
|
+
".pdf", ".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
61
|
+
".zip", ".gz", ".tgz", ".mp4", ".webm", ".lock",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function repoVisibility(configured) {
|
|
65
|
+
if (configured) return configured;
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync(
|
|
68
|
+
"gh", ["repo", "view", "--json", "visibility", "-q", ".visibility"],
|
|
69
|
+
{encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"]},
|
|
70
|
+
);
|
|
71
|
+
return out.trim().toLowerCase();
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function trackedFiles() {
|
|
78
|
+
const out = execFileSync("git", ["ls-files"], {encoding: "utf-8"});
|
|
79
|
+
return out.split("\n").filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toMatchers(entries) {
|
|
83
|
+
return entries.map((e) => {
|
|
84
|
+
if (typeof e === "string") {
|
|
85
|
+
return {pattern: new RegExp(e), why: e};
|
|
86
|
+
}
|
|
87
|
+
if (e instanceof RegExp) return {pattern: e, why: String(e)};
|
|
88
|
+
return {
|
|
89
|
+
pattern: e.pattern instanceof RegExp
|
|
90
|
+
? e.pattern
|
|
91
|
+
: new RegExp(e.pattern),
|
|
92
|
+
why: e.why ?? String(e.pattern),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
const mod = await loadConfig("check-private-refs.config.mjs");
|
|
99
|
+
// `loadConfig` returns the MODULE, so the config is its default
|
|
100
|
+
// export. Reading the module object itself silently found no
|
|
101
|
+
// patterns and reported a clean repo.
|
|
102
|
+
const config = mod?.default ?? {};
|
|
103
|
+
const visibility = repoVisibility(config.visibility);
|
|
104
|
+
|
|
105
|
+
if (visibility === null) {
|
|
106
|
+
// Not fatal, but never silent. A repo whose visibility we cannot
|
|
107
|
+
// establish has not been checked, and saying "skipped" is the
|
|
108
|
+
// difference between a gap you know about and one you do not.
|
|
109
|
+
console.warn(
|
|
110
|
+
"check-private-refs: SKIPPED — could not determine repo "
|
|
111
|
+
+ "visibility (is `gh` installed and authenticated?). Set "
|
|
112
|
+
+ "`visibility` in check-private-refs.config.mjs to be explicit.",
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (visibility !== "public") {
|
|
117
|
+
console.log(
|
|
118
|
+
`check-private-refs: repo is ${visibility} — private references `
|
|
119
|
+
+ "are expected here, nothing to check",
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const matchers = toMatchers([
|
|
125
|
+
...DEFAULT_FORBIDDEN,
|
|
126
|
+
...(config.forbidden ?? []),
|
|
127
|
+
]);
|
|
128
|
+
const allow = (config.allow ?? []).map((a) =>
|
|
129
|
+
a instanceof RegExp ? a : new RegExp(a));
|
|
130
|
+
|
|
131
|
+
const findings = [];
|
|
132
|
+
for (const file of trackedFiles()) {
|
|
133
|
+
if (SKIP_EXT.has(path.extname(file).toLowerCase())) continue;
|
|
134
|
+
if (allow.some((a) => a.test(file))) continue;
|
|
135
|
+
let lines;
|
|
136
|
+
try {
|
|
137
|
+
lines = fs.readFileSync(file, "utf-8").split("\n");
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
lines.forEach((line, i) => {
|
|
142
|
+
for (const {pattern, why} of matchers) {
|
|
143
|
+
if (pattern.test(line)) {
|
|
144
|
+
findings.push({
|
|
145
|
+
file, line: i + 1, why, text: line.trim().slice(0, 120),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (findings.length === 0) {
|
|
153
|
+
console.log(
|
|
154
|
+
`check-private-refs OK — public repo, ${matchers.length} patterns, `
|
|
155
|
+
+ "0 private references",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.error(
|
|
161
|
+
`\ncheck-private-refs: ${findings.length} private reference(s) in a `
|
|
162
|
+
+ "PUBLIC repo:\n",
|
|
163
|
+
);
|
|
164
|
+
for (const f of findings) {
|
|
165
|
+
console.error(` ${f.file}:${f.line} — ${f.why}\n ${f.text}`);
|
|
166
|
+
}
|
|
167
|
+
console.error(
|
|
168
|
+
"\nA public repo must name only what its readers can open. Where a "
|
|
169
|
+
+ "tracker link is what you wanted, name the retirement CONDITION "
|
|
170
|
+
+ "instead, or link an issue on this repo.",
|
|
171
|
+
);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main();
|
package/bin/readability.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {parseArgs} from "node:util";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import {loadConfig} from "./utils.mjs";
|
|
27
27
|
import {
|
|
28
|
+
MIN_SCORABLE_WORDS,
|
|
28
29
|
discoverSlugs,
|
|
29
30
|
extractText,
|
|
30
31
|
scoreText,
|
|
@@ -74,26 +75,43 @@ async function main() {
|
|
|
74
75
|
result.consensusGrade <= maxGrade;
|
|
75
76
|
pages.push({slug: s, ...result, pass});
|
|
76
77
|
} catch (err) {
|
|
78
|
+
// A page we could not read is a FAILURE, not a pass. Recording
|
|
79
|
+
// it as passing meant a broken path or an unreadable file
|
|
80
|
+
// reported green, which is the same shape of blind spot as
|
|
81
|
+
// treating an unscored page as a pass.
|
|
77
82
|
pages.push({
|
|
78
83
|
slug: s,
|
|
79
84
|
wordCount: 0,
|
|
80
85
|
scores: null,
|
|
81
86
|
consensusGrade: null,
|
|
82
|
-
pass:
|
|
87
|
+
pass: false,
|
|
83
88
|
error: err.message,
|
|
84
89
|
});
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
const allPassed = pages.every((p) => p.pass);
|
|
94
|
+
// Named separately from the pass/fail count. An unscored page is not
|
|
95
|
+
// evidence of anything, and it should not be able to hide inside a
|
|
96
|
+
// green run — the reader of this output has to be told how much of
|
|
97
|
+
// the site was actually measured.
|
|
98
|
+
const unscored = pages.filter((p) => p.unscored).map((p) => p.slug);
|
|
89
99
|
const threshold = {maxGrade};
|
|
90
|
-
const results = {pages, threshold, allPassed};
|
|
100
|
+
const results = {pages, threshold, allPassed, unscored};
|
|
91
101
|
|
|
92
102
|
if (values.pretty) {
|
|
93
103
|
console.error(formatPretty(results, threshold));
|
|
94
104
|
}
|
|
95
105
|
console.log(JSON.stringify(results, null, 2));
|
|
96
106
|
|
|
107
|
+
if (unscored.length > 0) {
|
|
108
|
+
console.error(
|
|
109
|
+
`\nreadability: ${unscored.length} page(s) under `
|
|
110
|
+
+ `${MIN_SCORABLE_WORDS} words were NOT scored — they are not `
|
|
111
|
+
+ `passing, they are unmeasured:\n ${unscored.join("\n ")}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
process.exit(allPassed ? 0 : 1);
|
|
98
116
|
}
|
|
99
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
"!**/*.test.mjs"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run typecheck && npm run test && npm run lint:md",
|
|
23
|
+
"check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run typecheck && npm run test && npm run lint:md",
|
|
24
24
|
"typecheck": "tsc --noEmit -p hooks/tsconfig.json",
|
|
25
25
|
"test": "node hooks/test.mts && node bin/check-gates.test.mjs",
|
|
26
26
|
"lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills docs",
|
|
27
27
|
"build": "echo 'no-op: source-only repo, nothing to build'",
|
|
28
|
-
"prepare": "husky || true"
|
|
28
|
+
"prepare": "husky || true",
|
|
29
|
+
"check:private-refs": "node bin/check-private-refs.mjs"
|
|
29
30
|
},
|
|
30
31
|
"casomo": {
|
|
31
32
|
"gates": {
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"bin": {
|
|
49
50
|
"build-report": "./bin/build-report.mjs",
|
|
50
51
|
"check-gates": "./bin/check-gates.mjs",
|
|
52
|
+
"check-private-refs": "./bin/check-private-refs.mjs",
|
|
51
53
|
"check-tags": "./bin/check-tags.sh",
|
|
52
54
|
"check-version": "./bin/check-version.sh",
|
|
53
55
|
"commit-msg": "./bin/commit-msg.sh",
|
package/readability/index.mjs
CHANGED
|
@@ -36,36 +36,138 @@ export function discoverSlugs(srcAppDir) {
|
|
|
36
36
|
// Extract prose text from a page.tsx source file
|
|
37
37
|
// -------------------------------------------------------------------
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a project-local import specifier to a file on disk.
|
|
41
|
+
*
|
|
42
|
+
* Relative (`./x`) and src-aliased (`@/x`) only. A bare specifier is a
|
|
43
|
+
* package and is deliberately not followed: node_modules prose is not
|
|
44
|
+
* this site's prose.
|
|
45
|
+
*/
|
|
46
|
+
function resolveLocalImport(spec, fromFile, srcDir) {
|
|
47
|
+
let base;
|
|
48
|
+
if (spec.startsWith(".")) {
|
|
49
|
+
base = path.resolve(path.dirname(fromFile), spec);
|
|
50
|
+
} else if (spec.startsWith("@/") && srcDir) {
|
|
51
|
+
base = path.join(srcDir, spec.slice(2));
|
|
52
|
+
} else {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const candidates = [
|
|
56
|
+
base,
|
|
57
|
+
`${base}.tsx`,
|
|
58
|
+
`${base}.ts`,
|
|
59
|
+
path.join(base, "index.tsx"),
|
|
60
|
+
path.join(base, "index.ts"),
|
|
61
|
+
];
|
|
62
|
+
for (const c of candidates) {
|
|
63
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
41
67
|
|
|
68
|
+
/** The `src` directory a file sits under, for resolving `@/` imports. */
|
|
69
|
+
function srcDirOf(filePath) {
|
|
70
|
+
const marker = `${path.sep}src${path.sep}`;
|
|
71
|
+
const i = filePath.lastIndexOf(marker);
|
|
72
|
+
return i === -1 ? null : filePath.slice(0, i + marker.length - 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const IMPORT_RE = /import\s+(?:type\s+)?(?:[\w*{},\s]+from\s+)?["']([^"']+)["']/g;
|
|
76
|
+
|
|
77
|
+
/** Prose lifted from ONE file's JSX, without following its imports. */
|
|
78
|
+
function extractOwnText(source) {
|
|
42
79
|
const jsxText = [];
|
|
43
|
-
|
|
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 = />([^<>]+)</g;
|
|
44
86
|
let match;
|
|
45
87
|
while ((match = tagContentRe.exec(source)) !== null) {
|
|
46
|
-
const text = match[1].trim();
|
|
88
|
+
const text = match[1].replace(/\{[^}]*\}/g, " ").trim();
|
|
47
89
|
if (text) jsxText.push(text);
|
|
48
90
|
}
|
|
49
91
|
|
|
50
|
-
// String literals that look like readable prose
|
|
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.
|
|
51
96
|
const stringRe = /['"`]([A-Z][^'"`]{20,})['"`]/g;
|
|
52
97
|
while ((match = stringRe.exec(source)) !== null) {
|
|
53
|
-
|
|
98
|
+
const candidate = match[1].trim();
|
|
99
|
+
if (isProse(candidate)) jsxText.push(candidate);
|
|
54
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;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a string is prose a reader would read, rather than data that
|
|
110
|
+
* happens to live in a string.
|
|
111
|
+
*
|
|
112
|
+
* Two tests, both cheap. It must be mostly letters — path data,
|
|
113
|
+
* class-name soup and number tables are not — and it must be more than
|
|
114
|
+
* a couple of words, which drops the fragments left behind when a JSX
|
|
115
|
+
* expression is stripped out of a short line.
|
|
116
|
+
*/
|
|
117
|
+
function isProse(text) {
|
|
118
|
+
const compact = text.replace(/\s+/g, "");
|
|
119
|
+
if (compact.length === 0) return false;
|
|
120
|
+
const letters = (compact.match(/[A-Za-z]/g) ?? []).length;
|
|
121
|
+
if (letters / compact.length < 0.6) return false;
|
|
122
|
+
const words = text.split(/\s+/).filter((w) => /[A-Za-z]{2,}/.test(w));
|
|
123
|
+
return words.length >= 3;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Prose a reader actually sees on a page, following the components it
|
|
128
|
+
* imports.
|
|
129
|
+
*
|
|
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.
|
|
135
|
+
*
|
|
136
|
+
* Depth-limited and cycle-safe. Only project-local imports are
|
|
137
|
+
* followed; a package's prose is not this site's prose.
|
|
138
|
+
*/
|
|
139
|
+
export function extractText(filePath, {maxDepth = 3} = {}) {
|
|
140
|
+
const srcDir = srcDirOf(filePath);
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const collected = [];
|
|
143
|
+
|
|
144
|
+
const walk = (file, depth) => {
|
|
145
|
+
const resolved = path.resolve(file);
|
|
146
|
+
if (seen.has(resolved) || depth > maxDepth) return;
|
|
147
|
+
seen.add(resolved);
|
|
148
|
+
const source = fs.readFileSync(resolved, "utf-8");
|
|
149
|
+
collected.push(...extractOwnText(source));
|
|
150
|
+
if (depth === maxDepth) return;
|
|
151
|
+
for (const m of source.matchAll(IMPORT_RE)) {
|
|
152
|
+
const target = resolveLocalImport(m[1], resolved, srcDir);
|
|
153
|
+
if (target) walk(target, depth + 1);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
walk(filePath, 0);
|
|
55
157
|
|
|
56
158
|
// Terminate fragments so formulas count separate sentences
|
|
57
159
|
const SENT_END = /[.!?]$/;
|
|
58
|
-
const terminated =
|
|
160
|
+
const terminated = collected.map((t) =>
|
|
59
161
|
SENT_END.test(t) ? t : t + "."
|
|
60
162
|
);
|
|
61
163
|
|
|
62
164
|
let text = terminated.join(" ");
|
|
63
165
|
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, " ");
|
|
166
|
+
text = text.replace(/&nbsp;/g, " ");
|
|
167
|
+
text = text.replace(/&amp;/g, "&");
|
|
168
|
+
text = text.replace(/&lt;/g, "<");
|
|
169
|
+
text = text.replace(/&gt;/g, ">");
|
|
170
|
+
text = text.replace(/&[a-z]+;/g, " ");
|
|
69
171
|
text = text.replace(/\s+/g, " ").trim();
|
|
70
172
|
|
|
71
173
|
return text;
|
|
@@ -90,10 +192,25 @@ function applyWordSubs(text, wordSubs) {
|
|
|
90
192
|
return out;
|
|
91
193
|
}
|
|
92
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The floor below which the formulas are not meaningful.
|
|
197
|
+
*
|
|
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.
|
|
202
|
+
*/
|
|
203
|
+
export const MIN_SCORABLE_WORDS = 100;
|
|
204
|
+
|
|
93
205
|
export function scoreText(text, wordSubs = DEFAULT_WORD_SUBS) {
|
|
94
206
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
95
|
-
if (wordCount <
|
|
96
|
-
return {
|
|
207
|
+
if (wordCount < MIN_SCORABLE_WORDS) {
|
|
208
|
+
return {
|
|
209
|
+
wordCount,
|
|
210
|
+
scores: null,
|
|
211
|
+
consensusGrade: null,
|
|
212
|
+
unscored: true,
|
|
213
|
+
};
|
|
97
214
|
}
|
|
98
215
|
const normalized = applyWordSubs(text, wordSubs);
|
|
99
216
|
const scores = {
|