@jterrazz/typescript 7.3.0 → 8.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.
@@ -68,11 +68,110 @@ OXFMT=$(find_binary oxfmt)
68
68
  KNIP=$(find_binary knip)
69
69
  CHECKER=$(find_binary jterrazz-test-check)
70
70
 
71
+ # ── The unit is the workspace package, not the repository ────────────────────
72
+ # Every gate measures from the NEAREST package.json. A single-package project
73
+ # has exactly one — the cwd — and nothing below changes for it. A workspace
74
+ # root has one per member, and the per-member gates run once per member
75
+ # instead of once for a root that owns neither the specs nor the docs.
76
+ #
77
+ # Root-only by nature, and deliberately left alone: tsc, oxlint and oxfmt
78
+ # measure from their CONFIG file, not from a package, and each already walks
79
+ # the whole tree from the cwd; knip is natively workspace-aware, so a member's
80
+ # knip config belongs under the root config's `workspaces` key, not in a second
81
+ # invocation.
82
+ WORKSPACE_MEMBERS=()
83
+ while IFS= read -r workspace_member; do
84
+ [ -n "$workspace_member" ] && WORKSPACE_MEMBERS+=("$workspace_member")
85
+ done < <(node "$PACKAGE_ROOT/lib/workspace-members.js" 2>/dev/null)
86
+
87
+ # The nearest package.json OWNS a directory. Walk up from the given path and
88
+ # stop at the cwd — a gate never asks a question above the project it runs in.
89
+ nearest_package_dir() {
90
+ local dir="$1"
91
+ while true; do
92
+ if [ -f "$dir/package.json" ]; then
93
+ printf '%s\n' "$dir"
94
+ return 0
95
+ fi
96
+ [ "$dir" = "." ] && return 1
97
+ dir=$(dirname "$dir")
98
+ done
99
+ }
100
+
71
101
  # The @jterrazz/test conventions checker (D4 tokens, C8/C9 fixtures) runs only when the
