@jterrazz/typescript 6.1.1 → 6.2.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.
@@ -66,6 +66,41 @@ TSC=$(find_tsc)
66
66
  OXLINT=$(find_binary oxlint)
67
67
  OXFMT=$(find_binary oxfmt)
68
68
  KNIP=$(find_binary knip)
69
+ CHECKER=$(find_binary jterrazz-test-check)
70
+
71
+ # 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.
73
+ 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
76
+ }
77
+
78
+ # The @jterrazz/test oxlint plugin is ESM-only. A CommonJS oxlint config silently drops
79
+ # it (oxlint prints a load warning and still exits 0) — none of the jterrazz/* rules run.
80
+ # Warn loudly when that pitfall is detectable.
81
+ warn_cjs_oxlint_config() {
82
+ local cfg=""
83
+ for c in oxlint.config.ts oxlint.config.mjs oxlint.config.cjs oxlint.config.js; do
84
+ [ -f "$c" ] && { cfg="$c"; break; }
85
+ done
86
+ [ -z "$cfg" ] && return 0
87
+
88
+ local is_cjs=false
89
+ case "$cfg" in
90
+ *.cjs) is_cjs=true ;;
91
+ *.js)
92
+ if ! node -e 'process.exit(require("./package.json").type==="module"?0:1)' 2>/dev/null; then
93
+ is_cjs=true
94
+ fi
95
+ ;;
96
+ esac
97
+
98
+ if [ "$is_cjs" = true ]; then
99
+ printf "${RED} WARNING ${NC} @jterrazz/test is installed but %s is CommonJS.\n" "$cfg"
100
+ printf " The @jterrazz/test oxlint plugin is ESM-only and will be SILENTLY DROPPED —\n"
101
+ printf " none of the jterrazz/* rules will run. Switch to an ESM config (oxlint.config.ts or .mjs).\n\n"
102
+ fi
103
+ }
69
104
 
70
105
  # Parse command and args
71
106
  COMMAND=""
@@ -109,6 +144,10 @@ run_checks() {
109
144
 
110
145
  printf "${CYAN_BG}${BRIGHT_WHITE} START ${NC} ${LABEL}\n"
111
146
 
147
+ if project_uses_jterrazz_test; then
148
+ warn_cjs_oxlint_config
149
+ fi
150
+
112
151
  # Run all tools in parallel
113
152
  "$TSC" --noEmit > "$tmp_dir/type.log" 2>&1 &
114
153
  local type_pid=$!
@@ -142,33 +181,71 @@ run_checks() {
142
181
  knip_pid=$!
143
182
  fi
144
183
 
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=""
187
+ 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=$!
191
+ fi
192
+
145
193
  # Wait and collect statuses
146
194
  wait $type_pid; local type_status=$?
147
195
  wait $lint_pid; local lint_status=$?
148
196
  wait $format_pid; local format_status=$?
149
197
  [ -n "$knip_pid" ] && { wait $knip_pid; knip_status=$?; }
198
+ [ -n "$checker_pid" ] && { wait $checker_pid; checker_status=$?; }
150
199
 
151
- # Print results
200
+ # Print results — quiet on success, verbose on failure: a tool's captured log
201
+ # is shown only when it failed, so green output stays byte-identical across
202
+ # platforms (some tool builds print success chatter on Linux but not macOS).
152
203
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} TypeScript Check\n\n"
153
- [ -s "$tmp_dir/type.log" ] && cat "$tmp_dir/type.log"
154
- [ $type_status -ne 0 ] && printf "${RED}✗ Failed with exit code %d${NC}\n" $type_status || printf "${GREEN}✓ Passed${NC}\n"
204
+ if [ $type_status -ne 0 ]; then
205
+ [ -s "$tmp_dir/type.log" ] && cat "$tmp_dir/type.log"
206
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $type_status
207
+ else
208
+ printf "${GREEN}✓ Passed${NC}\n"
209
+ fi
155
210
 
156
211
  local lint_label="Oxlint Check"
157
212
  [ "$FIX_MODE" = true ] && lint_label="Oxlint Fix"
158
213
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} ${lint_label}\n\n"
159
- [ -s "$tmp_dir/lint.log" ] && cat "$tmp_dir/lint.log"
160
- [ $lint_status -ne 0 ] && printf "${RED}✗ Failed with exit code %d${NC}\n" $lint_status || printf "${GREEN}✓ Passed${NC}\n"
214
+ if [ $lint_status -ne 0 ]; then
215
+ [ -s "$tmp_dir/lint.log" ] && cat "$tmp_dir/lint.log"
216
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $lint_status
217
+ else
218
+ printf "${GREEN}✓ Passed${NC}\n"
219
+ fi
161
220
 
