@nathapp/nax 0.18.1 → 0.18.2

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/.gitlab-ci.yml CHANGED
@@ -80,10 +80,10 @@ release:
80
80
  # --- Stage: Notify ---
81
81
  notify:
82
82
  stage: notify
83
- image: nathapp/node-bun:22.21.0-1.3.9-alpine
83
+ image: registry-intl.cn-hongkong.aliyuncs.com/gkci/node:22.14.0-alpine-ci
84
84
  needs: [release]
85
85
  script:
86
- - VERSION=$(bun -e "console.log(require('./package.json').version)")
86
+ - VERSION=$(node -e "console.log(require('./package.json').version)")
87
87
  - 'curl -s -X POST -H "Content-Type: application/json" -d "{\"chat_id\": \"$TELEGRAM_CHAT_ID\", \"text\": \"nax v${VERSION} released\"}" https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage'
88
88
  rules:
89
89
  - if: '$CI_COMMIT_MESSAGE =~ /release-by-bot/ || $CI_COMMIT_TAG'
package/docs/ROADMAP.md CHANGED
@@ -24,32 +24,29 @@
24
24
 
25
25
  ---
26
26
 
27
- ## v0.18.1 — Type Safety + Per-Story testStrategy
27
+ ## v0.18.1 — Type Safety + CI Pipeline ✅
28
28
 
29
- **Theme:** Fix all TypeScript/lint errors + fine-grained test strategy control
30
- **Status:** 🔲 Planned
29
+ **Theme:** Fix all TypeScript/lint errors, establish CI pipeline
30
+ **Status:** Shipped (2026-03-03)
31
31
 
32
32
  ### TypeScript Fixes (60 errors across 21 files)
33
- - [ ] **TS-001:** Fix context module exports — add `BuiltContext`, `ContextElement`, `ContextBudget`, `StoryContext` to `context/types.ts` (13 errors)
34
- - [ ] **TS-002:** Fix config/command type safety — type `{}` → proper types in `config/loader.ts`, `commands/logs.ts`, `agents/claude.ts` (12 errors)
35
- - [ ] **TS-003:** Fix review/verification types — add `softViolations`, `warnings`, `description` to review result types (9 errors)
36
- - [ ] **TS-004:** Fix escalation PRD type construction — ensure escalation produces valid `PRD` objects (4 errors)
37
- - [ ] **TS-005:** Fix misc — Logger mock types, null checks, missing exports (`RectificationState`, `TestSummary`, `TestFailure`) (6 errors)
38
-
39
- ### Lint Fixes (12 errors)
40
- - [ ] **LINT-001:** Run `biome check --fix` + manual review of unsafe fixes
41
-
42
- ### Verify Stage Fix
43
- - [ ] **TEST-001:** Fix hanging "test command that throws error" test — add timeout or proper process kill
44
-
45
- ### Per-Story testStrategy
46
- - [ ] Add optional `testStrategy` field to userStory PRD schema (`"test-after" | "three-session-tdd" | "three-session-tdd-lite"`)
47
- - [ ] When set, overrides global config + task classification for that story
48
- - [ ] Update routing stage to check `story.testStrategy` before config/LLM
49
- - [ ] Docs + tests
50
-
51
- ### Re-enable Checks
52
- - [ ] Re-enable `typecheck` in `nax/config.json` review checks after TS fixes land
33
+ - [x] ~~**TS-001:** Fix context module exports (13 errors)~~
34
+ - [x] ~~**TS-002:** Fix config/command type safety (12 errors)~~
35
+ - [x] ~~**TS-003:** Fix review/verification types (9 errors)~~
36
+ - [x] ~~**TS-004:** Fix escalation PRD type construction (4 errors)~~
37
+ - [x] ~~**TS-005:** Fix misc types (6 errors)~~
38
+ - [x] ~~**LINT-001:** Run biome check --fix + manual review~~
39
+
40
+ ### CI Pipeline (new)
41
+ - [x] `.gitlab-ci.yml` — stages: test → release → notify
42
+ - [x] Image: `nathapp/node-bun:22.21.0-1.3.9-alpine` (test/release), `gkci/node:22.14.0-alpine-ci` (notify)
43
+ - [x] `before_script`: apk add git python3 make g++, safe.directory, git identity
44
+ - [x] Test env: `NAX_SKIP_PRECHECK=1 bun test test/ --timeout=60000`
45
+ - [x] CI skip guards for env-sensitive tests (claude binary, PID checks, subprocess integration)
46
+ - [x] Fixed `checkClaudeCLI()` ENOENT crash try/catch around Bun.spawn
47
+ - [x] Release trigger: `[run-release]` in commit message on master
48
+ - [x] Runner requirement: 8GB shared runner (`saas-linux-small-amd64`)
49
+ - [x] **Result: 1952 pass, 56 skip, 0 fail**
53
50
 
