@jterrazz/typescript 8.1.3 → 9.0.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 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
@@ -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,23 @@ 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`. Opt-in by existence: a project with no `.gitignore` names no
293
+ # artefact path, so the gate has no question to ask. Root-only, because
294
+ # `.artifacts/` sits at the project ROOT. Runs in fix mode too — it is the
295
+ # one gate whose remedy is a rewrite rather than a deletion.
296
+ local gitignore_pid=""
297
+ local gitignore_status=0
298
+ if [ -f ".gitignore" ]; then
299
+ if [ "$FIX_MODE" = true ]; then
300
+ node "$PACKAGE_ROOT/lib/check-gitignore.js" --fix > "$tmp_dir/gitignore.log" 2>&1 &
301
+ else
302
+ node "$PACKAGE_ROOT/lib/check-gitignore.js" > "$tmp_dir/gitignore.log" 2>&1 &
303
+ fi
304
+ gitignore_pid=$!
305
+ fi
306
+
285
307
  # Conventions checker: only in check mode, once per specs root the workspace
286
308
  # owns, gated by the package that OWNS that root — a member may depend on
287
309
  # @jterrazz/test while the root does not, and the reverse.
@@ -326,6 +348,7 @@ run_checks() {
326
348
  wait $lint_pid; local lint_status=$?
327
349
  wait $format_pid; local format_status=$?
328
350
  [ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
351
+ [ -n "$gitignore_pid" ] && { wait $gitignore_pid; gitignore_status=$?; }
329
352
 
330
353
  # One pass, N runs: the pass fails if any run failed, and only the logs of
331
354
  # the runs that FAILED are printed — a green member stays silent.
@@ -380,6 +403,19 @@ run_checks() {
380
403
  printf "${GREEN}✓ Passed${NC}\n"
381
404
  fi
382
405
 
406
+ # The one pass that speaks on success: a rewrite changed a file the operator
407
+ # owns, and silence would hide it. In check mode a green gate writes nothing,
408
+ # so the green output stays byte-identical with the others.
409
+ if [ -n "$gitignore_pid" ]; then
410
+ printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Gitignore (artefacts)\n\n"
411
+ [ -s "$tmp_dir/gitignore.log" ] && cat "$tmp_dir/gitignore.log"
412
+ if [ $gitignore_status -ne 0 ]; then
413
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $gitignore_status
414
+ else
415
+ printf "${GREEN}✓ Passed${NC}\n"
416
+ fi
417
+ fi
418
+
383
419
  if [ "$FIX_MODE" = false ]; then
384
420
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Knip (unused code)\n\n"
385
421
  if [ $knip_status -ne 0 ]; then
@@ -421,7 +457,7 @@ run_checks() {
421
457
  printf "\n${CYAN_BG}${BRIGHT_WHITE} END ${NC} Finalizing quality checks\n\n"
422
458
  fi
423
459
 
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
460
+ 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
461
  printf "${GREEN}✓ All checks passed${NC}\n"
426
462
  exit 0
427
463
  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\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,260 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * The artefact convention, read off a project's `.gitignore`.
5
+ *
6
+ * Every build, test and lint artefact lives under `.artifacts/<tool>/` at the
7
+ * project root — one folder per tool that writes. A `.gitignore` line naming an
8
+ * artefact ANYWHERE else is the old layout, and this gate says so; `.artifacts/`
9
+ * itself must be ignored, so the convention's own folder never reaches a commit.
10
+ *
11
+ * Usage: node check-gitignore.js [--fix] [root]
12
+ *
13
+ * `--fix` rewrites the `.gitignore`: the artefact lines go, `.artifacts/` arrives,
14
+ * and everything else — comments, blank lines, order, the project's own paths —
15
+ * survives untouched. A `!` line is NEVER rewritten: it rescues a tracked file,
16
+ * and deleting the line it negates could hide a real path. Those are reported.
17
+ *
18
+ * Exit code: 0 when the project holds the convention, 1 otherwise. In `--fix`
19
+ * mode only what the rewrite cannot repair — a committed artefact — still fails.
20
+ */
21
+
22
+ import { execFileSync } from 'node:child_process';
23
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { argv, exit, stdout } from 'node:process';
26
+
27
+ /** The convention's own directory: the one path that MUST be ignored. */
28
+ const ARTIFACTS = '.artifacts';
29
+
30
+ /**
31
+ * The one exception to the convention: a build's product stays beside `src/`
32
+ * and is published from there, so `dist` is a legitimate ignored path.
33
+ */
34
+ const PRODUCT = 'dist';
35
+
36
+ /** Directories a tool writes, and the `.artifacts/` home each one moves to. */
37
+ const ARTEFACT_DIRECTORIES = new Map([
38
+ ['.cache', '.artifacts/<tool>/'],
39
+ ['.next', '.artifacts/next/'],
40
+ ['.turbo', '.artifacts/turbo/'],
41
+ ['.vite', '.artifacts/vite/'],
42
+ ['bin', '.artifacts/go/'],
43
+ ['build', '.artifacts/<tool>/'],
44
+ ['coverage', '.artifacts/coverage/'],
45
+ ['out', '.artifacts/next/'],
46
+ ['playwright-report', '.artifacts/playwright/'],
47
+ ['target', '.artifacts/cargo/'],
48
+ ['test-results', '.artifacts/playwright/'],
49
+ ]);
50
+
51
+ /** Files a tool writes, matched on their extension, and where they belong. */
52
+ const ARTEFACT_EXTENSIONS = [
53
+ ['.tsbuildinfo', '.artifacts/tsc/'],
54
+ ['.log', '.artifacts/logs/'],
55
+ ];
56
+
57
+ /**
58
+ * The closed list of ignored paths that are NOT artefacts of the convention:
59
+ * platform working directories a toolchain owns and cannot be told to move,
60
+ * generated files a framework expects at a fixed path, and `node_modules`.
61
+ */
62
+ const EXCEPTIONS = new Set([
63
+ '.build',
64
+ '.expo',
65
+ '.gradle',
66
+ '.swiftpm',
67
+ '.vercel',
68
+ 'DerivedData',
69
+ 'Package.resolved',
70
+ 'android',
71
+ 'ios',
72
+ 'next-env.d.ts',
73
+ 'node_modules',
74
+ ]);
75
+
76
+ /** The one exception spelled as a prefix — Expo stamps a suffix onto it. */
77
+ const EXCEPTION_PREFIXES = ['.metro-health-check'];
78
+
79
+ /**
80
+ * Directories whose every tracked file is a committed artefact. Narrower than
81
+ * the list above on purpose: `bin`, `out`, `build` and `target` are ignorable
82
+ * as OUTPUT, but a tracked file under them may be a project's own source —
83
+ * a `.gitignore` line declares intent, a tracked path declares nothing.
84
+ */
85
+ const TRACKED_ARTEFACT_DIRECTORIES = new Set([
86
+ '.artifacts',
87
+ '.cache',
88
+ '.next',
89
+ '.turbo',
90
+ '.vite',
91
+ 'coverage',
92
+ 'playwright-report',
93
+ 'test-results',
94
+ ]);
95
+
96
+ /** The extension no tracked file may carry — a buildinfo is never source. */
97
+ const TRACKED_ARTEFACT_EXTENSION = '.tsbuildinfo';
98
+
99
+ /**
100
+ * What a `.gitignore` pattern names, reduced to the one segment that carries
101
+ * the meaning: an anchored form, a nested form and a doubled-star form all name
102
+ * the same directory. A trailing `*` is dropped so `npm-debug.log*` reads as a
103
+ * log, and a `**` segment never wins over the name that follows it.
104
+ */
105
+ function subject(pattern) {
106
+ const segments = pattern
107
+ .replace(/\/+$/, '')
108
+ .split('/')
109
+ .filter((segment) => segment !== '' && segment !== '**');
110
+
111
+ return (segments.at(-1) ?? '').replace(/\*$/, '');
112
+ }
113
+
114
+ /** The `.artifacts/` home of the artefact a pattern names, or null. */
115
+ function artefactHome(pattern) {
116
+ const name = subject(pattern);
117
+
118
+ if (name === '' || name === PRODUCT || EXCEPTIONS.has(name)) {
119
+ return null;
120
+ }
121
+ if (EXCEPTION_PREFIXES.some((prefix) => name.startsWith(prefix))) {
122
+ return null;
123
+ }
124
+ if (ARTEFACT_DIRECTORIES.has(name)) {
125
+ return ARTEFACT_DIRECTORIES.get(name);
126
+ }
127
+ for (const [extension, home] of ARTEFACT_EXTENSIONS) {
128
+ if (name.endsWith(extension)) {
129
+ return home;
130
+ }
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ /** Every line of the file, classified once — the rewrite reads the same list. */
137
+ function readLines(gitignorePath) {
138
+ const text = readFileSync(gitignorePath, 'utf8').split('\n');
139
+
140
+ // The empty string a trailing newline leaves behind is not a line.
141
+ // Putting that newline back is the rewrite's job.
142
+ if (text.at(-1) === '') {
143
+ text.pop();
144
+ }
145
+
146
+ return text.map((line) => {
147
+ const pattern = line.trim();
148
+ const isPattern = pattern !== '' && !pattern.startsWith('#');
149
+
150
+ return {
151
+ home: isPattern && !pattern.startsWith('!') ? artefactHome(pattern) : null,
152
+ isNegation: isPattern && pattern.startsWith('!'),
153
+ pattern,
154
+ text: line,
155
+ };
156
+ });
157
+ }
158
+
159
+ /**
160
+ * The artefacts a project COMMITTED. Outside a git tree the question has no
161
+ * answer, and silence is the right one — a fixture directory is not a repo.
162
+ */
163
+ function trackedArtefacts(root) {
164
+ let listed;
165
+ try {
166
+ listed = execFileSync('git', ['ls-files', '-z'], {
167
+ cwd: root,
168
+ encoding: 'utf8',
169
+ stdio: ['ignore', 'pipe', 'ignore'],
170
+ });
171
+ } catch {
172
+ return [];
173
+ }
174
+
175
+ return listed
176
+ .split('\0')
177
+ .filter((path) => path !== '')
178
+ .filter((path) => {
179
+ const segments = path.split('/');
180
+
181
+ return (
182
+ segments
183
+ .slice(0, -1)
184
+ .some((segment) => TRACKED_ARTEFACT_DIRECTORIES.has(segment)) ||
185
+ path.endsWith(TRACKED_ARTEFACT_EXTENSION)
186
+ );
187
+ });
188
+ }
189
+
190
+ /** The rewritten file: artefact lines dropped, `.artifacts/` present, rest kept. */
191
+ function rewrite(lines) {
192
+ const kept = lines.filter((line) => line.home === null);
193
+ const isIgnored = kept.some((line) => !line.isNegation && subject(line.pattern) === ARTIFACTS);
194
+ const body = kept.map((line) => line.text);
195
+
196
+ if (!isIgnored) {
197
+ while (body.length > 0 && body.at(-1).trim() === '') {
198
+ body.pop();
199
+ }
200
+ body.push('', '# Build, test and lint artefacts (@jterrazz/typescript)', `${ARTIFACTS}/`);
201
+ }
202
+
203
+ return `${body.join('\n')}\n`;
204
+ }
205
+
206
+ const isFix = argv.includes('--fix');
207
+ const root = argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.';
208
+ const gitignorePath = join(root, '.gitignore');
209
+
210
+ // A project with no `.gitignore` declares no artefact path and nothing to fix.
211
+ // The gate has no question to ask of it, and stays silent.
212
+ if (!existsSync(gitignorePath)) {
213
+ exit(0);
214
+ }
215
+
216
+ const lines = readLines(gitignorePath);
217
+ const misplaced = lines.filter((line) => line.home !== null);
218
+ const negations = lines.filter((line) => line.isNegation);
219
+ const isIgnored = lines.some(
220
+ (line) => !line.isNegation && line.home === null && subject(line.pattern) === ARTIFACTS,
221
+ );
222
+ const tracked = trackedArtefacts(root);
223
+
224
+ if (isFix) {
225
+ const changed = misplaced.length > 0 || !isIgnored;
226
+ if (changed) {
227
+ writeFileSync(gitignorePath, rewrite(lines));
228
+ stdout.write('.gitignore rewritten:\n');
229
+ for (const line of misplaced) {
230
+ stdout.write(` - ${line.pattern}\n`);
231
+ }
232
+ if (!isIgnored) {
233
+ stdout.write(` + ${ARTIFACTS}/\n`);
234
+ }
235
+ for (const line of negations) {
236
+ stdout.write(` · ${line.pattern} — a negation rescues a tracked file, kept\n`);
237
+ }
238
+ }
239
+ } else if (misplaced.length > 0 || !isIgnored) {
240
+ stdout.write('Artefacts belong under .artifacts/<tool>/ at the project root:\n');
241
+ for (const line of misplaced) {
242
+ stdout.write(` ✗ .gitignore names ${line.pattern} — its home is ${line.home}\n`);
243
+ }
244
+ if (!isIgnored) {
245
+ stdout.write(` ✗ .gitignore does not ignore ${ARTIFACTS}/ — add it\n`);
246
+ }
247
+ for (const line of negations) {
248
+ stdout.write(` · ${line.pattern} — a negation rescues a tracked file, left for you\n`);
249
+ }
250
+ stdout.write("Run 'typescript fix' to rewrite .gitignore.\n");
251
+ }
252
+
253
+ if (tracked.length > 0) {
254
+ stdout.write('Artefacts must never be committed:\n');
255
+ for (const path of tracked) {
256
+ stdout.write(` ✗ ${path} is tracked — git rm --cached ${path}\n`);
257
+ }
258
+ }
259
+
260
+ exit(tracked.length > 0 || (!isFix && (misplaced.length > 0 || !isIgnored)) ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "8.1.3",
3
+ "version": "9.0.0",
4
4
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
  }
@@ -7,6 +7,7 @@
7
7
  "strict": true,
8
8
  "noEmit": true,
9
9
  "incremental": true,
10
+ "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
10
11
  "module": "esnext",
11
12
  "esModuleInterop": true,
12
13
  "moduleResolution": "bundler",
@@ -9,6 +9,8 @@
9
9
  "target": "ESNext",
10
10
  "types": ["node"],
11
11
  "strict": true,
12
+ "incremental": true,
13
+ "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
12
14
  "experimentalDecorators": true,
13
15
  "esModuleInterop": true,
14
16
  "resolveJsonModule": true,