162
221
  local format_label="Oxfmt Check"
163
222
  [ "$FIX_MODE" = true ] && format_label="Oxfmt Format"
164
223
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} ${format_label}\n\n"
165
- [ -s "$tmp_dir/format.log" ] && cat "$tmp_dir/format.log"
166
- [ $format_status -ne 0 ] && printf "${RED}✗ Failed with exit code %d${NC}\n" $format_status || printf "${GREEN}✓ Passed${NC}\n"
224
+ if [ $format_status -ne 0 ]; then
225
+ [ -s "$tmp_dir/format.log" ] && cat "$tmp_dir/format.log"
226
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $format_status
227
+ else
228
+ printf "${GREEN}✓ Passed${NC}\n"
229
+ fi
167
230
 
168
231
  if [ "$FIX_MODE" = false ]; then
169
232
  printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Knip (unused code)\n\n"
170
- [ -s "$tmp_dir/knip.log" ] && cat "$tmp_dir/knip.log"
171
- [ $knip_status -ne 0 ] && printf "${RED}✗ Failed with exit code %d${NC}\n" $knip_status || printf "${GREEN}✓ Passed${NC}\n"
233
+ if [ $knip_status -ne 0 ]; then
234
+ [ -s "$tmp_dir/knip.log" ] && cat "$tmp_dir/knip.log"
235
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $knip_status
236
+ else
237
+ printf "${GREEN}✓ Passed${NC}\n"
238
+ fi
239
+
240
+ if [ -n "$checker_pid" ]; then
241
+ printf "\n${CYAN_BG}${BRIGHT_WHITE} RUN ${NC} Test Conventions (@jterrazz/test)\n\n"
242
+ if [ $checker_status -ne 0 ]; then
243
+ [ -s "$tmp_dir/checker.log" ] && cat "$tmp_dir/checker.log"
244
+ printf "${RED}✗ Failed with exit code %d${NC}\n" $checker_status
245
+ else
246
+ printf "${GREEN}✓ Passed${NC}\n"
247
+ fi
248
+ fi
172
249
  fi
173
250
 
174
251
  # Summary
@@ -178,7 +255,7 @@ run_checks() {
178
255
  printf "\n${CYAN_BG}${BRIGHT_WHITE} END ${NC} Finalizing quality checks\n\n"
179
256
  fi
180
257
 
181
- if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ]; then
258
+ if [ $type_status -eq 0 ] && [ $lint_status -eq 0 ] && [ $format_status -eq 0 ] && [ $knip_status -eq 0 ] && [ $checker_status -eq 0 ]; then
182
259
  printf "${GREEN}✓ All checks passed${NC}\n"
183
260
  exit 0
184
261
  else
@@ -75,7 +75,7 @@ if (existsSync('docs')) {
75
75
  }
76
76
 
77
77
  // Scan for fixtures/** and expected/** directories anywhere in the tree (up to 3 levels)
78
- const scanDirs = ['tests', 'test', 'src'];
78
+ const scanDirs = ['specs', 'tests', 'test', 'src'];
79
79
  for (const root of scanDirs) {
80
80
  if (!existsSync(root)) {
81
81
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "6.1.1",
3
+ "version": "6.2.0",
4
4
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,10 @@
23
23
  "types": "./src/index.d.ts",
24
24
  "default": "./src/index.js"
25
25
  },
26
+ "./oxlint": {
27
+ "types": "./src/oxlint.d.ts",
28
+ "default": "./src/oxlint.js"
29
+ },
26
30
  "./tsconfig/*": "./presets/tsconfig/*.json",
27
31
  "./tsconfig/*.json": "./presets/tsconfig/*.json",
28
32
  "./tsdown/*": "./presets/tsdown/*.js",
@@ -52,7 +56,7 @@
52
56
  "typescript": "^6.0.0"
53
57
  },
