@casomoltd/tooling 0.23.5 → 0.24.1

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.
File without changes
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();
@@ -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: true,
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.23.5",
3
+ "version": "0.24.1",
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",
@@ -36,36 +36,138 @@ export function discoverSlugs(srcAppDir) {
36
36
  // Extract prose text from a page.tsx source file
37
37
  // -------------------------------------------------------------------
38
38
 
39
- export function extractText(filePath) {
40
- const source = fs.readFileSync(filePath, "utf-8");
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
- const tagContentRe = />([^<>{]+)</g;
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;
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
- jsxText.push(match[1].trim());
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 = jsxText.map((t) =>
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(/&nbsp;/g, " ");
65
- text = text.replace(/&amp;/g, "&");
66
- text = text.replace(/&lt;/g, "<");
67
- text = text.replace(/&gt;/g, ">");
68
- text = text.replace(/&[a-z]+;/g, " ");
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, " ");
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 < 100) {
96
- return {wordCount, scores: null, consensusGrade: null};
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 = {