@autotests/playwright-impact 0.1.2 → 0.1.4

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/LICENSE CHANGED
@@ -1,15 +1,21 @@
1
- ISC License
1
+ MIT License
2
2
 
3
3
  Copyright (c) 2026 Alex Kiselev
4
4
 
5
- Permission to use, copy, modify, and/or distribute this software for any
6
- purpose with or without fee is hereby granted, provided that the above
7
- copyright notice and this permission notice appear in all copies.
8
-
9
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
- ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
- ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
- OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -23,25 +23,27 @@ Create `impact.js` in your repo root:
23
23
  ```js
24
24
  const { analyzeImpactedSpecs } = require('@autotests/playwright-impact');
25
25
 
26
- const result = analyzeImpactedSpecs({
27
- repoRoot: process.cwd(),
28
- profile: {
29
- testsRootRelative: 'tests',
30
- changedSpecPrefix: 'tests/',
31
- isRelevantPomPath: (filePath) =>
32
- (filePath.startsWith('src/pages/') || filePath.startsWith('src/utils/')) &&
33
- (filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
34
- },
35
- });
36
-
37
- if (!result.hasAnythingToRun) {
38
- console.log('No impacted specs found');
39
- process.exit(0);
40
- }
41
-
42
- for (const spec of result.selectedSpecsRelative) {
43
- console.log(spec);
44
- }
26
+ (async () => {
27
+ const result = await analyzeImpactedSpecs({
28
+ repoRoot: process.cwd(),
29
+ profile: {
30
+ testsRootRelative: 'tests',
31
+ changedSpecPrefix: 'tests/',
32
+ isRelevantPomPath: (filePath) =>
33
+ (filePath.startsWith('src/pages/') || filePath.startsWith('src/utils/')) &&
34
+ (filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
35
+ },
36
+ });
37
+
38
+ if (!result.hasAnythingToRun) {
39
+ console.log('No impacted specs found');
40
+ process.exit(0);
41
+ }
42
+
43
+ for (const spec of result.selectedSpecsRelative) {
44
+ console.log(spec);
45
+ }
46
+ })();
45
47
  ```
46
48
 
47
49
  Save as `impact.js`
@@ -80,24 +82,26 @@ Use this when your branch is compared to `origin/main`.
80
82
  ```js
81
83
  const { analyzeImpactedSpecs } = require('@autotests/playwright-impact');
82
84
 
83
- const result = analyzeImpactedSpecs({
84
- repoRoot: process.cwd(),
85
- baseRef: 'origin/main',
86
- profile: {
87
- testsRootRelative: 'tests',
88
- changedSpecPrefix: 'tests/',
89
- isRelevantPomPath: (filePath) =>
90
- (filePath.startsWith('src/pages/') || filePath.startsWith('src/utils/')) &&
91
- (filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
92
- },
93
- });
94
-
95
- if (!result.hasAnythingToRun) {
96
- console.log('No impacted specs found');
97
- process.exit(0);
98
- }
99
-
100
- console.log(result.selectedSpecsRelative.join(' '));
85
+ (async () => {
86
+ const result = await analyzeImpactedSpecs({
87
+ repoRoot: process.cwd(),
88
+ baseRef: 'origin/main',
89
+ profile: {
90
+ testsRootRelative: 'tests',
91
+ changedSpecPrefix: 'tests/',
92
+ isRelevantPomPath: (filePath) =>
93
+ (filePath.startsWith('src/pages/') || filePath.startsWith('src/utils/')) &&
94
+ (filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
95
+ },
96
+ });
97
+
98
+ if (!result.hasAnythingToRun) {
99
+ console.log('No impacted specs found');
100
+ process.exit(0);
101
+ }
102
+
103
+ console.log(result.selectedSpecsRelative.join(' '));
104
+ })();
101
105
  ```
102
106
 
103
107
  ## Typical CI Usage
