@jterrazz/typescript 8.1.3 → 9.0.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.
- package/README.md +1 -0
- package/bin/commands/check.sh +47 -1
- package/bin/typescript.sh +19 -1
- package/lib/check-gitignore.js +453 -0
- package/package.json +1 -1
- package/presets/tsconfig/expo.json +3 -1
- package/presets/tsconfig/next.json +1 -0
- package/presets/tsconfig/node.json +2 -0
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ npx typescript dev # Build, run, and rebuild on changes
|
|
|
18
18
|
npx typescript docs # Compile the committed docs/reference tree from source
|
|
19
19
|
npx typescript check # Type-check, lint, format-check, and unused-code in parallel
|
|
20
20
|
npx typescript fix # Auto-fix lint and formatting issues
|
|
21
|
+
npx typescript clean # Remove .artifacts/ (dist/ stays — it is the product)
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
## How it works
|
package/bin/commands/check.sh
CHANGED
|
@@ -269,6 +269,11 @@ run_checks() {
|
|
|
269
269
|
# Merge base config (from this package) with optional project-local knip.json.
|
|
270
270
|
# Root-only on purpose: knip reads the workspace globs itself and reports per
|
|
271
271
|
# member from one run — a second invocation per member would double-report.
|
|
272
|
+
#
|
|
273
|
+
# Deliberately UNCACHED. Knip's `--cache` validates a cached glob against the
|
|
274
|
+
# mtimes of the directories that held a match; a file added to a directory
|
|
275
|
+
# that held none is invisible to it, and the run exits 0 where the uncached
|
|
276
|
+
# run exits 1. A gate that can pass on stale knowledge is worse than a slow one.
|
|
272
277
|
local knip_pid=""
|
|
273
278
|
local knip_status=0
|
|
274
279
|
if [ "$FIX_MODE" = false ]; then
|
|
@@ -282,6 +287,33 @@ run_checks() {
|
|
|
282
287
|
knip_pid=$!
|
|
283
288
|
fi
|
|
284
289
|
|
|
290
|
+
# Gitignore (artefacts): the convention — every artefact under
|
|
291
|
+
# `.artifacts/<tool>/`, `dist` excepted — read off the project's own
|
|
292
|
+
# `.gitignore` AND, in check mode, the nearest ancestor `.gitignore` above it
|
|
293
|
+
# (the workspace root's — check-gitignore.js walks up to find it). Opt-in by
|
|
294
|
+
# existence: neither file names an artefact path, the gate has no question to
|
|
295
|
+
# ask. A workspace whose `lint` delegates to members needs the ancestor probed
|
|
296
|
+
# here, in bash, because it decides whether to print the pass at all — the
|
|
297
|
+
# node script decides everything past that. Fix mode stays own-file-only: it
|
|
298
|
+
# is the one gate whose remedy is a rewrite, and it never rewrites another
|
|
299
|
+
# project's `.gitignore`.
|
|
300
|
+
local gitignore_pid=""
|
|
301
|
+
local gitignore_status=0
|
|
302
|
+
local gitignore_applicable=false
|
|
303
|
+
if [ -f ".gitignore" ]; then
|
|
304
|
+
gitignore_applicable=true
|
|
305
|
+
elif [ "$FIX_MODE" = false ] && node "$PACKAGE_ROOT/lib/check-gitignore.js" --has-ancestor > /dev/null 2>&1; then
|
|
306
|
+
gitignore_applicable=true
|
|
307
|
+
fi
|
|
308
|
+
if [ "$gitignore_applicable" = true ]; then
|
|
309
|
+
if [ "$FIX_MODE" = true ]; then
|
|
310
|
+
node "$PACKAGE_ROOT/lib/check-gitignore.js" --fix > "$tmp_dir/gitignore.log" 2>&1 &
|
|
311
|
+
else
|
|
312
|
+
node "$PACKAGE_ROOT/lib/check-gitignore.js" > "$tmp_dir/gitignore.log" 2>&1 &
|
|
313
|
+
fi
|
|
314
|
+
gitignore_pid=$!
|
|
315
|
+
fi
|
|
316
|
+
|
|
285
317
|
# Conventions checker: only in check mode, once per specs root the workspace
|
|
286
318
|
# owns, gated by the package that OWNS that root — a member may depend on
|
|
287
319
|
# @jterrazz/test while the root does not, and the reverse.
|
|
@@ -326,6 +358,7 @@ run_checks() {
|
|
|
326
358
|
wait $lint_pid; local lint_status=$?
|
|
327
359
|
wait $format_pid; local format_status=$?
|
|
328
360
|
[ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
|
|
361
|
+
[ -n "$gitignore_pid" ] && { wait $gitignore_pid; gitignore_status=$?; }
|
|
329
362
|
|
|
330
363
|
# One pass, N runs: the pass fails if any run failed, and only the logs of
|
|
331
364
|
# the runs that FAILED are printed — a green member stays silent.
|
|
@@ -380,6 +413,19 @@ run_checks() {
|
|
|
380
413
|
printf "${GREEN}✓ Passed${NC}\n"
|
|
381
414
|
fi
|
|
382
415
|
|
|
416
|
+
# The one pass that speaks on success: a rewrite changed a file the operator
|
|
417
|
+
# owns, and silence would hide it. In check mode a green gate writes nothing,
|
|
418
|
+
# so the green output stays byte-identical with the others.
|
|
419
|
+
if [ -n "$gitignore_pid" ]; then
|
|
420
|
+
printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Gitignore (artefacts)\n\n"
|
|
421
|
+
[ -s "$tmp_dir/gitignore.log" ] && cat "$tmp_dir/gitignore.log"
|
|
422
|
+
if [ $gitignore_status -ne 0 ]; then
|
|
423
|
+
printf "${RED}✗ Failed with exit code %d${NC}\n" $gitignore_status
|
|
424
|
+
else
|
|
425
|
+
printf "${GREEN}✓ Passed${NC}\n"
|
|
426
|
+
fi
|
|
427
|
+
fi
|
|
428
|
+
|
|
383
429
|
if [ "$FIX_MODE" = false ]; then
|
|
384
430
|
printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Knip (unused code)\n\n"
|
|
385
431
|
if [ $knip_status -ne 0 ]; then
|
|
@@ -421,7 +467,7 @@ run_checks() {
|
|
|
421
467
|
printf "\n${CYAN_BG}${BRIGHT_WHITE} END ${NC} Finalizing quality checks\n\n"
|
|
422
468
|
fi
|
|
423
469
|
|
|
424
|
-
if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $checker_status -eq 0 ] && [ $docs_status -eq 0 ]; then
|
|
470
|
+
if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $gitignore_status -eq 0 ] && [ $checker_status -eq 0 ] && [ $docs_status -eq 0 ]; then
|
|
425
471
|
printf "${GREEN}✓ All checks passed${NC}\n"
|
|
426
472
|
exit 0
|
|
427
473
|
else
|
package/bin/typescript.sh
CHANGED
|
@@ -144,6 +144,22 @@ case "$COMMAND" in
|
|
|
144
144
|
fi
|
|
145
145
|
;;
|
|
146
146
|
|
|
147
|
+
clean)
|
|
148
|
+
# Every tool writes under .artifacts/<tool>/, so removing that one
|
|
149
|
+
# directory is the whole cleanup. dist/ is NOT touched: it is the
|
|
150
|
+
# build's product, published from beside src/, not an artefact.
|
|
151
|
+
cd "$PROJECT_ROOT"
|
|
152
|
+
|
|
153
|
+
printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} Removing artefacts...\n\n"
|
|
154
|
+
|
|
155
|
+
if [ -d ".artifacts" ]; then
|
|
156
|
+
rm -rf .artifacts
|
|
157
|
+
printf "${GREEN}Removed .artifacts/${NC}\n"
|
|
158
|
+
else
|
|
159
|
+
printf "${GREEN}Nothing to remove — .artifacts/ is already absent${NC}\n"
|
|
160
|
+
fi
|
|
161
|
+
;;
|
|
162
|
+
|
|
147
163
|
check|fix)
|
|
148
164
|
exec bash "$SCRIPT_DIR/commands/check.sh" "$COMMAND" "$@"
|
|
149
165
|
;;
|
|
@@ -158,7 +174,8 @@ case "$COMMAND" in
|
|
|
158
174
|
printf " dev Build, run, and rebuild on changes\n"
|
|
159
175
|
printf " docs Generate the committed docs/reference tree; --check verifies sync\n"
|
|
160
176
|
printf " check Check types, lint, formatting, and unused code\n"
|
|
161
|
-
printf " fix Auto-fix lint and formatting issues\n
|
|
177
|
+
printf " fix Auto-fix lint and formatting issues\n"
|
|
178
|
+
printf " clean Remove .artifacts/ — dist/ stays, it is the build's product\n\n"
|
|
162
179
|
printf "Examples:\n"
|
|
163
180
|
printf " typescript build\n"
|
|
164
181
|
printf " typescript bundle\n"
|
|
@@ -168,6 +185,7 @@ case "$COMMAND" in
|
|
|
168
185
|
printf " typescript docs --check\n"
|
|
169
186
|
printf " typescript check\n"
|
|
170
187
|
printf " typescript fix\n"
|
|
188
|
+
printf " typescript clean\n"
|
|
171
189
|
exit 1
|
|
172
190
|
;;
|
|
173
191
|
esac
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The artefact convention, read off a project's `.gitignore` — and, in a
|
|
5
|
+
* workspace, off the ancestor `.gitignore` that covers it too.
|
|
6
|
+
*
|
|
7
|
+
* Every build, test and lint artefact lives under `.artifacts/<tool>/` at the
|
|
8
|
+
* project root — one folder per tool that writes. A `.gitignore` line naming an
|
|
9
|
+
* artefact ANYWHERE else is the old layout, and this gate says so; `.artifacts/`
|
|
10
|
+
* itself must be ignored, so the convention's own folder never reaches a commit.
|
|
11
|
+
*
|
|
12
|
+
* Two files are read, each judged by the same rules: the package's own
|
|
13
|
+
* `.gitignore`, and the nearest ANCESTOR `.gitignore` above it — the workspace
|
|
14
|
+
* root's, found by walking up to the nearest directory holding a lockfile or a
|
|
15
|
+
* `workspaces` manifest. A workspace whose `lint` delegates to members runs this
|
|
16
|
+
* gate once per member, cwd'd there; without the ancestor a root that ignores
|
|
17
|
+
* `.artifacts/` for everyone would look, from a member with no `.gitignore` of
|
|
18
|
+
* its own, exactly like a project declaring nothing. An ancestor pattern counts
|
|
19
|
+
* only when it is NOT anchored to the ancestor's own directory (no `/` besides a
|
|
20
|
+
* trailing one) — the same rule git applies when deciding whether a pattern
|
|
21
|
+
* reaches into a nested directory.
|
|
22
|
+
*
|
|
23
|
+
* Usage: node check-gitignore.js [--fix] [--has-ancestor] [root]
|
|
24
|
+
*
|
|
25
|
+
* `--has-ancestor` answers, silently, whether an ancestor `.gitignore` exists
|
|
26
|
+
* above `root` — the probe a caller uses to decide whether the gate has
|
|
27
|
+
* anything to read when `root` itself carries no `.gitignore`.
|
|
28
|
+
*
|
|
29
|
+
* `--fix` rewrites the package's OWN `.gitignore` (never the ancestor's, which
|
|
30
|
+
* is a different project's file): the artefact lines go, `.artifacts/` arrives,
|
|
31
|
+
* and everything else — comments, blank lines, order, the project's own paths —
|
|
32
|
+
* survives untouched. A `!` line is NEVER rewritten: it rescues a tracked file,
|
|
33
|
+
* and deleting the line it negates could hide a real path. Those are reported.
|
|
34
|
+
* Fix has nothing to do when the package owns no `.gitignore` of its own, even
|
|
35
|
+
* if an ancestor exists — it never creates a file, only repairs one.
|
|
36
|
+
*
|
|
37
|
+
* Exit code: 0 when the project holds the convention, 1 otherwise. In `--fix`
|
|
38
|
+
* mode only what the rewrite cannot repair — a committed artefact — still fails.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { execFileSync } from 'node:child_process';
|
|
42
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
43
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
44
|
+
import { argv, exit, stdout } from 'node:process';
|
|
45
|
+
|
|
46
|
+
/** The convention's own directory: the one path that MUST be ignored. */
|
|
47
|
+
const ARTIFACTS = '.artifacts';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The one exception to the convention: a build's product stays beside `src/`
|
|
51
|
+
* and is published from there, so `dist` is a legitimate ignored path.
|
|
52
|
+
*/
|
|
53
|
+
const PRODUCT = 'dist';
|
|
54
|
+
|
|
55
|
+
/** Directories a tool writes, and the `.artifacts/` home each one moves to. */
|
|
56
|
+
const ARTEFACT_DIRECTORIES = new Map([
|
|
57
|
+
['.cache', '.artifacts/<tool>/'],
|
|
58
|
+
['.next', '.artifacts/next/'],
|
|
59
|
+
['.turbo', '.artifacts/turbo/'],
|
|
60
|
+
['.vite', '.artifacts/vite/'],
|
|
61
|
+
['bin', '.artifacts/go/'],
|
|
62
|
+
['build', '.artifacts/<tool>/'],
|
|
63
|
+
['coverage', '.artifacts/coverage/'],
|
|
64
|
+
['out', '.artifacts/next/'],
|
|
65
|
+
['playwright-report', '.artifacts/playwright/'],
|
|
66
|
+
['target', '.artifacts/cargo/'],
|
|
67
|
+
['test-results', '.artifacts/playwright/'],
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Files a tool writes, matched on their extension, and where they belong. */
|
|
71
|
+
const ARTEFACT_EXTENSIONS = [
|
|
72
|
+
['.tsbuildinfo', '.artifacts/tsc/'],
|
|
73
|
+
['.log', '.artifacts/logs/'],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The closed list of ignored paths that are NOT artefacts of the convention:
|
|
78
|
+
* platform working directories a toolchain owns and cannot be told to move,
|
|
79
|
+
* generated files a framework expects at a fixed path, and `node_modules`.
|
|
80
|
+
*/
|
|
81
|
+
const EXCEPTIONS = new Set([
|
|
82
|
+
'.build',
|
|
83
|
+
'.expo',
|
|
84
|
+
'.gradle',
|
|
85
|
+
'.swiftpm',
|
|
86
|
+
'.vercel',
|
|
87
|
+
'DerivedData',
|
|
88
|
+
'Package.resolved',
|
|
89
|
+
'android',
|
|
90
|
+
'ios',
|
|
91
|
+
'next-env.d.ts',
|
|
92
|
+
'node_modules',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/** The one exception spelled as a prefix — Expo stamps a suffix onto it. */
|
|
96
|
+
const EXCEPTION_PREFIXES = ['.metro-health-check'];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `next.config.*` file names, in resolution order — mirrors what Next.js
|
|
100
|
+
* itself tries, closely enough for a textual `output` read.
|
|
101
|
+
*/
|
|
102
|
+
const NEXT_CONFIG_FILES = [
|
|
103
|
+
'next.config.js',
|
|
104
|
+
'next.config.mjs',
|
|
105
|
+
'next.config.ts',
|
|
106
|
+
'next.config.mts',
|
|
107
|
+
'next.config.cjs',
|
|
108
|
+
'next.config.cts',
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* With `output: 'export'`, Next reads `distDir` as the EXPORT destination and
|
|
113
|
+
* keeps its working directory at `.next` regardless — `next/dist/export/utils.js`
|
|
114
|
+
* (`hasCustomExportOutput`) refuses to move it. So `.next` cannot be told to
|
|
115
|
+
* live under `.artifacts/next/` in that one mode, proven by a real consumer
|
|
116
|
+
* (clawssify's site). Without `output: 'export'`, nothing pins it, and `.next`
|
|
117
|
+
* stays an ordinary artefact. Read textually — a project's own config may not
|
|
118
|
+
* even be valid JS in the tool's own runtime, and a regex answers the one
|
|
119
|
+
* question this gate has without loading it.
|
|
120
|
+
*/
|
|
121
|
+
function nextConfigDeclaresExport(dir) {
|
|
122
|
+
for (const name of NEXT_CONFIG_FILES) {
|
|
123
|
+
const path = join(dir, name);
|
|
124
|
+
if (!existsSync(path)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (/output\s*:\s*['"]export['"]/.test(readFileSync(path, 'utf8'))) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// An unreadable config answers no differently than a missing one.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Lockfiles whose presence marks a directory as a package manager's root. */
|
|
140
|
+
const WORKSPACE_LOCKFILES = [
|
|
141
|
+
'bun.lock',
|
|
142
|
+
'bun.lockb',
|
|
143
|
+
'npm-shrinkwrap.json',
|
|
144
|
+
'package-lock.json',
|
|
145
|
+
'pnpm-lock.yaml',
|
|
146
|
+
'yarn.lock',
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
/** A workspace root: a lockfile lives here, or its manifest declares `workspaces`. */
|
|
150
|
+
function isWorkspaceRoot(dir) {
|
|
151
|
+
if (WORKSPACE_LOCKFILES.some((name) => existsSync(join(dir, name)))) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const manifest = join(dir, 'package.json');
|
|
156
|
+
if (!existsSync(manifest)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).workspaces !== undefined;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The nearest ancestor ABOVE `root` that is a workspace root, or null. Never
|
|
169
|
+
* `root` itself — a project reads its OWN `.gitignore` regardless, so only
|
|
170
|
+
* what sits above it is worth a second file.
|
|
171
|
+
*/
|
|
172
|
+
function findWorkspaceRoot(root) {
|
|
173
|
+
let dir = dirname(resolve(root));
|
|
174
|
+
let parent = dirname(dir);
|
|
175
|
+
|
|
176
|
+
while (dir !== parent) {
|
|
177
|
+
if (isWorkspaceRoot(dir)) {
|
|
178
|
+
return dir;
|
|
179
|
+
}
|
|
180
|
+
dir = parent;
|
|
181
|
+
parent = dirname(dir);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return isWorkspaceRoot(dir) ? dir : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A pattern anchored to its OWN `.gitignore`'s directory — one that carries a
|
|
189
|
+
* `/` other than a trailing one, or a leading one — the way git itself reads
|
|
190
|
+
* it. An anchored pattern in an ANCESTOR's file never reaches a nested package;
|
|
191
|
+
* only an unanchored one (`.artifacts/`, not `/.artifacts/` or `out/.artifacts/`)
|
|
192
|
+
* matches at any depth below it.
|
|
193
|
+
*/
|
|
194
|
+
function isAnchored(pattern) {
|
|
195
|
+
return pattern.replace(/\/+$/, '').includes('/');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What a `.gitignore` pattern names, reduced to the one segment that carries
|
|
200
|
+
* the meaning: an anchored form, a nested form and a doubled-star form all name
|
|
201
|
+
* the same directory. A trailing `*` is dropped so `npm-debug.log*` reads as a
|
|
202
|
+
* log, and a `**` segment never wins over the name that follows it.
|
|
203
|
+
*/
|
|
204
|
+
function subject(pattern) {
|
|
205
|
+
const segments = pattern
|
|
206
|
+
.replace(/\/+$/, '')
|
|
207
|
+
.split('/')
|
|
208
|
+
.filter((segment) => segment !== '' && segment !== '**');
|
|
209
|
+
|
|
210
|
+
return (segments.at(-1) ?? '').replace(/\*$/, '');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The `.artifacts/` home of the artefact a pattern names, or null. */
|
|
214
|
+
function artefactHome(pattern, { nextIsExportDestination }) {
|
|
215
|
+
const name = subject(pattern);
|
|
216
|
+
|
|
217
|
+
if (name === '' || name === PRODUCT || EXCEPTIONS.has(name)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (nextIsExportDestination && name === '.next') {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (EXCEPTION_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
if (ARTEFACT_DIRECTORIES.has(name)) {
|
|
227
|
+
return ARTEFACT_DIRECTORIES.get(name);
|
|
228
|
+
}
|
|
229
|
+
for (const [extension, home] of ARTEFACT_EXTENSIONS) {
|
|
230
|
+
if (name.endsWith(extension)) {
|
|
231
|
+
return home;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Every line of a `.gitignore`, classified once — the rewrite reads the same list. */
|
|
239
|
+
function readLines(gitignorePath) {
|
|
240
|
+
const nextIsExportDestination = nextConfigDeclaresExport(dirname(gitignorePath));
|
|
241
|
+
const text = readFileSync(gitignorePath, 'utf8').split('\n');
|
|
242
|
+
|
|
243
|
+
// The empty string a trailing newline leaves behind is not a line.
|
|
244
|
+
// Putting that newline back is the rewrite's job.
|
|
245
|
+
if (text.at(-1) === '') {
|
|
246
|
+
text.pop();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return text.map((line) => {
|
|
250
|
+
const pattern = line.trim();
|
|
251
|
+
const isPattern = pattern !== '' && !pattern.startsWith('#');
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
home:
|
|
255
|
+
isPattern && !pattern.startsWith('!')
|
|
256
|
+
? artefactHome(pattern, { nextIsExportDestination })
|
|
257
|
+
: null,
|
|
258
|
+
isNegation: isPattern && pattern.startsWith('!'),
|
|
259
|
+
pattern,
|
|
260
|
+
text: line,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A `.gitignore` judged on its own: every line classified, plus what it
|
|
267
|
+
* contributes to the combined verdict. `reach` narrows an ancestor's file to
|
|
268
|
+
* the patterns that actually cross into a nested package — every line, for
|
|
269
|
+
* the package's own file, since that file's directory IS the project root.
|
|
270
|
+
*/
|
|
271
|
+
function readSource(path, { own, reach = () => true, root }) {
|
|
272
|
+
const lines = readLines(path);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
isIgnored: lines.some(
|
|
276
|
+
(line) =>
|
|
277
|
+
!line.isNegation &&
|
|
278
|
+
line.home === null &&
|
|
279
|
+
subject(line.pattern) === ARTIFACTS &&
|
|
280
|
+
reach(line.pattern),
|
|
281
|
+
),
|
|
282
|
+
label: relative(resolve(root), path).split('\\').join('/'),
|
|
283
|
+
lines,
|
|
284
|
+
misplaced: lines.filter((line) => line.home !== null && reach(line.pattern)),
|
|
285
|
+
negations: lines.filter((line) => line.isNegation),
|
|
286
|
+
own,
|
|
287
|
+
path,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Directories whose every tracked file is a committed artefact. Narrower than
|
|
293
|
+
* the list above on purpose: `bin`, `out`, `build` and `target` are ignorable
|
|
294
|
+
* as OUTPUT, but a tracked file under them may be a project's own source —
|
|
295
|
+
* a `.gitignore` line declares intent, a tracked path declares nothing.
|
|
296
|
+
*/
|
|
297
|
+
const TRACKED_ARTEFACT_DIRECTORIES = new Set([
|
|
298
|
+
'.artifacts',
|
|
299
|
+
'.cache',
|
|
300
|
+
'.next',
|
|
301
|
+
'.turbo',
|
|
302
|
+
'.vite',
|
|
303
|
+
'coverage',
|
|
304
|
+
'playwright-report',
|
|
305
|
+
'test-results',
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
/** The extension no tracked file may carry — a buildinfo is never source. */
|
|
309
|
+
const TRACKED_ARTEFACT_EXTENSION = '.tsbuildinfo';
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The artefacts a project COMMITTED. Outside a git tree the question has no
|
|
313
|
+
* answer, and silence is the right one — a fixture directory is not a repo.
|
|
314
|
+
*/
|
|
315
|
+
function trackedArtefacts(root) {
|
|
316
|
+
let listed;
|
|
317
|
+
try {
|
|
318
|
+
listed = execFileSync('git', ['ls-files', '-z'], {
|
|
319
|
+
cwd: root,
|
|
320
|
+
encoding: 'utf8',
|
|
321
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
322
|
+
});
|
|
323
|
+
} catch {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return listed
|
|
328
|
+
.split('\0')
|
|
329
|
+
.filter((path) => path !== '')
|
|
330
|
+
.filter((path) => {
|
|
331
|
+
const segments = path.split('/');
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
segments
|
|
335
|
+
.slice(0, -1)
|
|
336
|
+
.some((segment) => TRACKED_ARTEFACT_DIRECTORIES.has(segment)) ||
|
|
337
|
+
path.endsWith(TRACKED_ARTEFACT_EXTENSION)
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The rewritten file: artefact lines dropped, `.artifacts/` present, rest kept. */
|
|
343
|
+
function rewrite(lines) {
|
|
344
|
+
const kept = lines.filter((line) => line.home === null);
|
|
345
|
+
const isIgnored = kept.some((line) => !line.isNegation && subject(line.pattern) === ARTIFACTS);
|
|
346
|
+
const body = kept.map((line) => line.text);
|
|
347
|
+
|
|
348
|
+
if (!isIgnored) {
|
|
349
|
+
while (body.length > 0 && body.at(-1).trim() === '') {
|
|
350
|
+
body.pop();
|
|
351
|
+
}
|
|
352
|
+
body.push('', '# Build, test and lint artefacts (@jterrazz/typescript)', `${ARTIFACTS}/`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return `${body.join('\n')}\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isFix = argv.includes('--fix');
|
|
359
|
+
const isProbe = argv.includes('--has-ancestor');
|
|
360
|
+
const root = argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.';
|
|
361
|
+
|
|
362
|
+
const workspaceRoot = findWorkspaceRoot(root);
|
|
363
|
+
const ancestorPath = workspaceRoot ? join(workspaceRoot, '.gitignore') : null;
|
|
364
|
+
|
|
365
|
+
// The probe answers one question — does an ancestor `.gitignore` exist above
|
|
366
|
+
// `root` — for a caller that already knows how to test `root`'s own.
|
|
367
|
+
if (isProbe) {
|
|
368
|
+
exit(ancestorPath && existsSync(ancestorPath) ? 0 : 1);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const ownPath = join(root, '.gitignore');
|
|
372
|
+
const sources = [];
|
|
373
|
+
if (existsSync(ownPath)) {
|
|
374
|
+
sources.push(readSource(ownPath, { own: true, root }));
|
|
375
|
+
}
|
|
376
|
+
if (ancestorPath && existsSync(ancestorPath)) {
|
|
377
|
+
sources.push(
|
|
378
|
+
readSource(ancestorPath, { own: false, reach: (pattern) => !isAnchored(pattern), root }),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/*
|
|
383
|
+
* Neither the package nor any ancestor above it declares an artefact path —
|
|
384
|
+
* the gate has no question to ask, and stays silent.
|
|
385
|
+
*/
|
|
386
|
+
if (sources.length === 0) {
|
|
387
|
+
exit(0);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/*
|
|
391
|
+
* The combined verdict, own file and ancestor alike — an ancestor's line
|
|
392
|
+
* already narrowed to what actually reaches this package (`readSource`'s
|
|
393
|
+
* `reach`). Negations keep the original, file-agnostic wording: rescuing a
|
|
394
|
+
* tracked file is the same judgement call regardless of which file names it.
|
|
395
|
+
*/
|
|
396
|
+
const isIgnored = sources.some((source) => source.isIgnored);
|
|
397
|
+
const misplaced = sources.flatMap((source) =>
|
|
398
|
+
source.misplaced.map((line) => ({ ...line, source })),
|
|
399
|
+
);
|
|
400
|
+
const negations = sources.flatMap((source) => source.negations);
|
|
401
|
+
|
|
402
|
+
/*
|
|
403
|
+
* When nobody ignores it, the file to fix is the shared one when there is
|
|
404
|
+
* one — a workspace root covers every member, so that is where it belongs.
|
|
405
|
+
*/
|
|
406
|
+
const target = sources.find((source) => !source.own) ?? sources[0];
|
|
407
|
+
|
|
408
|
+
const tracked = trackedArtefacts(root);
|
|
409
|
+
|
|
410
|
+
if (isFix) {
|
|
411
|
+
/*
|
|
412
|
+
* Fix repairs the package's OWN file only — it never creates one, and it
|
|
413
|
+
* never rewrites an ancestor, which is a different project's file.
|
|
414
|
+
*/
|
|
415
|
+
const own = sources.find((source) => source.own);
|
|
416
|
+
if (own) {
|
|
417
|
+
const changed = own.misplaced.length > 0 || !own.isIgnored;
|
|
418
|
+
if (changed) {
|
|
419
|
+
writeFileSync(own.path, rewrite(own.lines));
|
|
420
|
+
stdout.write('.gitignore rewritten:\n');
|
|
421
|
+
for (const line of own.misplaced) {
|
|
422
|
+
stdout.write(` - ${line.pattern}\n`);
|
|
423
|
+
}
|
|
424
|
+
if (!own.isIgnored) {
|
|
425
|
+
stdout.write(` + ${ARTIFACTS}/\n`);
|
|
426
|
+
}
|
|
427
|
+
for (const line of own.negations) {
|
|
428
|
+
stdout.write(` · ${line.pattern} — a negation rescues a tracked file, kept\n`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else if (misplaced.length > 0 || !isIgnored) {
|
|
433
|
+
stdout.write('Artefacts belong under .artifacts/<tool>/ at the project root:\n');
|
|
434
|
+
for (const line of misplaced) {
|
|
435
|
+
stdout.write(` ✗ ${line.source.label} names ${line.pattern} — its home is ${line.home}\n`);
|
|
436
|
+
}
|
|
437
|
+
if (!isIgnored) {
|
|
438
|
+
stdout.write(` ✗ ${target.label} does not ignore ${ARTIFACTS}/ — add it\n`);
|
|
439
|
+
}
|
|
440
|
+
for (const line of negations) {
|
|
441
|
+
stdout.write(` · ${line.pattern} — a negation rescues a tracked file, left for you\n`);
|
|
442
|
+
}
|
|
443
|
+
stdout.write("Run 'typescript fix' to rewrite .gitignore.\n");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (tracked.length > 0) {
|
|
447
|
+
stdout.write('Artefacts must never be committed:\n');
|
|
448
|
+
for (const path of tracked) {
|
|
449
|
+
stdout.write(` ✗ ${path} is tracked — git rm --cached ${path}\n`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
exit(tracked.length > 0 || (!isFix && (misplaced.length > 0 || !isIgnored)) ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"allowJs": true,
|
|
8
8
|
"baseUrl": ".",
|
|
9
9
|
"esModuleInterop": true,
|
|
10
|
+
"incremental": true,
|
|
10
11
|
"jsx": "react-native",
|
|
11
12
|
"lib": ["DOM", "ESNext"],
|
|
12
13
|
"moduleResolution": "node",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
},
|
|
17
18
|
"resolveJsonModule": true,
|
|
18
19
|
"skipLibCheck": true,
|
|
19
|
-
"target": "ESNext"
|
|
20
|
+
"target": "ESNext",
|
|
21
|
+
"tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo"
|
|
20
22
|
}
|
|
21
23
|
}
|