@jterrazz/typescript 9.3.0 → 10.1.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.
Files changed (81) hide show
  1. package/README.md +20 -16
  2. package/bin/commands/check.sh +387 -146
  3. package/bin/find-tsc.sh +30 -0
  4. package/bin/typescript.sh +79 -1
  5. package/lib/check-architecture.js +89 -0
  6. package/lib/check-baseline.js +144 -0
  7. package/lib/check-docs.js +4 -3
  8. package/lib/check-drift.js +209 -0
  9. package/lib/check-gitignore.js +4 -4
  10. package/lib/check-markdown.js +279 -0
  11. package/lib/check-names.js +125 -0
  12. package/lib/check-publish.js +150 -0
  13. package/lib/check-secrets.js +115 -0
  14. package/lib/check-suppressions.js +355 -0
  15. package/lib/doctor.js +185 -0
  16. package/lib/entry-points.js +91 -0
  17. package/lib/merge-knip-config.js +57 -25
  18. package/lib/tracked-files.js +165 -0
  19. package/lib/unsafe-fixers.js +25 -0
  20. package/lib/workspace-members.js +5 -6
  21. package/package.json +36 -13
  22. package/presets/oxfmt/index.js +49 -5
  23. package/presets/oxlint/profiles/astro.js +10 -0
  24. package/presets/oxlint/profiles/bun.js +7 -0
  25. package/presets/oxlint/profiles/expo.js +7 -0
  26. package/presets/oxlint/profiles/library.js +16 -0
  27. package/presets/oxlint/profiles/next.js +7 -0
  28. package/presets/oxlint/profiles/node.js +7 -0
  29. package/presets/oxlint/profiles/react.js +7 -0
  30. package/presets/prettier/astro.json +6 -0
  31. package/presets/tsconfig/astro.json +25 -0
  32. package/presets/tsconfig/expo.json +16 -6
  33. package/presets/tsconfig/library.json +17 -0
  34. package/presets/tsconfig/next.json +12 -2
  35. package/presets/tsconfig/node.json +18 -4
  36. package/presets/tsconfig/react.json +33 -0
  37. package/presets/tsdown/build.d.ts +13 -0
  38. package/presets/tsdown/bundle.d.ts +13 -0
  39. package/presets/tsdown/bundle.js +10 -1
  40. package/rules/README.md +23 -0
  41. package/rules/_contract.js +207 -0
  42. package/rules/_contract.test.ts +81 -0
  43. package/rules/a11y.js +51 -0
  44. package/rules/architecture/hexagonal.js +56 -0
  45. package/rules/architecture/layers.js +75 -0
  46. package/rules/astro.js +56 -0
  47. package/rules/bundler.js +19 -0
  48. package/rules/catalog.js +166 -0
  49. package/rules/catalog.test.ts +98 -0
  50. package/rules/compile.js +125 -0
  51. package/rules/core/eslint.js +234 -0
  52. package/rules/core/import.js +117 -0
  53. package/rules/core/jsdoc.js +52 -0
  54. package/rules/core/node.js +36 -0
  55. package/rules/core/oxc.js +54 -0
  56. package/rules/core/promise.js +39 -0
  57. package/rules/core/typescript.js +223 -0
  58. package/rules/core/unicorn.js +210 -0
  59. package/rules/next.js +53 -0
  60. package/rules/profiles.js +95 -0
  61. package/rules/react-native.js +48 -0
  62. package/rules/react.js +155 -0
  63. package/rules/sorted.js +41 -0
  64. package/rules/vitest.js +178 -0
  65. package/src/docs.d.ts +4 -4
  66. package/src/docs.js +75 -57
  67. package/src/docs.test.ts +43 -31
  68. package/src/index.d.ts +14 -9
  69. package/src/index.js +17 -8
  70. package/src/oxfmt.d.ts +15 -2
  71. package/src/oxfmt.test.ts +10 -0
  72. package/src/oxlint.d.ts +59 -10
  73. package/src/oxlint.js +36 -50
  74. package/src/oxlint.test.ts +82 -28
  75. package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
  76. package/presets/oxlint/architectures/hexagonal.js +0 -13
  77. package/presets/oxlint/base.js +0 -145
  78. package/presets/oxlint/expo.js +0 -36
  79. package/presets/oxlint/next.js +0 -43
  80. package/presets/oxlint/node.js +0 -14
  81. package/presets/oxlint/plugins/codestyle.js +0 -231
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * A repository's prose, held to what a page owes its reader: its coordinates
5
+ * resolve, and its blocks breathe.
6
+ *
7
+ * Two families. **Coordinates** — every relative link and every backticked
8
+ * repo-relative path names something on disk, because a page that cites a file
9
+ * it cannot reach has already drifted from the tree it describes. **Floors** —
10
+ * the mechanical half of "How a page reads": a paragraph that runs past twelve
11
+ * lines, a fenced block that runs fifteen without breathing, a `##` section of
12
+ * thirty lines of prose broken by neither a `###` nor a list. The three numbers
13
+ * are a floor, not the craft the doctrine asks for; the gap between them is a
14
+ * reader's pass, not a red gate.
15
+ *
16
+ * Usage: node check-markdown.js [root] [--ignore-pattern <glob>]…
17
+ *
18
+ * One line per violation, `<rule> <path> <message>`. Exit code: 0 when the
19
+ * prose holds, 1 otherwise. No `--fix`: there is no rewrite that can split a
20
+ * paragraph into the two ideas it was carrying.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { dirname, join, resolve } from 'node:path';
25
+ import { argv, exit, stdout } from 'node:process';
26
+
27
+ import { ignorePatternsOf, readText, trackedFiles } from './tracked-files.js';
28
+
29
+ /** A paragraph, or one list item with its continuations, past this is a wall. */
30
+ const PROSE_BLOCK_LIMIT = 12;
31
+
32
+ /** A fenced block is measured by its longest run with no blank line in it. */
33
+ const FENCE_RUN_LIMIT = 15;
34
+
35
+ /** A `##` section carrying neither a `###` nor a list may run this long. */
36
+ const SECTION_PROSE_LIMIT = 30;
37
+
38
+ /** A generated projection is a machine's output, and its shape is its compiler's. */
39
+ const GENERATED = 'docs/reference/';
40
+
41
+ const LINK = /!?\[[^\]]*\]\((?<target>[^)\s]+)\)/gu;
42
+
43
+ /**
44
+ * A backticked string that reads as a path into this repository. The roster of
45
+ * opening segments is closed on purpose: without it every `a/b` in a sentence
46
+ * — a URL fragment, a ratio, a unit — would be read as a coordinate.
47
+ *
48
+ * `docs/` and `src/` are deliberately absent, and for one reason: every
49
+ * repository has both, so a page teaching a convention writes `src/index.ts`
50
+ * or `docs/03-testing.md` about the READER's tree, not about its own. A
51
+ * relative link into either is still judged — that one names a real target.
52
+ */
53
+ const BACKTICK_PATH = /`(?<path>(?:apps|bin|lib|packages|presets|specs|tests)\/[a-zA-Z\d._/-]+)`/gu;
54
+
55
+ /** A fenced example illustrates; it does not cite. */
56
+ const withoutFences = (markdown) => markdown.replaceAll(/^```.*?^```/gmsu, '');
57
+
58
+ /** A harness header at the very top of a file: metadata, and never a paragraph. */
59
+ const FRONTMATTER = /^---\n.*?\n---\n/su;
60
+ const FENCE = /^\s*(?:```|~~~)/u;
61
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/u;
62
+ const TABLE_ROW = /^\s*\|/u;
63
+ const HEADING = /^(?<hashes>#{1,6})\s/u;
64
+
65
+ /** Blanked line for line, so a reported line number still points at the page's. */
66
+ function lines(markdown) {
67
+ return markdown.replace(FRONTMATTER, (block) => block.replaceAll(/[^\n]/gu, '')).split('\n');
68
+ }
69
+
70
+ /**
71
+ * A block is the reader's unit, not the parser's: a paragraph, or a single list
72
+ * item with its continuations. A blank line, a heading, a table row, a fence or
73
+ * the next list marker all stop one.
74
+ */
75
+ function longProseBlocks(markdown) {
76
+ let inFence = false;
77
+ let length = 0;
78
+ let start = 0;
79
+ const found = [];
80
+ const close = () => {
81
+ if (length > PROSE_BLOCK_LIMIT) {
82
+ found.push(
83
+ `a block of ${length} lines at line ${start} — the floor is ${PROSE_BLOCK_LIMIT}`,
84
+ );
85
+ }
86
+ length = 0;
87
+ };
88
+
89
+ for (const [index, line] of lines(markdown).entries()) {
90
+ if (FENCE.test(line)) {
91
+ close();
92
+ inFence = !inFence;
93
+ continue;
94
+ }
95
+ if (inFence) {
96
+ continue;
97
+ }
98
+ if (line.trim() === '' || HEADING.test(line) || TABLE_ROW.test(line)) {
99
+ close();
100
+ continue;
101
+ }
102
+ if (LIST_ITEM.test(line)) {
103
+ close();
104
+ }
105
+ if (length === 0) {
106
+ start = index + 1;
107
+ }
108
+ length += 1;
109
+ }
110
+ close();
111
+
112
+ return found;
113
+ }
114
+
115
+ /** A fenced block is measured by its longest unbroken run: grouping is blank lines. */
116
+ function airlessFences(markdown) {
117
+ let inFence = false;
118
+ let fenceStart = 0;
119
+ let run = 0;
120
+ const found = [];
121
+ const close = () => {
122
+ if (run > FENCE_RUN_LIMIT) {
123
+ found.push(
124
+ `a fenced block at line ${fenceStart} runs ${run} lines unbroken — the floor is ${FENCE_RUN_LIMIT}`,
125
+ );
126
+ }
127
+ run = 0;
128
+ };
129
+
130
+ for (const [index, line] of lines(markdown).entries()) {
131
+ if (FENCE.test(line)) {
132
+ if (inFence) {
133
+ close();
134
+ } else {
135
+ fenceStart = index + 1;
136
+ run = 0;
137
+ }
138
+ inFence = !inFence;
139
+ continue;
140
+ }
141
+ if (!inFence) {
142
+ continue;
143
+ }
144
+ if (line.trim() === '') {
145
+ close();
146
+ continue;
147
+ }
148
+ run += 1;
149
+ }
150
+ close();
151
+
152
+ return found;
153
+ }
154
+
155
+ /** A `##` section of pure prose, carrying neither a `###` nor a list to break it up. */
156
+ function flatSections(markdown) {
157
+ let inFence = false;
158
+ let title = '';
159
+ let start = 0;
160
+ let prose = 0;
161
+ let broken = false;
162
+ const found = [];
163
+ const close = () => {
164
+ if (title !== '' && !broken && prose > SECTION_PROSE_LIMIT) {
165
+ found.push(
166
+ `"${title}" at line ${start} runs ${prose} lines of prose with no ### and no list`,
167
+ );
168
+ }
169
+ };
170
+
171
+ for (const [index, line] of lines(markdown).entries()) {
172
+ if (FENCE.test(line)) {
173
+ inFence = !inFence;
174
+ continue;
175
+ }
176
+ if (inFence) {
177
+ continue;
178
+ }
179
+
180
+ const heading = HEADING.exec(line);
181
+ if (heading !== null) {
182
+ const level = (heading.groups?.hashes ?? '').length;
183
+ if (level <= 2) {
184
+ close();
185
+ /* An h1 titles the page; it is not one of its questions. */
186
+ title = level === 2 ? line.trim() : '';
187
+ start = index + 1;
188
+ prose = 0;
189
+ broken = false;
190
+ } else {
191
+ broken = true;
192
+ }
193
+ continue;
194
+ }
195
+ if (LIST_ITEM.test(line)) {
196
+ broken = true;
197
+ continue;
198
+ }
199
+ if (line.trim() !== '' && !TABLE_ROW.test(line)) {
200
+ prose += 1;
201
+ }
202
+ }
203
+ close();
204
+
205
+ return found;
206
+ }
207
+
208
+ /** A link a page owns: not a URL, not an anchor, not a mail address. */
209
+ function isRelative(target) {
210
+ return (
211
+ target !== '' &&
212
+ !target.startsWith('http') &&
213
+ !target.startsWith('#') &&
214
+ !target.startsWith('mailto:') &&
215
+ !target.startsWith('<')
216
+ );
217
+ }
218
+
219
+ /** Every violation of one page, in the order the rules are declared. */
220
+ function auditPage(root, path, markdown) {
221
+ const found = [];
222
+ const cited = withoutFences(markdown);
223
+
224
+ for (const match of cited.matchAll(LINK)) {
225
+ const { target } = match.groups;
226
+ const clean = target.split('#')[0] ?? '';
227
+ if (!isRelative(target) || clean === '') {
228
+ continue;
229
+ }
230
+ if (!existsSync(resolve(root, dirname(path), clean))) {
231
+ found.push({
232
+ message: `${target} resolves to nothing on disk`,
233
+ rule: 'markdown-link-missing',
234
+ });
235
+ }
236
+ }
237
+
238
+ for (const match of cited.matchAll(BACKTICK_PATH)) {
239
+ const target = match.groups.path.replace(/\/$/u, '');
240
+ if (!existsSync(join(root, target)) && !existsSync(resolve(root, dirname(path), target))) {
241
+ found.push({
242
+ message: `\`${target}\` names nothing on disk`,
243
+ rule: 'markdown-path-missing',
244
+ });
245
+ }
246
+ }
247
+
248
+ for (const reason of longProseBlocks(markdown)) {
249
+ found.push({ message: reason, rule: 'markdown-block-long' });
250
+ }
251
+ for (const reason of airlessFences(markdown)) {
252
+ found.push({ message: reason, rule: 'markdown-fence-long' });
253
+ }
254
+ for (const reason of flatSections(markdown)) {
255
+ found.push({ message: reason, rule: 'markdown-section-flat' });
256
+ }
257
+
258
+ return found;
259
+ }
260
+
261
+ const root = resolve(argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.');
262
+ const pages = trackedFiles(root, { ignorePatterns: ignorePatternsOf(argv) }).filter(
263
+ (path) =>
264
+ path.endsWith('.md') && !path.startsWith(GENERATED) && !path.includes(`/${GENERATED}`),
265
+ );
266
+
267
+ let failed = false;
268
+ for (const path of pages) {
269
+ const markdown = readText(root, path);
270
+ if (markdown === null) {
271
+ continue;
272
+ }
273
+ for (const violation of auditPage(root, path, markdown)) {
274
+ stdout.write(`${violation.rule} ${path} ${violation.message}\n`);
275
+ failed = true;
276
+ }
277
+ }
278
+
279
+ exit(failed ? 1 : 0);
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * What a project's own tree calls its parts.
5
+ *
6
+ * Two rosters, both closed, both here and nowhere else. A **grab-bag** name
7
+ * says nothing about what it holds, so it holds whatever nobody placed:
8
+ * `utils.ts` is what a folder called `utils/` grows out of, and the folder is
9
+ * what a missing subject grows out of. A **shortcut** says it in half — each
10
+ * one the short form of a word the tree spells out somewhere else, so two
11
+ * spellings of one idea end up sitting side by side.
12
+ *
13
+ * Usage: node check-names.js [root] [--ignore-pattern <glob>]…
14
+ *
15
+ * One line per violation, `<rule> <path> <message>`. Exit code: 0 when every
16
+ * name claims a subject, 1 otherwise. No `--fix`: renaming a file is a move,
17
+ * and choosing the name it moves to is the work the rule is asking for.
18
+ */
19
+
20
+ import { resolve } from 'node:path';
21
+ import { argv, exit, stdout } from 'node:process';
22
+
23
+ import { ignorePatternsOf, trackedFiles } from './tracked-files.js';
24
+
25
+ /** Names that say nothing: a folder or a file called one of these holds whatever nobody placed. */
26
+ const GRAB_BAG = new Set([
27
+ 'base',
28
+ 'common',
29
+ 'core',
30
+ 'helpers',
31
+ 'lib',
32
+ 'misc',
33
+ 'shared',
34
+ 'stuff',
35
+ 'tools',
36
+ 'utils',
37
+ ]);
38
+
39
+ /**
40
+ * Names that say it in half — each the short form of a word the tree writes out
41
+ * somewhere. The `_` marker does not excuse one: it states a position, not
42
+ * whether the name is whole. A name is read segment by segment, split on the
43
+ * hyphen, so `explore-repo` is caught.
44
+ */
45
+ const SHORTCUTS = new Set([
46
+ 'auth',
47
+ 'cfg',
48
+ 'impl',
49
+ 'infra',
50
+ 'k8s',
51
+ 'pkg',
52
+ 'repo',
53
+ 'repos',
54
+ 'svc',
55
+ 'tmp',
56
+ ]);
57
+
58
+ /**
59
+ * The roots the rule sweeps — where a project keeps what it wrote. Each root's
60
+ * OWN name is the toolchain's vocabulary, not the project's choice, so it is
61
+ * never judged: `lib/` is on both this list and the grab-bag roster, and only
62
+ * what a project put INSIDE it is the project's to name.
63
+ */
64
+ const SWEPT = new Set(['apps', 'bin', 'lib', 'packages', 'specs', 'src', 'tests']);
65
+
66
+ /** A `_` opens a row — a fixture, a golden, a template — and a row is not a subject. */
67
+ const isOfTheRow = (name) => name.startsWith('_');
68
+
69
+ /** A directory claims its own segment. */
70
+ const isGrabBag = (name) => !isOfTheRow(name) && GRAB_BAG.has(name.toLowerCase());
71
+
72
+ /** A name carries a truncation when any hyphen-separated segment of it is one. */
73
+ const isShortcut = (name) =>
74
+ name
75
+ .toLowerCase()
76
+ .split('-')
77
+ .some((segment) => SHORTCUTS.has(segment));
78
+
79
+ /** A file claims what stands before its first dot: `utils.ts` and `utils.test.ts` both claim `utils`. */
80
+ const stemOf = (name) => name.split('.')[0] ?? '';
81
+
82
+ /**
83
+ * Every name a set of paths puts on the tree, each mapped to the path that
84
+ * carries it: every directory below a swept root, and every file's stem.
85
+ */
86
+ function namesOf(files) {
87
+ const named = new Map();
88
+
89
+ for (const file of files) {
90
+ const segments = file.split('/');
91
+ if (!SWEPT.has(segments[0]) || segments.length < 2) {
92
+ continue;
93
+ }
94
+
95
+ /* From the second segment on: the root's own name is not the project's. */
96
+ for (let index = 1; index < segments.length - 1; index += 1) {
97
+ const path = segments.slice(0, index + 1).join('/');
98
+ named.set(path, segments[index]);
99
+ }
100
+ named.set(file, stemOf(segments.at(-1)));
101
+ }
102
+
103
+ return [...named].toSorted(([left], [right]) => (left < right ? -1 : 1));
104
+ }
105
+
106
+ const root = resolve(argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.');
107
+ const named = namesOf(trackedFiles(root, { ignorePatterns: ignorePatternsOf(argv) }));
108
+
109
+ let failed = false;
110
+ for (const [path, name] of named) {
111
+ if (isGrabBag(name)) {
112
+ stdout.write(
113
+ `names-grab-bag ${path} \`${name}\` names no subject — it holds whatever nobody placed\n`,
114
+ );
115
+ failed = true;
116
+ }
117
+ if (isShortcut(name)) {
118
+ stdout.write(
119
+ `names-shortcut ${path} \`${name}\` is a word written in half — spell it whole\n`,
120
+ );
121
+ failed = true;
122
+ }
123
+ }
124
+
125
+ exit(failed ? 1 : 0);
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * What a published package promises, held to what the tarball will contain.
5
+ *
6
+ * Every mistake this gate refuses is invisible in the repository and fatal in
7
+ * the registry: an `exports` entry pointing at a file nobody built, a subpath
8
+ * left out of `files` so it resolves in development and 404s in a consumer, a
9
+ * type declaration a modern resolver cannot see. The two tools that know those
10
+ * failure modes best run here — `publint` on the packed tarball, and
11
+ * `@arethetypeswrong/cli` on the declarations — beside the two joins only the
12
+ * source tree can answer.
13
+ *
14
+ * Usage: node check-publish.js [root] [--publint <path>] [--attw <path>]
15
+ *
16
+ * One line per violation of this gate's own rules, `<rule> <path>
17
+ * <message>`; each tool's own report is printed verbatim under its name when it
18
+ * refuses. Exit code: 0 when the package is publishable, 1 otherwise. No
19
+ * `--fix`: an exports map is a contract, not a formatting choice.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
+ import { dirname, join, matchesGlob, resolve } from 'node:path';
25
+ import { argv, exit, stdout } from 'node:process';
26
+
27
+ /** Npm ships these whatever `files` says, so neither rule asks about them. */
28
+ const ALWAYS_PACKED = /^(?:package\.json|readme|license|licence|changelog)/iu;
29
+
30
+ /** Every string an exports map points at, wherever the conditions nest. */
31
+ function targetsOf(exports) {
32
+ if (typeof exports === 'string') {
33
+ return [exports];
34
+ }
35
+ if (Array.isArray(exports)) {
36
+ return exports.flatMap((entry) => targetsOf(entry));
37
+ }
38
+ if (exports !== null && typeof exports === 'object') {
39
+ return Object.values(exports).flatMap((entry) => targetsOf(entry));
40
+ }
41
+
42
+ return [];
43
+ }
44
+
45
+ /** A target with no `./` in front, which is how `files` spells the same path. */
46
+ const plain = (target) => target.replace(/^\.\//u, '');
47
+
48
+ /**
49
+ * Whether a target resolves on disk. A wildcard names a family rather than a
50
+ * file, so what is asked of it is that the directory it reads from exists and
51
+ * holds something the pattern matches.
52
+ */
53
+ function resolvesOnDisk(root, target) {
54
+ const path = plain(target);
55
+ if (!path.includes('*')) {
56
+ return existsSync(join(root, path));
57
+ }
58
+
59
+ const prefix = path.slice(0, path.indexOf('*'));
60
+ const directory = prefix.endsWith('/') ? prefix.slice(0, -1) : dirname(prefix);
61
+ try {
62
+ return readdirSync(join(root, directory)).some((name) =>
63
+ matchesGlob(join(directory, name), path),
64
+ );
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** Whether `files` reaches a target: by its own entry, or by a directory above it. */
71
+ function isPacked(files, target) {
72
+ const path = plain(target);
73
+ if (ALWAYS_PACKED.test(path)) {
74
+ return true;
75
+ }
76
+
77
+ return files.some((entry) => {
78
+ const packed = plain(entry).replace(/\/$/u, '');
79
+
80
+ return (
81
+ path === packed ||
82
+ path.startsWith(`${packed}/`) ||
83
+ matchesGlob(path, packed) ||
84
+ matchesGlob(path, `${packed}/**`)
85
+ );
86
+ });
87
+ }
88
+
89
+ /** One tool's verdict, with its report kept for the failing case. */
90
+ function runTool(name, binary, args, root) {
91
+ const run = spawnSync(binary, args, { cwd: root, encoding: 'utf8' });
92
+ if (run.error) {
93
+ return { output: `${name} could not be run: ${run.error.message}\n`, status: 1 };
94
+ }
95
+
96
+ return { output: `${run.stdout ?? ''}${run.stderr ?? ''}`, status: run.status ?? 1 };
97
+ }
98
+
99
+ const flagged = (name, fallback) => {
100
+ const at = argv.indexOf(name);
101
+
102
+ return at === -1 || argv[at + 1] === undefined ? fallback : argv[at + 1];
103
+ };
104
+
105
+ const root = resolve(
106
+ argv.slice(2).find((argument, index) => {
107
+ const previous = argv[index + 1];
108
+
109
+ return !argument.startsWith('--') && previous !== '--publint' && previous !== '--attw';
110
+ }) ?? '.',
111
+ );
112
+
113
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
114
+ const files = manifest.files ?? [];
115
+ const targets = [
116
+ ...targetsOf(manifest.exports ?? {}),
117
+ ...(manifest.main === undefined ? [] : [manifest.main]),
118
+ ...(manifest.types === undefined ? [] : [manifest.types]),
119
+ ];
120
+
121
+ let failed = false;
122
+
123
+ for (const target of [...new Set(targets)].toSorted((left, right) => (left < right ? -1 : 1))) {
124
+ if (!resolvesOnDisk(root, target)) {
125
+ stdout.write(
126
+ `publish-exports-target package.json ${target} resolves to nothing on disk\n`,
127
+ );
128
+ failed = true;
129
+ continue;
130
+ }
131
+ if (files.length > 0 && !isPacked(files, target)) {
132
+ stdout.write(
133
+ `publish-files package.json ${target} is outside "files" — it resolves here and 404s in a consumer\n`,
134
+ );
135
+ failed = true;
136
+ }
137
+ }
138
+
139
+ for (const [name, binary, args] of [
140
+ ['publint', flagged('--publint', 'publint'), ['--strict']],
141
+ ['attw', flagged('--attw', 'attw'), ['--pack', '.', '--profile', 'esm-only']],
142
+ ]) {
143
+ const { output, status } = runTool(name, binary, args, root);
144
+ if (status !== 0) {
145
+ stdout.write(output);
146
+ failed = true;
147
+ }
148
+ }
149
+
150
+ exit(failed ? 1 : 0);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * No file the project would commit carries a live-looking credential.
5
+ *
6
+ * Two engines, one gate. Where `gitleaks` is on PATH it is the better scanner
7
+ * and this gate is its runner — `gitleaks dir . --redact`, so a finding names
8
+ * the file and never reprints the secret. Where it is not, ten patterns over
9
+ * the tracked text answer the same question well enough to stop the leak that
10
+ * actually happens: a token pasted into a note and committed with it.
11
+ *
12
+ * Which engine runs is decided HERE and not in `check.sh`, because the gate
13
+ * applies either way — bash decides whether a gate applies, and this one always
14
+ * does. What changes is only who answers.
15
+ *
16
+ * A hit is forgiven by one thing: the same line declaring itself fake. There is
17
+ * no exception list, and there will not be one — an allow-list of paths is the
18
+ * first place a real leak hides.
19
+ *
20
+ * Usage: node check-secrets.js [root] [--ignore-pattern <glob>]…
21
+ *
22
+ * One line per violation, `<rule> <path> <message>`. Exit code: 0 when
23
+ * nothing looks live, 1 otherwise. No `--fix`: a leaked credential is rotated,
24
+ * not reformatted.
25
+ */
26
+
27
+ import { spawnSync } from 'node:child_process';
28
+ import { resolve } from 'node:path';
29
+ import { argv, exit, stdout } from 'node:process';
30
+
31
+ import { ignorePatternsOf, readText, trackedFiles } from './tracked-files.js';
32
+
33
+ /** The ten shapes, each with the rule id a finding is reported under. */
34
+ const PATTERNS = [
35
+ {
36
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/u,
37
+ rule: 'secrets-private-key',
38
+ what: 'a private key block',
39
+ },
40
+ { pattern: /\bAKIA[\dA-Z]{16}\b/u, rule: 'secrets-aws-key', what: 'an aws access key' },
41
+ {
42
+ pattern: /\bgh[oprsu]_[\dA-Za-z]{30,}\b/u,
43
+ rule: 'secrets-github-token',
44
+ what: 'a github token',
45
+ },
46
+ {
47
+ pattern: /\bxox[abprs]-[\dA-Za-z-]{10,}\b/u,
48
+ rule: 'secrets-slack-token',
49
+ what: 'a slack token',
50
+ },
51
+ {
52
+ pattern: /\btskey-[a-z]+-[\dA-Za-z]{6,}\b/u,
53
+ rule: 'secrets-tailscale-key',
54
+ what: 'a tailscale key',
55
+ },
56
+ { pattern: /\beyJ[\w-]{10,}\.eyJ[\w-]{10,}\./u, rule: 'secrets-jwt', what: 'a signed jwt' },
57
+ {
58
+ pattern: /\bglsa_[\dA-Za-z_]{20,}\b/u,
59
+ rule: 'secrets-grafana-token',
60
+ what: 'a grafana token',
61
+ },
62
+ { pattern: /\bsk-[\dA-Za-z]{20,}\b/u, rule: 'secrets-api-key', what: 'an api key' },
63
+ { pattern: /\bFR\d{2} ?(?:\d{4} ?){5}/u, rule: 'secrets-iban', what: 'an iban' },
64
+ { pattern: /\bst\.[\da-f]{24,}\b/u, rule: 'secrets-service-token', what: 'a service token' },
65
+ ];
66
+
67
+ /** The one predicate that lets a hit through: the line must declare itself fake. */
68
+ const isSynthetic = (line) => /dummy|example|fake|redacted|sample|synthetic/iu.test(line);
69
+
70
+ /** Gitleaks, when the machine has it — the better scanner, run redacted. */
71
+ function runGitleaks(root) {
72
+ const probe = spawnSync('gitleaks', ['version'], { stdio: 'ignore' });
73
+ if (probe.error) {
74
+ return null;
75
+ }
76
+
77
+ const run = spawnSync('gitleaks', ['dir', '.', '--no-banner', '--redact', '--exit-code', '1'], {
78
+ cwd: root,
79
+ encoding: 'utf8',
80
+ });
81
+
82
+ return { output: `${run.stdout ?? ''}${run.stderr ?? ''}`, status: run.status ?? 1 };
83
+ }
84
+
85
+ const root = resolve(argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.');
86
+
87
+ const gitleaks = runGitleaks(root);
88
+ if (gitleaks !== null) {
89
+ if (gitleaks.status !== 0) {
90
+ stdout.write(gitleaks.output);
91
+ }
92
+ exit(gitleaks.status === 0 ? 0 : 1);
93
+ }
94
+
95
+ let failed = false;
96
+ for (const path of trackedFiles(root, { ignorePatterns: ignorePatternsOf(argv) })) {
97
+ const text = readText(root, path);
98
+ if (text === null) {
99
+ continue;
100
+ }
101
+
102
+ for (const [index, line] of text.split('\n').entries()) {
103
+ if (isSynthetic(line)) {
104
+ continue;
105
+ }
106
+ for (const { pattern, rule, what } of PATTERNS) {
107
+ if (pattern.test(line)) {
108
+ stdout.write(`${rule} ${path} line ${index + 1} looks like ${what}\n`);
109
+ failed = true;
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ exit(failed ? 1 : 0);