72
- # consuming project depends on @jterrazz/test — auto-detected from its package.json.
102
+ # owning package depends on @jterrazz/test — auto-detected from its package.json.
73
103
  project_uses_jterrazz_test() {
74
- [ -f "package.json" ] || return 1
75
- node -e 'const p=require("./package.json");const d={...p.dependencies,...p.devDependencies,...p.peerDependencies};process.exit(d["@jterrazz/test"]?0:1)' 2>/dev/null
104
+ local dir="${1:-.}"
105
+ [ -f "$dir/package.json" ] || return 1
106
+ node -e 'const {readFileSync}=require("node:fs");const p=JSON.parse(readFileSync(process.argv[1],"utf8"));const d={...p.dependencies,...p.devDependencies,...p.peerDependencies};process.exit(d["@jterrazz/test"]?0:1)' "$dir/package.json" 2>/dev/null
107
+ }
108
+
109
+ # In a workspace the dependency may sit on a member alone — the warning below
110
+ # is about the ROOT oxlint config, but the reason to print it is anywhere.
111
+ workspace_uses_jterrazz_test() {
112
+ project_uses_jterrazz_test "." && return 0
113
+ local member
114
+ for member in "${WORKSPACE_MEMBERS[@]}"; do
115
+ project_uses_jterrazz_test "$member" && return 0
116
+ done
117
+ return 1
118
+ }
119
+
120
+ # A path git has been told to forget is not this workspace's source. Clones,
121
+ # workbenches and build output live under gitignored paths, and the conventions
122
+ # checker walks whatever root it is handed — so the filter belongs here, before
123
+ # the handing over. Outside a git tree the question has no answer, and the
124
+ # non-zero exit reads as "not ignored", which is the right default.
125
+ path_is_gitignored() {
126
+ git check-ignore --quiet "$1" 2>/dev/null
127
+ }
128
+
129
+ # A discovered root must belong to the member that produced it. Walk back up
130
+ # from the candidate: a nested `.git`, or a package.json no workspace glob
131
+ # claims, means the walk crossed OUT of this workspace into a foreign tree —
132
+ # a vendored dependency, a sibling clone — whose conventions are not ours.
133
+ inside_owning_member() {
134
+ local dir member="$2"
135
+ dir=$(dirname "$1")
136
+ while [ "$dir" != "$member" ] && [ "$dir" != "." ] && [ "$dir" != "/" ]; do
137
+ if [ -e "$dir/.git" ] || [ -f "$dir/package.json" ]; then
138
+ return 1
139
+ fi
140
+ dir=$(dirname "$dir")
141
+ done
142
+ return 0
143
+ }
144
+
145
+ # Every specs root the workspace owns: the root's own, plus the first one found
146
+ # At or below each member (a member that nests its facet — web/specs — counts).
147
+ # Never descends INTO a specs tree: the fixtures under it are not specs roots.
148
+ discover_specs_roots() {
149
+ {
150
+ [ -d "specs" ] && ! path_is_gitignored "specs" && printf '%s\n' "specs"
151
+ local member candidate
152
+ for member in "${WORKSPACE_MEMBERS[@]}"; do
153
+ while IFS= read -r candidate; do
154
+ [ -n "$candidate" ] || continue
155
+ path_is_gitignored "$candidate" && continue
156
+ inside_owning_member "$candidate" "$member" || continue
157
+ printf '%s\n' "$candidate"
158
+ done < <(find "$member" \
159
+ \( -name node_modules -o -name dist -o -name .git \) -prune -o \
160
+ -type d -name specs -prune -print 2>/dev/null)
161
+ done
162
+ } | LC_ALL=C sort -u
163
+ }
164
+
165
+ # Every package that owns a committed docs projection. A package's docs sit at
166
+ # its own root — that IS the nearest-package.json rule, so no walk is needed.
167
+ discover_docs_roots() {
168
+ {
169
+ [ -d "docs/reference" ] && printf '%s\n' "."
170
+ local member
171
+ for member in "${WORKSPACE_MEMBERS[@]}"; do
172
+ [ -d "$member/docs/reference" ] && printf '%s\n' "$member"
173
+ done
174
+ } | LC_ALL=C sort -u
76
175
  }
77
176
 
78
177
  # The @jterrazz/test oxlint plugin is ESM-only. A CommonJS oxlint config silently drops