54
58
  "devDependencies": {
55
- "@jterrazz/test": "^8.0.0",
59
+ "@jterrazz/test": "^9.0.0",
56
60
  "@types/node": "^26.1.1",
57
61
  "vitest": "^4.1.10"
58
62
  },
@@ -4,6 +4,19 @@ import { defineConfig } from 'oxlint';
4
4
  const require = createRequire(import.meta.url);
5
5
  const perfectionistPath = require.resolve('eslint-plugin-perfectionist');
6
6
 
7
+ /*
8
+ * Base lint preset — tooling rules only. Wiring is EXPLICIT: this preset never
9
+ * auto-detects other jterrazz packages. A project using @jterrazz/test composes
10
+ * its `testing` fragment itself:
11
+ *
12
+ * import { testing } from '@jterrazz/test/oxlint';
13
+ * import { compose, node } from '@jterrazz/typescript/oxlint';
14
+ *
15
+ * export default compose(node, testing);
16
+ *
17
+ * (The @jterrazz/test plugin is ESM-only: a CommonJS oxlint config silently
18
+ * drops it — `typescript check` still warns loudly about that pitfall.)
19
+ */
7
20
  export default defineConfig({
8
21
  plugins: ['typescript', 'import', 'oxc', 'unicorn'],
9
22
  jsPlugins: [perfectionistPath],
@@ -0,0 +1,15 @@
1
+ type ConfigObject = Record<string, unknown>;
2
+
3
+ declare const expo: ConfigObject;
4
+ declare const hexagonal: ConfigObject;
5
+ declare const next: ConfigObject;
6
+ declare const node: ConfigObject;
7
+
8
+ /**
9
+ * Deterministic merge of oxlint config fragments, left to right:
10
+ * jsPlugins/plugins/ignorePatterns/extends concatenated + deduped,
11
+ * rules/categories shallow-merged (last wins), overrides concatenated.
12
+ */
13
+ declare function compose(...fragments: ConfigObject[]): ConfigObject;
14
+
15
+ export { compose, expo, hexagonal, next, node };
package/src/oxlint.js ADDED
@@ -0,0 +1,56 @@
1
+ /*
2
+ * The tool-facing oxlint entry (`@jterrazz/typescript/oxlint`): the named
3
+ * presets plus the `compose()` helper. Wiring is EXPLICIT — a consumer
4
+ * composes exactly the fragments it wants, nothing is auto-detected:
5
+ *
6
+ * import { testing } from '@jterrazz/test/oxlint';
7
+ * import { compose, node } from '@jterrazz/typescript/oxlint';
8
+ *
9
+ * export default compose(node, testing);
10
+ */
11
+
12
+ /** Config keys concatenated across fragments, duplicates dropped (===). */
13
+ const CONCAT_DEDUPE = new Set(['extends', 'ignorePatterns', 'jsPlugins', 'plugins']);
14
+ /** Config keys concatenated verbatim (order matters, no dedupe). */
15
+ const CONCAT = new Set(['overrides']);
16
+ /** Config keys shallow-merged as objects — the LAST fragment wins per key. */
17
+ const SHALLOW_MERGE = new Set(['categories', 'env', 'globals', 'rules', 'settings']);
18
+
19
+ /**
20
+ * Deterministic merge of oxlint config fragments, left to right:
21
+ * `jsPlugins` / `plugins` / `ignorePatterns` / `extends` are concatenated and
22
+ * deduped, `rules` / `categories` (and env/globals/settings) are shallow-merged
23
+ * with last-wins per key, `overrides` are concatenated, and any other key is
24
+ * taken from the last fragment that sets it.
25
+ */
26
+ export function compose(...fragments) {
27
+ const merged = {};
28
+ for (const fragment of fragments) {
29
+ if (!fragment || typeof fragment !== 'object') {
30
+ continue;
31
+ }
32
+ for (const [key, value] of Object.entries(fragment)) {
33
+ if (value === undefined) {
34
+ continue;
35
+ }
36
+ if (CONCAT_DEDUPE.has(key)) {
37
+ const previous = Array.isArray(merged[key]) ? merged[key] : [];
38
+ const combined = [...previous, ...(Array.isArray(value) ? value : [value])];
39
+ merged[key] = combined.filter((entry, index) => combined.indexOf(entry) === index);
40
+ } else if (CONCAT.has(key)) {
41
+ const previous = Array.isArray(merged[key]) ? merged[key] : [];
42
+ merged[key] = [...previous, ...(Array.isArray(value) ? value : [value])];
43
+ } else if (SHALLOW_MERGE.has(key)) {
44
+ merged[key] = { ...merged[key], ...value };
45
+ } else {
46
+ merged[key] = value;
47
+ }
48
+ }
49
+ }
50
+ return merged;
51
+ }
52
+
53
+ export { default as hexagonal } from '../presets/oxlint/architectures/hexagonal.js';
54
+ export { default as expo } from '../presets/oxlint/expo.js';
55
+ export { default as next } from '../presets/oxlint/next.js';
56
+ export { default as node } from '../presets/oxlint/node.js';
@@ -0,0 +1,79 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import { compose, expo, hexagonal, next, node } from './oxlint.js';
4
+
5
+ test('concatenates and dedupes plugin lists', () => {
6
+ // Given - two fragments sharing one jsPlugin
7
+ const merged = compose(
8
+ { jsPlugins: ['a', 'b'], plugins: ['typescript'] },
9
+ { jsPlugins: ['b', 'c'], plugins: ['typescript', 'import'] },
10
+ );
11
+
12
+ // Then - order preserved, duplicates dropped
13
+ expect(merged.jsPlugins).toEqual(['a', 'b', 'c']);
14
+ expect(merged.plugins).toEqual(['typescript', 'import']);
15
+ });
16
+
17
+ test('shallow-merges rules with last fragment winning', () => {
18
+ // Given - two fragments disagreeing on one rule
19
+ const merged = compose(
20
+ { rules: { curly: 'error', 'no-ternary': 'off' } },
21
+ { rules: { curly: 'off', 'jterrazz/b4-given-then': 'error' } },
22
+ );
23
+
24
+ // Then - the later fragment wins per key, others survive
25
+ expect(merged.rules).toEqual({
26
+ curly: 'off',
27
+ 'jterrazz/b4-given-then': 'error',
28
+ 'no-ternary': 'off',
29
+ });
30
+ });
31
+
32
+ test('concatenates overrides in order without deduping', () => {
33
+ // Given - two fragments each shipping an override
34
+ const first = { files: ['**/*.specification.ts'], rules: {} };
35
+ const second = { files: ['src/**'], rules: {} };
36
+ const merged = compose({ overrides: [first] }, { overrides: [second] });
37
+
38
+ // Then - both overrides survive, in composition order
39
+ expect(merged.overrides).toEqual([first, second]);
40
+ });
41
+
42
+ test('concatenates and dedupes ignorePatterns', () => {
43
+ // Given - overlapping ignore lists
44
+ const merged = compose(
45
+ { ignorePatterns: ['dist/**', 'node_modules/**'] },
46
+ { ignorePatterns: ['node_modules/**', '**/fixtures/**'] },
47
+ );
48
+
49
+ // Then - one entry each
50
+ expect(merged.ignorePatterns).toEqual(['dist/**', 'node_modules/**', '**/fixtures/**']);
51
+ });
52
+
53
+ test('takes unknown scalar keys from the last fragment', () => {
54
+ // Given - fragments disagreeing on a scalar key
55
+ const merged = compose({ somethingElse: 1 }, { somethingElse: 2 });
56
+
57
+ // Then - last wins
58
+ expect(merged.somethingElse).toBe(2);
59
+ });
60
+
61
+ test('ignores null and undefined fragments', () => {
62
+ // Given - a composition with holes (conditional fragments)
63
+ const merged = compose(
64
+ undefined as unknown as Record<string, unknown>,
65
+ { rules: { curly: 'error' } },
66
+ null as unknown as Record<string, unknown>,
67
+ );
68
+
69
+ // Then - the holes contribute nothing
70
+ expect(merged.rules).toEqual({ curly: 'error' });
71
+ });
72
+
73
+ test('exports the named presets', () => {
74
+ // Given - the tool-facing entry
75
+ // Then - every preset is a config object
76
+ for (const preset of [node, expo, next, hexagonal]) {
77
+ expect(typeof preset).toBe('object');
78
+ }
79
+ });