package/package.json CHANGED
@@ -1,16 +1,9 @@
1
1
  {
2
2
  "name": "@autotests/playwright-impact",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Core impacted-test selection library for changed POM/method analysis",
5
- "license": "ISC",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/akiselevaristek/playwright-impact.git"
9
- },
10
- "homepage": "https://github.com/akiselevaristek/playwright-impact#readme",
11
- "bugs": {
12
- "url": "https://github.com/akiselevaristek/playwright-impact/issues"
13
- },
5
+ "license": "MIT",
6
+ "repository": "github:akiselevaristek/playwright-impact",
14
7
  "main": "src/index.js",
15
8
  "exports": {
16
9
  ".": "./src/index.js"
@@ -18,7 +11,6 @@
18
11
  "types": "src/index.d.ts",
19
12
  "files": [
20
13
  "src",
21
- "tests",
22
14
  "README.md",
23
15
  "LICENSE"
24
16
  ],
@@ -38,5 +30,8 @@
38
30
  "devDependencies": {
39
31
  "@types/node": "^24.10.9",
40
32
  "typescript": "^5.9.2"
33
+ },
34
+ "dependencies": {
35
+ "isomorphic-git": "^1.37.2"
41
36
  }
42
37
  }
@@ -52,7 +52,7 @@ const normalizeFileExtensions = (fileExtensions) => {
52
52
  * 3) Stage A preselect specs by impacted fixture keys
53
53
  * 4) Stage B precise/uncertain method matching with selection bias policy
54
54
  */