54
51
  ---
55
52
 
@@ -96,6 +93,7 @@
96
93
 
97
94
  | Version | Theme | Date | Details |
98
95
  |:---|:---|:---|:---|
96
+ | v0.18.1 | Type Safety + CI Pipeline | 2026-03-03 | 60 TS errors + 12 lint errors fixed, GitLab CI green (1952/56/0) |
99
97
  | v0.18.0 | Orchestration Quality | 2026-03-03 | BUG-016/017/018/019/020/021/022/023/025 all fixed |
100
98
  | v0.17.0 | Config Management | 2026-03-02 | CM-001 --explain, CM-002 --diff, CM-003 default view |
101
99
  | v0.16.4 | Bugfixes: Routing + Env Allowlist | 2026-03-02 | BUG-012/013/014 |
package/nax/config.json CHANGED
@@ -63,11 +63,14 @@
63
63
  "verificationTimeoutSeconds": 300,
64
64
  "maxStoriesPerFeature": 15,
65
65
  "rectification": {
66
- "enabled": true,
66
+ "enabled": false,
67
67
  "maxRetries": 2,
68
- "fullSuiteTimeoutSeconds": 120,
68
+ "fullSuiteTimeoutSeconds": 600,
69
69
  "maxFailureSummaryChars": 2000,
70
70
  "abortOnIncreasingFailures": true
71
+ },
72
+ "regressionGate": {
73
+ "enabled": false
71
74
  }
72
75
  },
73
76
  "quality": {
@@ -75,9 +78,9 @@
75
78
  "requireLint": true,
76
79
  "requireTests": true,
77
80
  "commands": {
78
- "test": "bun test --no-coverage",
79
- "typecheck": "bun x tsc --noEmit",
80
- "lint": "bun x biome check src/ bin/"
81
+ "test": "bun run test",
82
+ "typecheck": "bun run typecheck",
83
+ "lint": "bun run lint"
81
84
  },
82
85
  "forceExit": false,
83
86
  "detectOpenHandles": true,
@@ -114,15 +117,15 @@
114
117
  "maxCodebaseSummaryTokens": 5000
115
118
  },
116
119
  "review": {
117
- "enabled": true,
120
+ "enabled": false,
118
121
  "checks": [
119
122
  "test",
120
123
  "lint"
121
124
  ],
122
125
  "commands": {
123
- "test": "bun test --no-coverage",
124
- "typecheck": "bun x tsc --noEmit",
125
- "lint": "bun x biome check src/ bin/"
126
+ "test": "bun run test",
127
+ "typecheck": "bun run typecheck",
128
+ "lint": "bun run lint"
126
129
  }
127
130
  },