@@ -144,7 +243,7 @@ run_checks() {
144
243
 
145
244
  printf "${CYAN_BG}${BRIGHT_WHITE} START ${NC} ${LABEL}\n"
146
245
 
147
- if project_uses_jterrazz_test; then
246
+ if workspace_uses_jterrazz_test; then
148
247
  warn_cjs_oxlint_config
149
248
  fi
150
249
 
@@ -167,7 +266,9 @@ run_checks() {
167
266
  local format_pid=$!
168
267
 
169
268
  # Knip: only run in check mode (fix mode is destructive)
170
- # Merge base config (from this package) with optional project-local knip.json
269
+ # Merge base config (from this package) with optional project-local knip.json.
270
+ # Root-only on purpose: knip reads the workspace globs itself and reports per
271
+ # member from one run — a second invocation per member would double-report.
171
272
  local knip_pid=""
172
273
  local knip_status=0
173
274
  if [ "$FIX_MODE" = false ]; then
@@ -181,24 +282,43 @@ run_checks() {
181
282
  knip_pid=$!
182
283
  fi
183
284
 
184
- # Conventions checker: only in check mode, only when the project uses @jterrazz/test
185
- # and has a specs/ directory to validate.
186
- local checker_pid=""
285
+ # Conventions checker: only in check mode, once per specs root the workspace
286
+ # owns, gated by the package that OWNS that root — a member may depend on
287
+ # @jterrazz/test while the root does not, and the reverse.
288
+ local checker_pids=()
289
+ local checker_logs=()
187
290
  local checker_status=0
188
- if [ "$FIX_MODE" = false ] && [ -d "specs" ] && project_uses_jterrazz_test; then
189
- "$CHECKER" specs > "$tmp_dir/checker.log" 2>&1 &
190
- checker_pid=$!
291
+ if [ "$FIX_MODE" = false ]; then
292
+ local checker_index=0
293
+ while IFS= read -r specs_root; do
294
+ [ -n "$specs_root" ] || continue
295
+ local owner
296
+ owner=$(nearest_package_dir "$(dirname "$specs_root")") || continue
297
+ project_uses_jterrazz_test "$owner" || continue
298
+ "$CHECKER" "$specs_root" > "$tmp_dir/checker-$checker_index.log" 2>&1 &
299
+ checker_pids+=($!)
300
+ checker_logs+=("$tmp_dir/checker-$checker_index.log")
301
+ checker_index=$((checker_index + 1))
302
+ done < <(discover_specs_roots)
191
303
  fi
192
304
 
193
- # Docs (sync): only in check mode, and only once the project has generated
305
+ # Docs (sync): only in check mode, and only for a package that has generated
194
306
  # its committed docs (docs/reference/ exists — opt-in by first generation).
195
307
  # Delegates to docs.sh --check: regenerate into a temp dir, diff the
196
308
  # committed projections. Never duplicates the compiler's logic.
197
- local docs_pid=""
309
+ local docs_pids=()
310
+ local docs_logs=()
198
311
  local docs_status=0
199
- if [ "$FIX_MODE" = false ] && [ -d "docs/reference" ]; then
200
- bash "$SCRIPT_DIR/docs.sh" "$(pwd)" "$PACKAGE_ROOT" --check > "$tmp_dir/docs.log" 2>&1 &
201
- docs_pid=$!
312
+ if [ "$FIX_MODE" = false ]; then
313
+ local docs_index=0
314
+ while IFS= read -r docs_root; do
315
+ [ -n "$docs_root" ] || continue
316
+ bash "$SCRIPT_DIR/docs.sh" "$(cd "$docs_root" && pwd)" "$PACKAGE_ROOT" --check \
317
+ > "$tmp_dir/docs-$docs_index.log" 2>&1 &
318
+ docs_pids+=($!)
319
+ docs_logs+=("$tmp_dir/docs-$docs_index.log")
320
+ docs_index=$((docs_index + 1))
321
+ done < <(discover_docs_roots)
202
322
  fi
203
323
 
204
324
  # Wait and collect statuses
@@ -206,8 +326,28 @@ run_checks() {
206
326
  wait $lint_pid; local lint_status=$?
207
327
  wait $format_pid; local format_status=$?
208
328
  [ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
209
- [ -n "$checker_pid" ] && { wait $checker_pid; checker_status=$?; }
210
- [ -n "$docs_pid" ] && { wait $docs_pid; docs_status=$?; }
329
+
330
+ # One pass, N runs: the pass fails if any run failed, and only the logs of
331
+ # the runs that FAILED are printed — a green member stays silent.
332
+ local checker_failed_logs=()
333
+ local index=0
334
+ for pid in "${checker_pids[@]}"; do
335
+ if ! wait "$pid"; then
336
+ checker_status=1
337
+ checker_failed_logs+=("${checker_logs[$index]}")
338
+ fi
339
+ index=$((index + 1))
340
+ done
341
+
342
+ local docs_failed_logs=()
343
+ index=0
344
+ for pid in "${docs_pids[@]}"; do
345
+ if ! wait "$pid"; then
346
+ docs_status=1
347
+ docs_failed_logs+=("${docs_logs[$index]}")
348
+ fi
349
+ index=$((index + 1))
350
+ done
211
351
 
212
352
  # Print results — quiet on success, verbose on failure: a tool's captured log
213
353
  # is shown only when it failed, so green output stays byte-identical across
@@ -249,20 +389,24 @@ run_checks() {
249
389
  printf "${GREEN}✓ Passed${NC}\n"
250
390
  fi
251
391
 
252
- if [ -n "$checker_pid" ]; then
392
+ if [ ${#checker_pids[@]} -gt 0 ]; then
253
393
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Test Conventions (@jterrazz/test)\n\n"
254
394
  if [ $checker_status -ne 0 ]; then
255
- [ -s "$tmp_dir/checker.log" ] && cat "$tmp_dir/checker.log"
395
+ for log in "${checker_failed_logs[@]}"; do
396
+ [ -s "$log" ] && cat "$log"
397
+ done
256
398
  printf "${RED}✗ Failed with exit code %d${NC}\n" $checker_status
257
399
  else
258
400
  printf "${GREEN}✓ Passed${NC}\n"
259
401
  fi
260
402
  fi
261
403
 
262
- if [ -n "$docs_pid" ]; then
404
+ if [ ${#docs_pids[@]} -gt 0 ]; then
263
405
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Docs (sync)\n\n"
264
406
  if [ $docs_status -ne 0 ]; then
265
- [ -s "$tmp_dir/docs.log" ] && cat "$tmp_dir/docs.log"
407
+ for log in "${docs_failed_logs[@]}"; do
408
+ [ -s "$log" ] && cat "$log"
409
+ done
266
410
  printf "${RED}✗ Failed with exit code %d${NC}\n" $docs_status
267
411
  else
268
412
  printf "${GREEN}✓ Passed${NC}\n"
package/bin/typescript.sh CHANGED
@@ -88,18 +88,59 @@ case "$COMMAND" in
88
88
  ;;
89
89
 
90
90
  docs)
91
+ # The unit is the PACKAGE. A projection is compiled from a package's own
92
+ # Barrel against its own tsconfig into its own docs/ — three things a
93
+ # Workspace root does not have, and each member does. docs.sh was always
94
+ # Parameterised by a project root; what was missing is the list.
95
+ owns_a_projection() {
96
+ local root="$1"
97
+ [ -d "$root/docs" ] || return 1
98
+ [ -f "$root/src/index.ts" ] || [ -f "$root/src/index.d.ts" ]
99
+ }
100
+
101
+ DOCS_UNITS=()
102
+ if owns_a_projection "$PROJECT_ROOT"; then
103
+ DOCS_UNITS+=("$PROJECT_ROOT")
104
+ fi
105
+ while IFS= read -r docs_member; do
106
+ [ -n "$docs_member" ] || continue
107
+ if owns_a_projection "$PROJECT_ROOT/$docs_member"; then
108
+ DOCS_UNITS+=("$PROJECT_ROOT/$docs_member")
109
+ fi
110
+ done < <(node "$PACKAGE_ROOT/lib/workspace-members.js" "$PROJECT_ROOT" 2>/dev/null)
111
+
112
+ # Nothing qualified: hand the project root over anyway, so the compiler's
113
+ # Own diagnostic is what the operator reads.
114
+ if [ ${#DOCS_UNITS[@]} -eq 0 ]; then
115
+ DOCS_UNITS=("$PROJECT_ROOT")
116
+ fi
117
+
91
118
  if [ "${1:-}" = "--check" ]; then
92
119
  printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} Checking docs are in sync...\n\n"
93
120
 
94
- bash "$SCRIPT_DIR/commands/docs.sh" "$PROJECT_ROOT" "$PACKAGE_ROOT" --check
121
+ for docs_unit in "${DOCS_UNITS[@]}"; do
122
+ if [ ${#DOCS_UNITS[@]} -gt 1 ]; then
123
+ printf "%s\n" "${docs_unit#"$PROJECT_ROOT/"}"
124
+ fi
125
+ bash "$SCRIPT_DIR/commands/docs.sh" "$docs_unit" "$PACKAGE_ROOT" --check
126
+ done
95
127
 
96
128
  printf "${GREEN}Docs are in sync${NC}\n"
97
129
  else
98
130
  printf "${CYAN_BG}${BRIGHT_WHITE} TYPESCRIPT ${NC} Generating API docs...\n\n"
99
131
 
100
- bash "$SCRIPT_DIR/commands/docs.sh" "$PROJECT_ROOT" "$PACKAGE_ROOT"
101
-
102
- printf "\n${GREEN}Docs generated at docs/${NC}\n"
132
+ for docs_unit in "${DOCS_UNITS[@]}"; do
133
+ if [ ${#DOCS_UNITS[@]} -gt 1 ]; then
134
+ printf "%s\n" "${docs_unit#"$PROJECT_ROOT/"}"
135
+ fi
136
+ bash "$SCRIPT_DIR/commands/docs.sh" "$docs_unit" "$PACKAGE_ROOT"
137
+ done
138
+
139
+ if [ ${#DOCS_UNITS[@]} -gt 1 ]; then
140
+ printf "\n${GREEN}Docs generated for %d packages${NC}\n" ${#DOCS_UNITS[@]}
141
+ else
142
+ printf "\n${GREEN}Docs generated at docs/${NC}\n"
143
+ fi
103
144
  fi
104
145
  ;;
105
146
 
@@ -14,12 +14,21 @@
14
14
  *
15
15
  * Dynamic detection:
16
16
  * - Published library (main/exports/publishConfig) → disable exports/types/files rules
17
- * - Auto-ignore docs/**, fixtures/**, expected/** convention paths
17
+ * - Auto-ignore docs/**, fixtures/**, expected/** convention paths — at the root
18
+ * AND inside every workspace member, because the convention is the package's
19
+ * and knip resolves `ignore` globs from the root
18
20
  * - Vitest workspace workaround → explicit vitest config when vitest is a dependency
21
+ *
22
+ * Knip itself is workspace-aware: it reads the `workspaces` globs and reports per
23
+ * member from one run. A consumer that needs per-member knip settings spells them
24
+ * under a `workspaces` key in its own knip.json — the base preset has none, so the
25
+ * key passes through the merge untouched.
19
26
  */
20
27
 
21
28
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
29
 
30
+ import { workspaceMembers } from './workspace-members.js';
31
+
23
32
  const basePath = process.argv[2];
24
33
  const projectPath = process.argv[3];
25
34
 
@@ -68,19 +77,26 @@ if (pkg.main || pkg.exports || pkg.publishConfig) {
68
77
  merged.rules = { exports: 'off', files: 'off', types: 'off', ...merged.rules };
69
78
  }
70
79
 
71
- // 2. Auto-ignore convention paths that exist on disk
80
+ // 2. Auto-ignore convention paths that exist on disk.
81
+ // The convention belongs to a PACKAGE, so the scan runs once per unit — the
82
+ // Root and each workspace member. Knip resolves `ignore` globs from the root,
83
+ // So a member's paths are emitted with their member prefix.
72
84
  const conventionIgnores = [];
73
- if (existsSync('docs')) {
74
- conventionIgnores.push('docs/**');
75
- }
85
+ const units = ['.', ...workspaceMembers('.')];
76
86
 
77
- // Scan for fixtures/** and expected/** directories anywhere in the tree (up to 3 levels)
78
- const scanDirs = ['specs', 'tests', 'test', 'src'];
79
- for (const root of scanDirs) {
80
- if (!existsSync(root)) {
81
- continue;
87
+ for (const unit of units) {
88
+ const prefix = unit === '.' ? '' : `${unit}/`;
89
+
90
+ if (existsSync(`${prefix}docs`)) {
91
+ conventionIgnores.push(`${prefix}docs/**`);
92
+ }
93
+
94
+ // Scan for fixtures/** and expected/** directories anywhere in the unit (up to 3 levels)
95
+ for (const scanDir of ['specs', 'tests', 'test', 'src']) {
96
+ if (existsSync(`${prefix}${scanDir}`)) {
97
+ findConventionDirs(`${prefix}${scanDir}`, 0);
98
+ }
82
99
  }
83
- findConventionDirs(root, 0);
84
100
  }
85
101
 
86
102
  function findConventionDirs(dir, depth) {
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * The workspace members declared by a package.json — the unit every
5
+ * per-package gate measures from.
6
+ *
7
+ * As a module: `workspaceMembers(root)` returns the member directories,
8
+ * relative to the root, no trailing slash, sorted. As a CLI, the same list on
9
+ * stdout, one per line:
10
+ *
11
+ * node workspace-members.js [root]
12
+ *
13
+ * The toolchain's unit is the workspace PACKAGE, not the repository: a gate
14
+ * that reads `specs/` or `docs/` at cwd sees only the root's, and a monorepo's
15
+ * members are silently never checked. Callers ask here what the members are;
16
+ * the list is empty when the package declares no `workspaces`, and the caller
17
+ * falls back to the root itself.
18
+ *
19
+ * Supported declarations (the npm/yarn/bun surface):
20
+ * "workspaces": ["apps/*", "packages/*"]
21
+ * "workspaces": { "packages": ["apps/*"] } // yarn classic
22
+ *
23
+ * A member is a directory matching a glob AND holding a package.json — the
24
+ * same rule the package managers apply. Globs support `*` (one segment) and
25
+ * `**` (any depth); `node_modules`, `dist` and dot-directories are never
26
+ * descended into.
27
+ */
28
+
29
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
30
+ import { join, relative, resolve, sep } from 'node:path';
31
+ import { argv, stdout } from 'node:process';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const SKIPPED = new Set(['dist', 'node_modules']);
35
+
36
+ /** A glob over path segments — `*` stops at a separator, `**` does not. */
37
+ function toPattern(glob) {
38
+ const escaped = glob
39
+ .replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`)
40
+ .replaceAll('**', ' ')
41
+ .replaceAll('*', '[^/]*')
42
+ .replaceAll(' ', '.*');
43
+
44
+ return new RegExp(`^${escaped}$`);
45
+ }
46
+
47
+ /** The `workspaces` globs of a package.json, in either declared form. */
48
+ function declaredGlobs(root) {
49
+ const manifest = join(root, 'package.json');
50
+ if (!existsSync(manifest)) {
51
+ return [];
52
+ }
53
+
54
+ let declared;
55
+ try {
56
+ const pkg = JSON.parse(readFileSync(manifest, 'utf8'));
57
+ declared = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
58
+ } catch {
59
+ return [];
60
+ }
61
+
62
+ return Array.isArray(declared) ? declared : [];
63
+ }
64
+
65
+ /**
66
+ * The member directories of the workspace rooted at `root` — empty for a
67
+ * single-package project, which every caller reads as "the root is the unit".
68
+ */
69
+ export function workspaceMembers(root = '.') {
70
+ const absolute = resolve(root);
71
+ const patterns = declaredGlobs(absolute)
72
+ .filter((glob) => typeof glob === 'string' && !glob.startsWith('!'))
73
+ .map((glob) => toPattern(glob.replace(/\/+$/, '')));
74
+
75
+ if (patterns.length === 0) {
76
+ return [];
77
+ }
78
+
79
+ const members = new Set();
80
+
81
+ /** Walk the tree once, keeping every directory a pattern claims as a package. */
82
+ function walk(dir, depth) {
83
+ if (depth > 6) {
84
+ return;
85
+ }
86
+
87
+ let entries;
88
+ try {
89
+ entries = readdirSync(dir, { withFileTypes: true });
90
+ } catch {
91
+ return;
92
+ }
93
+
94
+ for (const entry of entries) {
95
+ if (!entry.isDirectory() || entry.name.startsWith('.') || SKIPPED.has(entry.name)) {
96
+ continue;
97
+ }
98
+
99
+ const child = join(dir, entry.name);
100
+ const path = relative(absolute, child).split(sep).join('/');
101
+
102
+ if (
103
+ patterns.some((pattern) => pattern.test(path)) &&
104
+ existsSync(join(child, 'package.json'))
105
+ ) {
106
+ members.add(path);
107
+ continue;
108
+ }
109
+
110
+ walk(child, depth + 1);
111
+ }
112
+ }
113
+
114
+ walk(absolute, 0);
115
+
116
+ return [...members].sort();
117
+ }
118
+
119
+ // CLI form — only when this file IS the process entry, never on import.
120
+ if (argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url)) {
121
+ const members = workspaceMembers(argv[2] ?? '.');
122
+ if (members.length > 0) {
123
+ stdout.write(`${members.join('\n')}\n`);
124
+ }
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "7.3.0",
3
+ "version": "8.1.0",
4
4
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -78,6 +78,41 @@ function createHexagonalRule() {
78
78
  // Imports-with-ext - Require .js extensions
79
79
  // ============================================
80
80
 
81
+ // The extensions a bundler or Node actually resolves from an import specifier.
82
+ // Anything else after a dot is part of the module NAME (`user.entity`,
83
+ // `dashboard.post`, `article.repository`) and still needs its `.js`.
84
+ const KNOWN_IMPORT_EXTENSIONS = [
85
+ 'cjs',
86
+ 'css',
87
+ 'cts',
88
+ 'graphql',
89
+ 'gql',
90
+ 'html',
91
+ 'jpeg',
92
+ 'jpg',
93
+ 'js',
94
+ 'json',
95
+ 'jsx',
96
+ 'less',
97
+ 'md',
98
+ 'mdx',
99
+ 'mjs',
100
+ 'mts',
101
+ 'node',
102
+ 'png',
103
+ 'sass',
104
+ 'scss',
105
+ 'svg',
106
+ 'toml',
107
+ 'ts',
108
+ 'tsx',
109
+ 'txt',
110
+ 'wasm',
111
+ 'webp',
112
+ 'yaml',
113
+ 'yml',
114
+ ];
115
+
81
116
  const importsWithExtRule = {
82
117
  meta: {
83
118
  type: 'problem',
@@ -89,7 +124,9 @@ const importsWithExtRule = {
89
124
  schema: [],
90
125
  },
91
126
  create(context) {
92
- const hasExtension = /\.[a-zA-Z0-9]+$/;
127
+ // Only a KNOWN extension counts as already-extended — "anything after the
128
+ // Last dot" read `./entities/dashboard.post` as extended and skipped it.
129
+ const hasExtension = new RegExp(`\\.(?:${KNOWN_IMPORT_EXTENSIONS.join('|')})$`, 'i');
93
130
 
94
131
  function checkNode(node) {
95
132
  if (!node.source || !node.source.value) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "display": "Expo",
3
- "include": ["../../../../**/*.ts", "../../../../**/*.tsx"],
4
- "exclude": ["../../../../node_modules"],
3
+ "include": ["${configDir}/**/*.ts", "${configDir}/**/*.tsx"],
4
+ "exclude": ["${configDir}/node_modules"],
5
5
 
6
6
  "compilerOptions": {
7
7
  "allowJs": true,
@@ -4,7 +4,7 @@
4
4
  "lib": ["dom", "dom.iterable", "esnext"],
5
5
  "allowJs": true,
6
6
  "skipLibCheck": true,
7
- "strict": false,
7
+ "strict": true,
8
8
  "noEmit": true,
9
9
  "incremental": true,
10
10
  "module": "esnext",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "display": "Node ESM",
3
- "include": ["../../../../**/*.ts"],
4
- "exclude": ["../../../../node_modules"],
3
+ "include": ["${configDir}/**/*.ts"],
4
+ "exclude": ["${configDir}/node_modules"],
5
5
 
6
6
  "compilerOptions": {
7
7
  "moduleResolution": "Bundler",