55
- const analyzeImpactedSpecs = ({
55
+ const analyzeImpactedSpecs = async ({
56
56
  repoRoot,
57
57
  baseRef = null,
58
58
  profile,
@@ -74,7 +74,7 @@ const analyzeImpactedSpecs = ({
74
74
  : getDefaultGlobalWatchPatterns();
75
75
 
76
76
  // Stage 0: gather changed files and keep only profile-relevant subsets.
77
- const changedEntriesResult = getChangedEntries({
77
+ const changedEntriesResult = await getChangedEntries({
78
78
  repoRoot,
79
79
  baseRef,
80
80
  includeWorkingTreeWithBase,
@@ -104,7 +104,7 @@ const analyzeImpactedSpecs = ({
104
104
  .map((entry) => entry.effectivePath)
105
105
  .filter(Boolean);
106
106
  const untrackedSpecFiles = includeUntrackedSpecs
107
- ? getUntrackedSpecPaths({ repoRoot, changedSpecPrefix: profile.changedSpecPrefix, fileExtensions: effectiveExtensions })
107
+ ? await getUntrackedSpecPaths({ repoRoot, changedSpecPrefix: profile.changedSpecPrefix, fileExtensions: effectiveExtensions })
108
108
  : [];
109
109
  const directChangedSpecFiles = Array.from(new Set([...changedSpecFiles, ...untrackedSpecFiles])).sort((a, b) => a.localeCompare(b));
110
110
 
@@ -200,10 +200,20 @@ const analyzeImpactedSpecs = ({
200
200
 
201
201
  // Stage 1: semantic seed and callgraph propagation from changed POM entries.
202
202
  if (changedPomEntries.length > 0) {
203
+ const contentsByEntry = new Map();
204
+ for (const entry of changedPomEntries) {
205
+ const key = `${entry.status}:${entry.oldPath || ''}:${entry.newPath || ''}:${entry.effectivePath || ''}`;
206
+ contentsByEntry.set(key, await readChangeContents({ repoRoot, entry, baseRef }));
207
+ }
208
+ const readContentsFromCache = (entry) => {
209
+ const key = `${entry.status}:${entry.oldPath || ''}:${entry.newPath || ''}:${entry.effectivePath || ''}`;
210
+ return contentsByEntry.get(key) || { basePath: null, headPath: null, baseContent: null, headContent: null };
211
+ };
212
+
203
213
  const changedMethodsResult = collectChangedMethodsByClass({
204
214
  changedPomEntries,
205
215
  baseRef,
206
- readChangeContents: (entry, entryBaseRef) => readChangeContents({ repoRoot, entry, baseRef: entryBaseRef }),
216
+ readChangeContents: (entry) => readContentsFromCache(entry),
207
217
  });
208
218
 
209
219
  semanticStats = changedMethodsResult.stats;
@@ -214,7 +224,7 @@ const analyzeImpactedSpecs = ({
214
224
  changedPomEntries,
215
225
  childrenByParent,
216
226
  baseRef,
217
- readChangeContents: (entry, entryBaseRef) => readChangeContents({ repoRoot, entry, baseRef: entryBaseRef }),
227
+ readChangeContents: (entry) => readContentsFromCache(entry),
218
228
  });
219
229
 
220
230
  const impactedMethodsResult = buildImpactedMethodsByClass({
package/src/index.d.ts CHANGED
@@ -64,7 +64,7 @@ export type AnalyzeResult = {
64
64
  globalWatchResolvedFiles: string[];
65
65
  };
66
66
 
67
- export function analyzeImpactedSpecs(options: AnalyzeOptions): AnalyzeResult;
67
+ export function analyzeImpactedSpecs(options: AnalyzeOptions): Promise<AnalyzeResult>;
68
68
 
69
69
  export function formatSelectionReasonsForLog(args: {
70
70
  selectedSpecs: string[];
@@ -2,20 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { runCommand } = require('./shell');
5
+ const git = require('isomorphic-git');
6
6
 
7
7
  const STATUS_PRIORITY = { D: 4, R: 3, M: 2, A: 1 };
8
8
  const SUPPORTED_FILE_EXTENSIONS = new Set(['.ts', '.tsx']);
9
9
 
10
- const readFileFromBaseRef = ({ repoRoot, relativePath, baseRef }) => {
11
- if (!relativePath) return null;
12
- const effectiveBaseRef = String(baseRef || 'HEAD').trim();
13
- if (!effectiveBaseRef) return null;
14
- const showResult = runCommand('git', ['show', `${effectiveBaseRef}:${relativePath}`], { cwd: repoRoot });
15
- if (showResult.status !== 0 || showResult.error) return null;
16
- return typeof showResult.stdout === 'string' ? showResult.stdout : null;
17
- };
18
-
19
10
  const readFileFromWorkingTree = ({ repoRoot, relativePath }) => {
20
11
  if (!relativePath) return null;
21
12
  const absolutePath = path.join(repoRoot, relativePath);
@@ -74,27 +65,7 @@ const listFilesRecursive = (rootDir) => {
74
65
  return files;
75
66
  };
76
67
 
77
- const runDiffAndParse = ({ repoRoot, args }) => {
78
- // Use rename detection to keep semantic compare stable across pure file moves/renames.
79
- const result = runCommand('git', ['diff', '--name-status', '-M', ...args], { cwd: repoRoot });
80
- if (result.error) throw new Error(`Failed to execute git diff: ${result.error.message}`);
81
- if (result.status !== 0) {
82
- const details = (result.stderr || result.stdout || '').trim();
83
- throw new Error(`git diff failed with code ${result.status}: ${details}`);
84
- }
85
-
86
- return result.stdout
87
- .split(/\r?\n/)
88
- .map((line) => line.trim())
89
- .filter(Boolean)
90
- .map(parseChangedEntryLine)
91
- .filter((entry) => Boolean(entry));
92
- };
93
-
94
68
  const normalizeEntryStatus = (entry, warnings) => {
95
- // Compatibility fallback:
96
- // - C behaves like an add because there is no stable base path identity
97
- // - T/U/unknown behave like modify to preserve fail-open behavior
98
69
  const status = String(entry.status || '').toUpperCase();
99
70
  if (['A', 'M', 'D', 'R'].includes(status)) return { ...entry, status };
100
71
 
@@ -119,7 +90,6 @@ const mergeByPriority = (existing, incoming) => {
119
90
  if (incomingPriority > currentPriority) return incoming;
120
91
  if (incomingPriority < currentPriority) return existing;
121
92
 
122
- // If priorities are equal, keep entry with richer rename info.
123
93
  if (incoming.status === 'R' && incoming.oldPath && incoming.newPath) return incoming;
124
94
  return existing;
125
95
  };
@@ -129,25 +99,257 @@ const isValidExtension = (filePath, fileExtensions) => {
129
99
  return fileExtensions.includes(ext);
130
100
  };
131
101
 
132
- const getUntrackedPaths = ({ repoRoot }) => {
133
- const result = runCommand('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repoRoot });
134
- if (result.error || result.status !== 0) return [];
102
+ const resolveRefBase = async ({ repoRoot, refBase }) => {
103
+ if (/^[0-9a-f]{40}$/i.test(refBase)) return refBase;
104
+
105
+ const candidates = [
106
+ refBase,
107
+ `refs/heads/${refBase}`,
108
+ `refs/remotes/${refBase}`,
109
+ `refs/tags/${refBase}`,
110
+ ];
111
+
112
+ for (const candidate of candidates) {
113
+ try {
114
+ return await git.resolveRef({ fs, dir: repoRoot, ref: candidate });
115
+ } catch (_) {
116
+ // Try next candidate.
117
+ }
118
+ }
119
+
120
+ throw new Error(`Unknown git ref: ${refBase}`);
121
+ };
122
+
123
+ const resolveRevisionToOid = async ({ repoRoot, ref }) => {
124
+ const source = String(ref || 'HEAD').trim();
125
+ if (!source) throw new Error('Missing git ref');
126
+
127
+ const match = source.match(/^(.*)~(\d+)$/);
128
+ const baseRef = match ? (match[1] || 'HEAD') : source;
129
+ const backSteps = match ? Number.parseInt(match[2], 10) : 0;
130
+
131
+ let oid = await resolveRefBase({ repoRoot, refBase: baseRef });
132
+ for (let index = 0; index < backSteps; index += 1) {
133
+ const commit = await git.readCommit({ fs, dir: repoRoot, oid });
134
+ const parent = commit?.commit?.parent?.[0];
135
+ if (!parent) throw new Error(`Cannot resolve ${source}: parent commit is missing`);
136
+ oid = parent;
137
+ }
138
+
139
+ return oid;
140
+ };
141
+
142
+ const readFileFromBaseRef = async ({ repoRoot, relativePath, baseRef }) => {
143
+ if (!relativePath) return null;
144
+ try {
145
+ const refOid = await resolveRevisionToOid({ repoRoot, ref: baseRef || 'HEAD' });
146
+ const blob = await git.readBlob({ fs, dir: repoRoot, oid: refOid, filepath: relativePath });
147
+ return Buffer.from(blob.blob).toString('utf8');
148
+ } catch (_) {
149
+ return null;
150
+ }
151
+ };
152
+
153
+ const getTrackedFileOidAtRef = async ({ repoRoot, refOid, filepath }) => {
154
+ try {
155
+ const blob = await git.readBlob({ fs, dir: repoRoot, oid: refOid, filepath });
156
+ return blob.oid;
157
+ } catch (_) {
158
+ return null;
159
+ }
160
+ };
161
+
162
+ const getWorkingFileOid = async ({ repoRoot, filepath }) => {
163
+ const absolutePath = path.join(repoRoot, filepath);
164
+ if (!fs.existsSync(absolutePath)) return null;
165
+ const bytes = fs.readFileSync(absolutePath);
166
+ const hashed = await git.hashBlob({ object: bytes });
167
+ return hashed.oid;
168
+ };
169
+
170
+ const applyRenameDetection = ({ entries, warnings }) => {
171
+ const adds = entries
172
+ .filter((entry) => entry.status === 'A' && entry.newOid)
173
+ .map((entry) => ({ ...entry, consumed: false }));
174
+ const deletions = entries.filter((entry) => entry.status === 'D' && entry.oldOid);
175
+ const survivors = entries.filter((entry) => entry.status !== 'A' && entry.status !== 'D');
176
+
177
+ for (const del of deletions) {
178
+ const match = adds.find((add) => !add.consumed && add.newOid === del.oldOid);
179
+ if (!match) {
180
+ survivors.push(del);
181
+ continue;
182
+ }
183
+
184
+ match.consumed = true;
185
+ survivors.push({
186
+ status: 'R',
187
+ oldPath: del.oldPath,
188
+ newPath: match.newPath,
189
+ effectivePath: match.newPath,
190
+ rawStatus: 'R100',
191
+ });
192
+ }
193
+
194
+ for (const add of adds) {
195
+ if (add.consumed) continue;
196
+ survivors.push(add);
197
+ }
198
+
199
+ // Ensure deterministic order.
200
+ return survivors
201
+ .map((entry) => {
202
+ const { oldOid, newOid, ...rest } = entry;
203
+ return rest;
204
+ })
205
+ .sort((a, b) => a.effectivePath.localeCompare(b.effectivePath));
206
+ };
207
+
208
+ const getDiffEntriesBetweenRefs = async ({ repoRoot, fromRef, toRef = 'HEAD' }) => {
209
+ const fromOid = await resolveRevisionToOid({ repoRoot, ref: fromRef });
210
+ const toOid = await resolveRevisionToOid({ repoRoot, ref: toRef });
211
+ const rawEntries = [];
212
+ const fromFiles = await git.listFiles({ fs, dir: repoRoot, ref: fromOid });
213
+ const toFiles = await git.listFiles({ fs, dir: repoRoot, ref: toOid });
214
+
215
+ const fromMap = new Map();
216
+ for (const filepath of fromFiles) {
217
+ const blob = await git.readBlob({ fs, dir: repoRoot, oid: fromOid, filepath });
218
+ fromMap.set(filepath, blob.oid);
219
+ }
220
+
221
+ const toMap = new Map();
222
+ for (const filepath of toFiles) {
223
+ const blob = await git.readBlob({ fs, dir: repoRoot, oid: toOid, filepath });
224
+ toMap.set(filepath, blob.oid);
225
+ }
226
+
227
+ const allPaths = new Set([...fromMap.keys(), ...toMap.keys()]);
228
+ for (const filepath of allPaths) {
229
+ const fromOidEntry = fromMap.get(filepath) || null;
230
+ const toOidEntry = toMap.get(filepath) || null;
231
+
232
+ if (fromOidEntry && !toOidEntry) {
233
+ rawEntries.push({
234
+ status: 'D',
235
+ oldPath: filepath,
236
+ newPath: null,
237
+ effectivePath: filepath,
238
+ rawStatus: 'D',
239
+ oldOid: fromOidEntry,
240
+ newOid: null,
241
+ });
242
+ continue;
243
+ }
244
+
245
+ if (!fromOidEntry && toOidEntry) {
246
+ rawEntries.push({
247
+ status: 'A',
248
+ oldPath: null,
249
+ newPath: filepath,
250
+ effectivePath: filepath,
251
+ rawStatus: 'A',
252
+ oldOid: null,
253
+ newOid: toOidEntry,
254
+ });
255
+ continue;
256
+ }
257
+
258
+ if (fromOidEntry !== toOidEntry) {
259
+ rawEntries.push({
260
+ status: 'M',
261
+ oldPath: filepath,
262
+ newPath: filepath,
263
+ effectivePath: filepath,
264
+ rawStatus: 'M',
265
+ oldOid: fromOidEntry,
266
+ newOid: toOidEntry,
267
+ });
268
+ }
269
+ }
135
270
 
136
- return result.stdout
137
- .split(/\r?\n/)
138
- .map((line) => line.trim())
139
- .filter(Boolean)
271
+ return applyRenameDetection({ entries: rawEntries, warnings: [] });
272
+ };
273
+
274
+ const getWorkingTreeDiffEntries = async ({ repoRoot }) => {
275
+ const headOid = await resolveRevisionToOid({ repoRoot, ref: 'HEAD' });
276
+ const rawEntries = [];
277
+ const headFiles = await git.listFiles({ fs, dir: repoRoot, ref: headOid });
278
+ const indexFiles = await git.listFiles({ fs, dir: repoRoot });
279
+ const indexSet = new Set(indexFiles);
280
+
281
+ const headMap = new Map();
282
+ for (const filepath of headFiles) {
283
+ headMap.set(filepath, await getTrackedFileOidAtRef({ repoRoot, refOid: headOid, filepath }));
284
+ }
285
+
286
+ const candidates = new Set([...headFiles, ...indexFiles]);
287
+ for (const filepath of candidates) {
288
+ const headFileOid = headMap.get(filepath) || null;
289
+ const workFileOid = await getWorkingFileOid({ repoRoot, filepath });
290
+ const inHead = Boolean(headFileOid);
291
+ const inIndex = indexSet.has(filepath);
292
+
293
+ if (!inHead && inIndex && workFileOid) {
294
+ rawEntries.push({
295
+ status: 'A',
296
+ oldPath: null,
297
+ newPath: filepath,
298
+ effectivePath: filepath,
299
+ rawStatus: 'A',
300
+ oldOid: null,
301
+ newOid: workFileOid,
302
+ });
303
+ continue;
304
+ }
305
+
306
+ if (inHead && !workFileOid) {
307
+ rawEntries.push({
308
+ status: 'D',
309
+ oldPath: filepath,
310
+ newPath: null,
311
+ effectivePath: filepath,
312
+ rawStatus: 'D',
313
+ oldOid: headFileOid,
314
+ newOid: null,
315
+ });
316
+ continue;
317
+ }
318
+
319
+ if (inHead && workFileOid && headFileOid !== workFileOid) {
320
+ rawEntries.push({
321
+ status: 'M',
322
+ oldPath: filepath,
323
+ newPath: filepath,
324
+ effectivePath: filepath,
325
+ rawStatus: 'M',
326
+ oldOid: headFileOid,
327
+ newOid: workFileOid,
328
+ });
329
+ }
330
+ }
331
+
332
+ return applyRenameDetection({ entries: rawEntries, warnings: [] });
333
+ };
334
+
335
+ const getUntrackedPaths = async ({ repoRoot }) => {
336
+ const matrix = await git.statusMatrix({ fs, dir: repoRoot });
337
+ return matrix
338
+ .filter(([, head, workdir, stage]) => head === 0 && workdir === 2 && stage === 0)
339
+ .map(([filepath]) => filepath)
140
340
  .sort((a, b) => a.localeCompare(b));
141
341
  };
142
342
 
143
- const getUntrackedSpecPaths = ({ repoRoot, changedSpecPrefix, fileExtensions = ['.ts', '.tsx'] }) => {
144
- return getUntrackedPaths({ repoRoot })
343
+ const getUntrackedSpecPaths = async ({ repoRoot, changedSpecPrefix, fileExtensions = ['.ts', '.tsx'] }) => {
344
+ const untracked = await getUntrackedPaths({ repoRoot });
345
+ return untracked
145
346
  .filter((filePath) => filePath.startsWith(changedSpecPrefix))
146
347
  .filter((filePath) => fileExtensions.some((ext) => filePath.endsWith(`.spec${ext}`)));
147
348
  };
148
349
 
149
- const getUntrackedSourceEntries = ({ repoRoot, profile, fileExtensions = ['.ts', '.tsx'] }) => {
150
- return getUntrackedPaths({ repoRoot })
350
+ const getUntrackedSourceEntries = async ({ repoRoot, profile, fileExtensions = ['.ts', '.tsx'] }) => {
351
+ const untracked = await getUntrackedPaths({ repoRoot });
352
+ return untracked
151
353
  .filter((filePath) => isValidExtension(filePath, fileExtensions))
152
354
  .filter((filePath) => profile.isRelevantPomPath(filePath))
153
355
  .map((filePath) => ({
@@ -159,31 +361,24 @@ const getUntrackedSourceEntries = ({ repoRoot, profile, fileExtensions = ['.ts',
159
361
  }));
160
362
  };
161
363
 
162
- /**
163
- * Build normalized changed entries for analysis.
164
- * Sources:
165
- * - git diff <base>...HEAD (if baseRef is set)
166
- * - git diff HEAD (working tree, optionally merged with base mode)
167
- * - untracked source files matching profile.isRelevantPomPath
168
- */
169
- const getChangedEntries = ({ repoRoot, baseRef, includeWorkingTreeWithBase = true, profile = null, fileExtensions = ['.ts', '.tsx'] }) => {
364
+ const getChangedEntries = async ({ repoRoot, baseRef, includeWorkingTreeWithBase = true, profile = null, fileExtensions = ['.ts', '.tsx'] }) => {
170
365
  const warnings = [];
171
366
  let statusFallbackHits = 0;
172
367
 
173
- const baseHeadEntries = baseRef ? runDiffAndParse({ repoRoot, args: [`${baseRef}...HEAD`] }) : [];
174
- const workingTreeEntries = (!baseRef || includeWorkingTreeWithBase) ? runDiffAndParse({ repoRoot, args: ['HEAD'] }) : [];
368
+ const baseHeadEntries = baseRef
369
+ ? await getDiffEntriesBetweenRefs({ repoRoot, fromRef: baseRef, toRef: 'HEAD' })
370
+ : [];
371
+ const workingTreeEntries = (!baseRef || includeWorkingTreeWithBase)
372
+ ? await getWorkingTreeDiffEntries({ repoRoot })
373
+ : [];
175
374
  const combined = [...baseHeadEntries, ...workingTreeEntries];
176
375
 
177
- if (!baseRef && combined.length === 0) {
178
- // Keep backward-compatible behavior for local-only mode where `HEAD` diff is the primary source.
179
- combined.push(...workingTreeEntries);
180
- }
181
-
376
+ let untrackedSourceEntries = [];
182
377
  if (profile && typeof profile.isRelevantPomPath === 'function') {
183
- combined.push(...getUntrackedSourceEntries({ repoRoot, profile, fileExtensions }));
378
+ untrackedSourceEntries = await getUntrackedSourceEntries({ repoRoot, profile, fileExtensions });
379
+ combined.push(...untrackedSourceEntries);
184
380
  }
185
381
 
186
- // Deterministic status merge by effective path with explicit precedence (D > R > M > A).
187
382
  const mergedByPath = new Map();
188
383
  for (const entry of combined) {
189
384
  const normalized = normalizeEntryStatus(entry, warnings);
@@ -204,16 +399,16 @@ const getChangedEntries = ({ repoRoot, baseRef, includeWorkingTreeWithBase = tru
204
399
  changedEntriesBySource: {
205
400
  fromBaseHead: baseHeadEntries.length,
206
401
  fromWorkingTree: workingTreeEntries.length,
207
- fromUntracked: profile ? getUntrackedSourceEntries({ repoRoot, profile, fileExtensions }).length : 0,
402
+ fromUntracked: untrackedSourceEntries.length,
208
403
  },
209
404
  };
210
405
  };
211
406
 
212
- const readChangeContents = ({ repoRoot, entry, baseRef }) => {
407
+ const readChangeContents = async ({ repoRoot, entry, baseRef }) => {
213
408
  const basePath = entry?.status === 'R' ? entry.oldPath : entry?.oldPath;
214
409
  const headPath = entry?.status === 'R' ? entry.newPath : entry?.newPath;
215
410
 
216
- const baseContent = basePath ? readFileFromBaseRef({ repoRoot, relativePath: basePath, baseRef }) : null;
411
+ const baseContent = basePath ? await readFileFromBaseRef({ repoRoot, relativePath: basePath, baseRef }) : null;
217
412
  const headContent = headPath ? readFileFromWorkingTree({ repoRoot, relativePath: headPath }) : null;
218
413
 
219
414
  return { basePath: basePath || null, headPath: headPath || null, baseContent, headContent };
@@ -230,5 +425,6 @@ module.exports = {
230
425
  parseChangedEntryLine,
231
426
  normalizeEntryStatus,
232
427
  mergeByPriority,
428
+ resolveRevisionToOid,
233
429
  },
234
430
  };
@@ -1,24 +0,0 @@
1
- 'use strict';
2
-
3
- const { spawnSync } = require('child_process');
4
-
5
- /**
6
- * Small spawnSync wrapper with normalized process result shape.
7
- */
8
- const runCommand = (command, args, options = {}) => {
9
- const result = spawnSync(command, args, {
10
- encoding: 'utf-8',
11
- ...options,
12
- });
13
-
14
- return {
15
- status: result.status ?? 1,
16
- stdout: result.stdout || '',
17
- stderr: result.stderr || '',
18
- error: result.error || null,
19
- };
20
- };
21
-
22
- module.exports = {
23
- runCommand,
24
- };