128
131
  "plan": {
@@ -0,0 +1,7 @@
1
+ # Plan: smart-test-runner
2
+
3
+ ## Architecture
4
+
5
+ ## Phases
6
+
7
+ ## Dependencies
@@ -0,0 +1,203 @@
1
+ {
2
+ "project": "nax",
3
+ "feature": "smart-test-runner",
4
+ "branchName": "feat/v0.18.2-smart-runner",
5
+ "userStories": [
6
+ {
7
+ "id": "FIX-001",
8
+ "title": "Fix partial routing override in routing stage",
9
+ "description": "In `src/pipeline/stages/routing.ts`, the code unconditionally overwrites complexity and testStrategy from story.routing, even if undefined. Fix: only override when the field is actually set. Change line 41 `routing.complexity = ctx.story.routing.complexity` to `if (ctx.story.routing?.complexity) routing.complexity = ctx.story.routing.complexity`. Same for testStrategy on the next line. This allows PRD authors to set just `\"routing\": { \"testStrategy\": \"test-after\" }` without needing complexity/modelTier. Add test in `test/unit/pipeline/routing-partial-override.test.ts` verifying: (1) partial override with only testStrategy works, (2) LLM-classified complexity is preserved when not overridden, (3) full override still works.",
10
+ "complexity": "simple",
11
+ "status": "passed",
12
+ "tags": [
13
+ "bugfix"
14
+ ],
15
+ "acceptanceCriteria": [
16
+ "routing.complexity is only overridden when ctx.story.routing.complexity is defined",
17
+ "routing.testStrategy is only overridden when ctx.story.routing.testStrategy is defined",
18
+ "PRD with only routing.testStrategy set uses LLM for complexity",
19
+ "Test file test/unit/pipeline/routing-partial-override.test.ts exists with 3 passing tests"
20
+ ],
21
+ "dependencies": [],
22
+ "escalations": [],
23
+ "attempts": 1,
24
+ "passes": true,
25
+ "routing": {
26
+ "testStrategy": "test-after"
27
+ },
28
+ "priorErrors": [],
29
+ "storyPoints": 1
30
+ },
31
+ {
32
+ "id": "STR-001",
33
+ "title": "Git diff file detection",
34
+ "description": "Create module `src/verification/smart-runner.ts` with exported function `getChangedSourceFiles(workdir: string): Promise<string[]>`. Runs `git diff --name-only HEAD~1` in the workdir to get changed files. Filter to only `.ts` files that start with `src/`. Use Bun.spawn for the git command. Handle git errors gracefully (return empty array on failure).",
35
+ "complexity": "simple",
36
+ "status": "passed",
37
+ "tags": [
38
+ "core"
39
+ ],
40
+ "acceptanceCriteria": [
41
+ "getChangedSourceFiles returns only .ts files under src/",
42
+ "Returns empty array when git command fails",
43
+ "Uses Bun.spawn (not child_process)"
44
+ ],
45
+ "dependencies": [
46
+ "FIX-001"
47
+ ],
48
+ "escalations": [],
49
+ "attempts": 0,
50
+ "passes": true,
51
+ "routing": {
52
+ "testStrategy": "test-after",
53
+ "modelTier": "powerful"
54
+ },
55
+ "priorErrors": [
56
+ "REGRESSION: full-suite regression detected",
57
+ "Attempt 2 failed with model tier: fast",
58
+ "Attempt 1 failed with model tier: balanced"
59
+ ],
60
+ "storyPoints": 1
61
+ },
62
+ {
63
+ "id": "STR-002",
64
+ "title": "Source-to-test file mapping",
65
+ "description": "Add `mapSourceToTests(sourceFiles: string[], workdir: string): Promise<string[]>` to `src/verification/smart-runner.ts`. Convention: `src/foo/bar.ts` → `test/unit/foo/bar.test.ts`. Also check `test/integration/foo/bar.test.ts`. Only return paths where the file actually exists on disk (use Bun.file(path).exists()). Return empty array if no test files map.",
66
+ "complexity": "simple",
67
+ "status": "passed",
68
+ "tags": [
69
+ "core"
70
+ ],
71
+ "acceptanceCriteria": [
72
+ "Maps src/foo/bar.ts to test/unit/foo/bar.test.ts correctly",
73
+ "Also checks test/integration/ path",
74
+ "Only returns files that exist on disk",
75
+ "Returns empty array when no test files match"
76
+ ],
77
+ "dependencies": [
78
+ "STR-001"
79
+ ],
80
+ "escalations": [],
81
+ "attempts": 0,
82
+ "passes": true,
83
+ "routing": {
84
+ "testStrategy": "test-after"
85
+ },
86
+ "priorErrors": [],
87
+ "storyPoints": 1
88
+ },
89
+ {
90
+ "id": "STR-003",
91
+ "title": "Smart test command builder",
92
+ "description": "Add `buildSmartTestCommand(testFiles: string[], baseCommand: string): string` to `src/verification/smart-runner.ts`. When testFiles is non-empty: replace the last path argument in baseCommand with the specific test file paths joined by spaces. E.g., `bun test test/` with files `[test/unit/foo.test.ts]` → `bun test test/unit/foo.test.ts`. When testFiles is empty: return the original baseCommand unchanged (full suite fallback).",
93
+ "complexity": "simple",
94
+ "status": "passed",
95
+ "tags": [
96
+ "core"
97
+ ],
98
+ "acceptanceCriteria": [
99
+ "Builds scoped command with specific test files",
100
+ "Returns original command when testFiles is empty",
101
+ "Works with bun test test/ base command"
102
+ ],
103
+ "dependencies": [
104
+ "STR-002"
105
+ ],
106
+ "escalations": [],
107
+ "attempts": 0,
108
+ "passes": true,
109
+ "routing": {
110
+ "testStrategy": "test-after"
111
+ },
112
+ "priorErrors": [],
113
+ "storyPoints": 1
114
+ },
115
+ {
116
+ "id": "STR-004",
117
+ "title": "Smart runner config flag",
118
+ "description": "Add `execution.smartTestRunner: boolean` (default: true) to the config in `src/config/types.ts` (ExecutionConfig interface) and the Zod schema in `src/config/loader.ts`. When false, the verify stage skips smart runner entirely and uses the full test suite command.",
119
+ "complexity": "simple",
120
+ "status": "passed",
121
+ "tags": [
122
+ "config"
123
+ ],
124
+ "acceptanceCriteria": [
125
+ "execution.smartTestRunner field exists in ExecutionConfig interface",
126
+ "Zod schema accepts and defaults to true",
127
+ "Config loads correctly with and without the field"
128
+ ],
129
+ "dependencies": [
130
+ "STR-003"
131
+ ],
132
+ "escalations": [],
133
+ "attempts": 0,
134
+ "passes": true,
135
+ "routing": {
136
+ "testStrategy": "test-after"
137
+ },
138
+ "priorErrors": [],
139
+ "storyPoints": 1
140
+ },
141
+ {
142
+ "id": "STR-005",
143
+ "title": "Integrate smart runner into verify stage",
144
+ "description": "Modify `src/pipeline/stages/verify.ts` to use smart runner when `ctx.config.execution.smartTestRunner` is true (default). Before calling regression(): call getChangedSourceFiles(ctx.workdir) → mapSourceToTests(files, ctx.workdir) → buildSmartTestCommand(testFiles, testCommand). Use the result as the command for regression(). Log which mode was selected: `[smart-runner] Running N targeted test files` or `[smart-runner] No mapped tests — falling back to full suite`.",
145
+ "complexity": "medium",
146
+ "status": "passed",
147
+ "tags": [
148
+ "integration"
149
+ ],
150
+ "acceptanceCriteria": [
151
+ "Verify stage uses scoped test command when smart runner finds test files",
152
+ "Falls back to full suite when no test files map",
153
+ "Skips smart runner entirely when config.execution.smartTestRunner is false",
154
+ "Logs the mode used"
155
+ ],
156
+ "dependencies": [
157
+ "STR-004"
158
+ ],
159
+ "escalations": [],
160
+ "attempts": 1,
161
+ "passes": true,
162
+ "routing": {
163
+ "testStrategy": "test-after",
164
+ "modelTier": "powerful"
165
+ },
166
+ "priorErrors": [
167
+ "Attempt 1 failed with model tier: fast",
168
+ "Attempt 1 failed with model tier: balanced"
169
+ ],
170
+ "storyPoints": 1
171
+ },
172
+ {
173
+ "id": "STR-006",
174
+ "title": "Unit tests for smart runner module",
175
+ "description": "Write `test/unit/verification/smart-runner.test.ts` with tests: (1) getChangedSourceFiles filters to .ts src/ files only, (2) mapSourceToTests maps paths correctly and checks file existence, (3) mapSourceToTests returns empty when files don't exist, (4) buildSmartTestCommand builds scoped command with test files, (5) buildSmartTestCommand returns original command when testFiles is empty. Use temp dirs with Bun.write for file existence tests.",
176
+ "complexity": "medium",
177
+ "status": "failed",
178
+ "tags": [
179
+ "test"
180
+ ],
181
+ "acceptanceCriteria": [
182
+ "5 test cases all pass",
183
+ "Uses temp dirs for file existence tests",
184
+ "No external process spawning in unit tests (mock Bun.spawn)"
185
+ ],
186
+ "dependencies": [
187
+ "STR-005"
188
+ ],
189
+ "escalations": [],
190
+ "attempts": 1,
191
+ "passes": false,
192
+ "routing": {
193
+ "testStrategy": "test-after",
194
+ "modelTier": "powerful"
195
+ },
196
+ "priorErrors": [
197
+ "Attempt 1 failed with model tier: balanced"
198
+ ],
199
+ "storyPoints": 1
200
+ }
201
+ ],
202
+ "updatedAt": "2026-03-03T14:17:09.852Z"
203
+ }
@@ -0,0 +1,13 @@
1
+ # Progress: smart-test-runner
2
+
3
+ Created: 2026-03-03T11:45:10.257Z
4
+
5
+ ---
6
+ [2026-03-03T12:12:58.473Z] FIX-001 — FAILED — Fix partial routing override in routing stage — Review failed: test failed (exit code 132)
7
+ [2026-03-03T12:27:31.803Z] STR-001 — PASSED — Git diff file detection — Cost: $0.0994
8
+ [2026-03-03T12:29:37.971Z] STR-001 — FAILED — Git diff file detection -- REGRESSION: full-suite regression detected
9
+ [2026-03-03T12:52:43.615Z] STR-002 — PASSED — Source-to-test file mapping — Cost: $0.0889
10
+ [2026-03-03T12:59:34.497Z] STR-003 — PASSED — Smart test command builder — Cost: $0.0862
11
+ [2026-03-03T13:05:48.251Z] STR-004 — PASSED — Smart runner config flag — Cost: $0.1583
12
+ [2026-03-03T13:21:09.123Z] STR-005 — FAILED — Integrate smart runner into verify stage — Execution failed
13
+ [2026-03-03T14:17:09.853Z] STR-006 — FAILED — Unit tests for smart runner module — Execution failed
@@ -0,0 +1,7 @@
1
+ # Feature: smart-test-runner
2
+
3
+ ## Overview
4
+
5
+ ## Requirements
6
+
7
+ ## Acceptance Criteria
@@ -0,0 +1,8 @@
1
+ # Tasks: smart-test-runner
2
+
3
+ ## US-001: [Title]
4
+
5
+ ### Description
6
+
7
+ ### Acceptance Criteria
8
+ - [ ] Criterion 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,6 +68,7 @@ export const DEFAULT_CONFIG: NaxConfig = {
68
68
  timeoutSeconds: 120,
69
69
  },
70
70
  contextProviderTokenBudget: 2000,
71
+ smartTestRunner: true,
71
72
  },
