@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.
- package/README.md +20 -16
- package/bin/commands/check.sh +387 -146
- package/bin/find-tsc.sh +30 -0
- package/bin/typescript.sh +79 -1
- package/lib/check-architecture.js +89 -0
- package/lib/check-baseline.js +144 -0
- package/lib/check-docs.js +4 -3
- package/lib/check-drift.js +209 -0
- package/lib/check-gitignore.js +4 -4
- package/lib/check-markdown.js +279 -0
- package/lib/check-names.js +125 -0
- package/lib/check-publish.js +150 -0
- package/lib/check-secrets.js +115 -0
- package/lib/check-suppressions.js +355 -0
- package/lib/doctor.js +185 -0
- package/lib/entry-points.js +91 -0
- package/lib/merge-knip-config.js +57 -25
- package/lib/tracked-files.js +165 -0
- package/lib/unsafe-fixers.js +25 -0
- package/lib/workspace-members.js +5 -6
- package/package.json +36 -13
- package/presets/oxfmt/index.js +49 -5
- package/presets/oxlint/profiles/astro.js +10 -0
- package/presets/oxlint/profiles/bun.js +7 -0
- package/presets/oxlint/profiles/expo.js +7 -0
- package/presets/oxlint/profiles/library.js +16 -0
- package/presets/oxlint/profiles/next.js +7 -0
- package/presets/oxlint/profiles/node.js +7 -0
- package/presets/oxlint/profiles/react.js +7 -0
- package/presets/prettier/astro.json +6 -0
- package/presets/tsconfig/astro.json +25 -0
- package/presets/tsconfig/expo.json +16 -6
- package/presets/tsconfig/library.json +17 -0
- package/presets/tsconfig/next.json +12 -2
- package/presets/tsconfig/node.json +18 -4
- package/presets/tsconfig/react.json +33 -0
- package/presets/tsdown/build.d.ts +13 -0
- package/presets/tsdown/bundle.d.ts +13 -0
- package/presets/tsdown/bundle.js +10 -1
- package/rules/README.md +23 -0
- package/rules/_contract.js +207 -0
- package/rules/_contract.test.ts +81 -0
- package/rules/a11y.js +51 -0
- package/rules/architecture/hexagonal.js +56 -0
- package/rules/architecture/layers.js +75 -0
- package/rules/astro.js +56 -0
- package/rules/bundler.js +19 -0
- package/rules/catalog.js +166 -0
- package/rules/catalog.test.ts +98 -0
- package/rules/compile.js +125 -0
- package/rules/core/eslint.js +234 -0
- package/rules/core/import.js +117 -0
- package/rules/core/jsdoc.js +52 -0
- package/rules/core/node.js +36 -0
- package/rules/core/oxc.js +54 -0
- package/rules/core/promise.js +39 -0
- package/rules/core/typescript.js +223 -0
- package/rules/core/unicorn.js +210 -0
- package/rules/next.js +53 -0
- package/rules/profiles.js +95 -0
- package/rules/react-native.js +48 -0
- package/rules/react.js +155 -0
- package/rules/sorted.js +41 -0
- package/rules/vitest.js +178 -0
- package/src/docs.d.ts +4 -4
- package/src/docs.js +75 -57
- package/src/docs.test.ts +43 -31
- package/src/index.d.ts +14 -9
- package/src/index.js +17 -8
- package/src/oxfmt.d.ts +15 -2
- package/src/oxfmt.test.ts +10 -0
- package/src/oxlint.d.ts +59 -10
- package/src/oxlint.js +36 -50
- package/src/oxlint.test.ts +82 -28
- package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
- package/presets/oxlint/architectures/hexagonal.js +0 -13
- package/presets/oxlint/base.js +0 -145
- package/presets/oxlint/expo.js +0 -36
- package/presets/oxlint/next.js +0 -43
- package/presets/oxlint/node.js +0 -14
- 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);
|