@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 +18 -12
- package/README.md +41 -37
- package/package.json +6 -11
- package/src/analyze-impacted-specs.js +15 -5
- package/src/index.d.ts +1 -1
- package/src/modules/file-and-git-helpers.js +258 -62
- package/src/modules/shell.js +0 -24
- package/tests/_test-helpers.js +0 -45
- package/tests/analyze-impacted-specs.integration.test.js +0 -505
- package/tests/analyze-impacted-specs.test.js +0 -36
- package/tests/class-impact-helpers.test.js +0 -101
- package/tests/file-and-git-helpers.test.js +0 -140
- package/tests/file-status-compat.test.js +0 -55
- package/tests/fixture-map-helpers.test.js +0 -118
- package/tests/format-analyze-result.test.js +0 -26
- package/tests/global-watch-helpers.test.js +0 -92
- package/tests/method-filter-helpers.test.js +0 -316
- package/tests/method-impact-helpers.test.js +0 -195
- package/tests/semantic-coverage-matrix.test.js +0 -381
- package/tests/spec-selection-helpers.test.js +0 -115
package/LICENSE
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2026 Alex Kiselev
|
|
4
4
|
|
|
5
|
-
Permission
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
(filePath
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(filePath
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Core impacted-test selection library for changed POM/method analysis",
|
|
5
|
-
"license": "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
};
|
package/src/modules/shell.js
DELETED
|
@@ -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
|
-
};
|