72
73
  quality: {
73
74
  requireTypecheck: true,
@@ -81,6 +81,7 @@ const ExecutionConfigSchema = z.object({
81
81
  lintCommand: z.string().nullable().optional(),
82
82
  typecheckCommand: z.string().nullable().optional(),
83
83
  dangerouslySkipPermissions: z.boolean().default(true),
84
+ smartTestRunner: z.boolean().default(true),
84
85
  });
85
86
 
86
87
  const QualityConfigSchema = z.object({
@@ -106,6 +106,8 @@ export interface ExecutionConfig {
106
106
  typecheckCommand?: string | null;
107
107
  /** Use --dangerously-skip-permissions flag for agent (default: true for backward compat, SEC-1 fix) */
108
108
  dangerouslySkipPermissions?: boolean;
109
+ /** Enable smart test runner to scope test runs to changed files (default: true) */
110
+ smartTestRunner?: boolean;
109
111
  }
110
112
 
111
113
  /** Quality gate config */
@@ -36,10 +36,10 @@ export const routingStage: PipelineStage = {
36
36
  if (ctx.story.routing) {
37
37
  // Use cached complexity/testStrategy, but re-derive modelTier from current config
38
38
  routing = await routeStory(ctx.story, { config: ctx.config }, ctx.workdir, ctx.plugins);
39
- // Override with cached complexity if available
40
- routing.complexity = ctx.story.routing.complexity;
41
- routing.testStrategy = ctx.story.routing.testStrategy;
42
- // Re-derive modelTier from cached complexity and current config
39
+ // Override with cached values only when they are actually set
40
+ if (ctx.story.routing?.complexity) routing.complexity = ctx.story.routing.complexity;
41
+ if (ctx.story.routing?.testStrategy) routing.testStrategy = ctx.story.routing.testStrategy;
42
+ // Re-derive modelTier from (possibly overridden) complexity and current config
43
43
  routing.modelTier = complexityToModelTier(routing.complexity as import("../../config").Complexity, ctx.config);
44
44
  } else {
45
45
  // Fresh classification
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { getLogger } from "../../logger";
24
24
  import { regression } from "../../verification/gate";
25
+ import { _smartRunnerDeps } from "../../verification/smart-runner";
25
26
  import type { PipelineContext, PipelineStage, StageResult } from "../types";
26
27
 
27
28
  export const verifyStage: PipelineStage = {
@@ -46,10 +47,28 @@ export const verifyStage: PipelineStage = {
46
47
 
47
48
  logger.info("verify", "Running verification", { storyId: ctx.story.id });
48
49
 
50
+ // Determine effective test command (smart runner or full suite)
51
+ let effectiveCommand = testCommand;
52
+ const smartRunnerEnabled = ctx.config.execution.smartTestRunner !== false;
53
+
54
+ if (smartRunnerEnabled) {
55
+ const sourceFiles = await _smartRunnerDeps.getChangedSourceFiles(ctx.workdir);
56
+ const testFiles = await _smartRunnerDeps.mapSourceToTests(sourceFiles, ctx.workdir);
57
+
58
+ if (testFiles.length > 0) {
59
+ effectiveCommand = _smartRunnerDeps.buildSmartTestCommand(testFiles, testCommand);
60
+ logger.info("verify", `[smart-runner] Running ${testFiles.length} targeted test files`, {
61
+ storyId: ctx.story.id,
62
+ });
63
+ } else {
64
+ logger.info("verify", "[smart-runner] No mapped tests — falling back to full suite", { storyId: ctx.story.id });
65
+ }
66
+ }
67
+
49
68
  // Use unified regression gate (includes 2s wait for agent process cleanup)
50
69
  const result = await regression({
51
70
  workdir: ctx.workdir,
52
- command: testCommand,
71
+ command: effectiveCommand,
53
72
  timeoutSeconds: ctx.config.execution.verificationTimeoutSeconds,
54
73
  });
55
74
 
@@ -62,6 +62,8 @@ export interface PrecheckOptions {
62
62
  format?: "human" | "json";
63
63
  /** Working directory */
64
64
  workdir: string;
65
+ /** Suppress console output (for programmatic use) */
66
+ silent?: boolean;
65
67
  }
66
68
 
67
69
  /** Extended result with exit code for CLI usage */
@@ -87,6 +89,7 @@ export async function runPrecheck(
87
89
  ): Promise<PrecheckResultWithCode> {
88
90
  const workdir = options?.workdir || process.cwd();
89
91
  const format = options?.format || "human";
92
+ const silent = options?.silent ?? false;
90
93
 
91
94
  const passed: Check[] = [];
92
95
  const blockers: Check[] = [];
@@ -196,10 +199,12 @@ export async function runPrecheck(
196
199
  exitCode = hasPRDError ? EXIT_CODES.INVALID_PRD : EXIT_CODES.BLOCKER;
197
200
  }
198
201
 
199
- if (format === "json") {
200
- console.log(JSON.stringify(output, null, 2));
201
- } else {
202
- printSummary(output);
202
+ if (!silent) {
203
+ if (format === "json") {
204
+ console.log(JSON.stringify(output, null, 2));
205
+ } else {
206
+ printSummary(output);
207
+ }
203
208
  }
204
209
 
205
210
  return {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Smart Runner — Git diff file detection
3
+ *
4
+ * Detects changed TypeScript source files using git diff,
5
+ * enabling targeted test runs on only the files that changed.
6
+ */
7
+
8
+ /**
9
+ * Get TypeScript source files changed since the previous commit.
10
+ *
11
+ * Runs `git diff --name-only HEAD~1` in the given workdir and filters
12
+ * results to only `.ts` files under `src/`. Returns an empty array on
13
+ * any git error (not a repo, no previous commit, etc.).
14
+ *
15
+ * @param workdir - Working directory to run git command in
16
+ * @returns Array of changed .ts file paths relative to the repo root
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const files = await getChangedSourceFiles("/path/to/repo");
21
+ * // Returns: ["src/foo/bar.ts", "src/utils/git.ts"]
22
+ * ```
23
+ */
24
+ /**
25
+ * Map source files to their corresponding test files.
26
+ *
27
+ * For each file in `sourceFiles`, checks both:
28
+ * - `<workdir>/test/unit/<relative-path>.test.ts`
29
+ * - `<workdir>/test/integration/<relative-path>.test.ts`
30
+ *
31
+ * where `<relative-path>` is the file path with the leading `src/` stripped
32
+ * and the `.ts` extension replaced with `.test.ts`.
33
+ *
34
+ * Only returns paths that actually exist on disk.
35
+ *
36
+ * @param sourceFiles - Array of source file paths (e.g. `["src/foo/bar.ts"]`)
37
+ * @param workdir - Absolute path to the repository root
38
+ * @returns Existing test file paths (absolute)
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const tests = await mapSourceToTests(["src/foo/bar.ts"], "/repo");
43
+ * // Returns: ["/repo/test/unit/foo/bar.test.ts"] (if it exists)
44
+ * ```
45
+ */
46
+ export async function mapSourceToTests(sourceFiles: string[], workdir: string): Promise<string[]> {
47
+ const result: string[] = [];
48
+
49
+ for (const sourceFile of sourceFiles) {
50
+ // Strip leading "src/" and replace ".ts" with ".test.ts"
51
+ const relative = sourceFile.replace(/^src\//, "").replace(/\.ts$/, ".test.ts");
52
+
53
+ const candidates = [`${workdir}/test/unit/${relative}`, `${workdir}/test/integration/${relative}`];
54
+
55
+ for (const candidate of candidates) {
56
+ if (await Bun.file(candidate).exists()) {
57
+ result.push(candidate);
58
+ }
59
+ }
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * Build a scoped test command targeting specific test files.
67
+ *
68
+ * When `testFiles` is non-empty, replaces the last path-like argument in
69
+ * `baseCommand` (a token containing `/`) with the specific test file paths
70
+ * joined by spaces. If no path argument is found, appends the test files.
71
+ *
72
+ * When `testFiles` is empty, returns `baseCommand` unchanged (full-suite
73
+ * fallback).
74
+ *
75
+ * @param testFiles - Test file paths to scope the run to
76
+ * @param baseCommand - Full test command (e.g. `"bun test test/"`)
77
+ * @returns Scoped command string
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * buildSmartTestCommand(["test/unit/foo.test.ts"], "bun test test/")
82
+ * // => "bun test test/unit/foo.test.ts"
83
+ *
84
+ * buildSmartTestCommand([], "bun test test/")
85
+ * // => "bun test test/"
86
+ * ```
87
+ */
88
+ export function buildSmartTestCommand(testFiles: string[], baseCommand: string): string {
89
+ if (testFiles.length === 0) {
90
+ return baseCommand;
91
+ }
92
+
93
+ const parts = baseCommand.trim().split(/\s+/);
94
+
95
+ // Find the last token that looks like a path (contains '/')
96
+ let lastPathIndex = -1;
97
+ for (let i = parts.length - 1; i >= 0; i--) {
98
+ if (parts[i].includes("/")) {
99
+ lastPathIndex = i;
100
+ break;
101
+ }
102
+ }
103
+
104
+ if (lastPathIndex === -1) {
105
+ // No path argument — append test files
106
+ return `${baseCommand} ${testFiles.join(" ")}`;
107
+ }
108
+
109
+ // Replace the last path argument with the specific test files
110
+ const newParts = [...parts.slice(0, lastPathIndex), ...testFiles];
111
+ return newParts.join(" ");
112
+ }
113
+
114
+ export async function getChangedSourceFiles(workdir: string): Promise<string[]> {
115
+ try {
116
+ const proc = Bun.spawn({
117
+ cmd: ["git", "diff", "--name-only", "HEAD~1"],
118
+ cwd: workdir,
119
+ stdout: "pipe",
120
+ stderr: "pipe",
121
+ });
122
+
123
+ const exitCode = await proc.exited;
124
+ if (exitCode !== 0) return [];
125
+
126
+ const stdout = await new Response(proc.stdout).text();
127
+ const lines = stdout.trim().split("\n").filter(Boolean);
128
+
129
+ return lines.filter((f) => f.startsWith("src/") && f.endsWith(".ts"));
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Injectable dependencies for testing.
137
+ * Allows tests to swap implementations without using mock.module(),
138
+ * which leaks across files in Bun 1.x due to shared module registry.
139
+ *
140
+ * @internal - test use only. Do not use in production code.
141
+ */
142
+ export const _smartRunnerDeps = {
143
+ getChangedSourceFiles,
144
+ mapSourceToTests,
145
+ buildSmartTestCommand,
146
+ };