playwright-intelligence-engine 2.0.1 → 2.0.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/README.md +229 -229
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.js +81 -24
- package/package.json +76 -76
- package/src/authoring/author.ts +81 -81
- package/src/authoring/generation-contract.ts +283 -283
- package/src/authoring/hybrid-author.ts +115 -115
- package/src/authoring/index.ts +7 -7
- package/src/authoring/llm-author.ts +282 -282
- package/src/authoring/naming/name-plan.ts +28 -28
- package/src/authoring/naming/name-planner.ts +339 -339
- package/src/authoring/naming/naming-linter.ts +292 -292
- package/src/cli/index.ts +1238 -1172
- package/src/collectors/git-diff-collector.ts +190 -190
- package/src/core/version.ts +25 -25
- package/src/diagnostics/diagnostic-report.ts +204 -204
- package/src/diagnostics/failure-classifier.ts +701 -701
- package/src/evaluation/repository-runner.ts +197 -197
- package/src/gate/quality.ts +101 -101
- package/src/generator/architecture-generator.ts +582 -582
- package/src/generator/assertion-quality-gate.ts +88 -88
- package/src/generator/candidate-writer.ts +288 -288
- package/src/generator/test-generator.ts +52 -52
- package/src/index.ts +107 -107
- package/src/intelligence/evidence-bundle.ts +300 -300
- package/src/intelligence/generation-agent.ts +561 -561
- package/src/intelligence/generation-verifier.ts +1115 -1115
- package/src/intelligence/locator-disambiguation-engine.ts +331 -331
- package/src/intelligence/project-intelligence.ts +140 -140
- package/src/intelligence/repository-profile.ts +796 -796
- package/src/intelligence/trace-proposal-workflow.ts +318 -318
- package/src/intelligence/workflow-planner.ts +966 -966
- package/src/mcp/server.ts +970 -970
- package/src/perception/app-model.ts +32 -32
- package/src/perception/crawler.ts +263 -263
- package/src/perception/index.ts +3 -3
- package/src/repair/assertion-hasher.ts +386 -386
- package/src/repair/diff-allowlist.ts +612 -612
- package/src/repair/intent-validator.ts +761 -761
- package/src/repair/repair-planner.ts +467 -467
- package/src/repair/statement-patcher.ts +544 -544
- package/src/reproduce/interactive-workflow-runner.ts +828 -828
- package/src/sandbox/execution-runner.ts +408 -408
- package/src/selector/abstention-policy.ts +175 -175
- package/src/selector/candidate-extractor.ts +343 -343
- package/src/selector/repair-candidate-ranker.ts +92 -92
- package/src/selector/selector-scorer.ts +344 -344
|
@@ -1,288 +1,288 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import fsSync from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import type { CandidateChange, CandidateFile } from '../authoring/author.js';
|
|
6
|
-
import { isSafeRelativeProposalPath, normalizeProposalPath } from '../authoring/generation-contract.js';
|
|
7
|
-
|
|
8
|
-
export interface FileSnapshot {
|
|
9
|
-
relativePath: string;
|
|
10
|
-
existed: boolean;
|
|
11
|
-
sha256Before?: string | undefined;
|
|
12
|
-
contentBefore?: string | undefined;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface CandidateWriteOptions {
|
|
16
|
-
protectedPaths?: string[] | undefined;
|
|
17
|
-
allowModify?: boolean | undefined;
|
|
18
|
-
overwriteCreate?: boolean | undefined;
|
|
19
|
-
validateLocalImports?: boolean | undefined;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface CandidateWriteResult {
|
|
23
|
-
success: boolean;
|
|
24
|
-
written: string[];
|
|
25
|
-
modified: string[];
|
|
26
|
-
skipped: string[];
|
|
27
|
-
snapshots: FileSnapshot[];
|
|
28
|
-
errors: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const DEFAULT_PROTECTED_PATHS = new Set([
|
|
32
|
-
'package.json',
|
|
33
|
-
'package-lock.json',
|
|
34
|
-
'pnpm-lock.yaml',
|
|
35
|
-
'yarn.lock',
|
|
36
|
-
'tsconfig.json',
|
|
37
|
-
'.env',
|
|
38
|
-
'.env.local',
|
|
39
|
-
'.env.test',
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
function sha256(content: string): string {
|
|
43
|
-
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isProtectedPath(normPath: string, extraProtected: Set<string>): boolean {
|
|
47
|
-
if (DEFAULT_PROTECTED_PATHS.has(normPath) || extraProtected.has(normPath)) return true;
|
|
48
|
-
if (normPath.startsWith('.git/') || normPath === '.git') return true;
|
|
49
|
-
if (normPath.startsWith('node_modules/') || normPath === 'node_modules') return true;
|
|
50
|
-
if (path.posix.basename(normPath).startsWith('.env')) return true;
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function assertNoSymlinkEscape(canonicalRoot: string, workspaceRoot: string, normPath: string): Promise<string> {
|
|
55
|
-
const target = path.resolve(workspaceRoot, normPath);
|
|
56
|
-
const relativeFromRoot = path.relative(workspaceRoot, target);
|
|
57
|
-
if (relativeFromRoot.startsWith('..') || path.isAbsolute(relativeFromRoot)) {
|
|
58
|
-
throw new Error(`Path escapes workspace root: '${normPath}'`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Walk existing path segments to reject symlinks / junctions pointing outside workspaceRoot
|
|
62
|
-
const segments = normPath.split('/');
|
|
63
|
-
let current = workspaceRoot;
|
|
64
|
-
for (let i = 0; i < segments.length; i += 1) {
|
|
65
|
-
current = path.join(current, segments[i]!);
|
|
66
|
-
try {
|
|
67
|
-
const lstat = await fs.lstat(current);
|
|
68
|
-
if (lstat.isSymbolicLink()) {
|
|
69
|
-
const real = await fs.realpath(current);
|
|
70
|
-
const relReal = path.relative(canonicalRoot, real);
|
|
71
|
-
if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
|
|
72
|
-
throw new Error(`Symlink escapes workspace root at '${current}' -> '${real}'`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} catch (error: any) {
|
|
76
|
-
if (error?.code === 'ENOENT') {
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
throw error;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Check nearest existing parent directory realpath
|
|
84
|
-
let parent = path.dirname(target);
|
|
85
|
-
while (parent && parent !== path.dirname(parent)) {
|
|
86
|
-
if (fsSync.existsSync(parent)) {
|
|
87
|
-
const realParent = fsSync.realpathSync(parent);
|
|
88
|
-
const rel = path.relative(canonicalRoot, realParent);
|
|
89
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
90
|
-
throw new Error(`Resolved parent directory escapes workspace root: '${realParent}'`);
|
|
91
|
-
}
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
parent = path.dirname(parent);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return target;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function validateRelativeImports(files: CandidateFile[], workspaceRoot: string): string[] {
|
|
101
|
-
const errors: string[] = [];
|
|
102
|
-
const candidatePathSet = new Set(files.map((f) => normalizeProposalPath(f.relativePath)));
|
|
103
|
-
|
|
104
|
-
for (const file of files) {
|
|
105
|
-
if (!/\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i.test(file.relativePath)) continue;
|
|
106
|
-
const fromDir = path.posix.dirname(normalizeProposalPath(file.relativePath));
|
|
107
|
-
const importRegex = /(?:import|export)\s+(?:type\s+)?(?:[^'"`]+?\s+from\s+)?['"](\.[^'"]+)['"]/g;
|
|
108
|
-
for (const match of file.content.matchAll(importRegex)) {
|
|
109
|
-
const specifier = match[1]!;
|
|
110
|
-
const joined = normalizeProposalPath(path.posix.join(fromDir === '.' ? '' : fromDir, specifier));
|
|
111
|
-
const strippedExt = joined.replace(/\.(?:js|jsx|mjs|cjs|ts|tsx)$/, '');
|
|
112
|
-
const candidates = [
|
|
113
|
-
joined,
|
|
114
|
-
`${joined}.ts`,
|
|
115
|
-
`${joined}.tsx`,
|
|
116
|
-
`${joined}.js`,
|
|
117
|
-
`${strippedExt}.ts`,
|
|
118
|
-
`${strippedExt}.tsx`,
|
|
119
|
-
`${strippedExt}/index.ts`,
|
|
120
|
-
`${strippedExt}/index.tsx`,
|
|
121
|
-
];
|
|
122
|
-
const foundInCandidate = candidates.some((c) => candidatePathSet.has(c));
|
|
123
|
-
const foundOnDisk = candidates.some((c) => fsSync.existsSync(path.resolve(workspaceRoot, c)));
|
|
124
|
-
if (!foundInCandidate && !foundOnDisk) {
|
|
125
|
-
errors.push(`Unresolved local import '${specifier}' in '${file.relativePath}'`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return errors;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export class CandidateWriter {
|
|
133
|
-
/**
|
|
134
|
-
* Transactionally validates and applies CandidateChange files to workspaceRoot.
|
|
135
|
-
* Performs path safety, symlink checks, duplicate checks, CREATE/MODIFY checks,
|
|
136
|
-
* snapshot capture, and full rollback if any file write fails.
|
|
137
|
-
*/
|
|
138
|
-
public static async apply(
|
|
139
|
-
workspaceRoot: string,
|
|
140
|
-
candidate: Pick<CandidateChange, 'files'>,
|
|
141
|
-
options: CandidateWriteOptions = {},
|
|
142
|
-
): Promise<CandidateWriteResult> {
|
|
143
|
-
const resolvedRoot = path.resolve(workspaceRoot);
|
|
144
|
-
await fs.mkdir(resolvedRoot, { recursive: true });
|
|
145
|
-
const canonicalRoot = fsSync.realpathSync(resolvedRoot);
|
|
146
|
-
const extraProtected = new Set<string>((options.protectedPaths ?? []).map(normalizeProposalPath));
|
|
147
|
-
|
|
148
|
-
const errors: string[] = [];
|
|
149
|
-
const seen = new Set<string>();
|
|
150
|
-
const prepared: Array<{ file: CandidateFile; normPath: string; target: string; action: 'CREATE' | 'MODIFY' | 'SKIP_EXISTING' }> = [];
|
|
151
|
-
|
|
152
|
-
for (const file of candidate.files) {
|
|
153
|
-
if (!isSafeRelativeProposalPath(file.relativePath)) {
|
|
154
|
-
errors.push(`Unsafe candidate path rejected: '${file.relativePath}'`);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
const normPath = normalizeProposalPath(file.relativePath);
|
|
158
|
-
if (seen.has(normPath)) {
|
|
159
|
-
errors.push(`Duplicate candidate path rejected: '${normPath}'`);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
seen.add(normPath);
|
|
163
|
-
|
|
164
|
-
if (isProtectedPath(normPath, extraProtected)) {
|
|
165
|
-
errors.push(`Protected path cannot be modified: '${normPath}'`);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let target: string;
|
|
170
|
-
try {
|
|
171
|
-
target = await assertNoSymlinkEscape(canonicalRoot, resolvedRoot, normPath);
|
|
172
|
-
} catch (error) {
|
|
173
|
-
errors.push(error instanceof Error ? error.message : String(error));
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const action = file.action ?? 'CREATE';
|
|
178
|
-
const existsOnDisk = fsSync.existsSync(target);
|
|
179
|
-
|
|
180
|
-
if (action === 'SKIP_EXISTING') {
|
|
181
|
-
prepared.push({ file, normPath, target, action: 'SKIP_EXISTING' });
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (action === 'MODIFY') {
|
|
186
|
-
if (options.allowModify === false) {
|
|
187
|
-
errors.push(`MODIFY action disallowed by policy for '${normPath}'`);
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (!existsOnDisk) {
|
|
191
|
-
errors.push(`Cannot MODIFY non-existent file '${normPath}'`);
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
} else if (action === 'CREATE' && existsOnDisk && !options.overwriteCreate) {
|
|
195
|
-
prepared.push({ file, normPath, target, action: 'SKIP_EXISTING' });
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
prepared.push({ file, normPath, target, action });
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (options.validateLocalImports) {
|
|
203
|
-
errors.push(...validateRelativeImports(candidate.files, resolvedRoot));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (errors.length > 0) {
|
|
207
|
-
return {
|
|
208
|
-
success: false,
|
|
209
|
-
written: [],
|
|
210
|
-
modified: [],
|
|
211
|
-
skipped: [],
|
|
212
|
-
snapshots: [],
|
|
213
|
-
errors,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const snapshots: FileSnapshot[] = [];
|
|
218
|
-
const written: string[] = [];
|
|
219
|
-
const modified: string[] = [];
|
|
220
|
-
const skipped: string[] = [];
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
for (const item of prepared) {
|
|
224
|
-
if (item.action === 'SKIP_EXISTING') {
|
|
225
|
-
skipped.push(item.normPath);
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const existed = fsSync.existsSync(item.target);
|
|
230
|
-
if (existed) {
|
|
231
|
-
const prior = await fs.readFile(item.target, 'utf8');
|
|
232
|
-
snapshots.push({
|
|
233
|
-
relativePath: item.normPath,
|
|
234
|
-
existed: true,
|
|
235
|
-
sha256Before: sha256(prior),
|
|
236
|
-
contentBefore: prior,
|
|
237
|
-
});
|
|
238
|
-
} else {
|
|
239
|
-
snapshots.push({ relativePath: item.normPath, existed: false });
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
243
|
-
await fs.writeFile(item.target, item.file.content, 'utf8');
|
|
244
|
-
if (item.action === 'MODIFY') modified.push(item.normPath);
|
|
245
|
-
else written.push(item.normPath);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
success: true,
|
|
250
|
-
written,
|
|
251
|
-
modified,
|
|
252
|
-
skipped,
|
|
253
|
-
snapshots,
|
|
254
|
-
errors: [],
|
|
255
|
-
};
|
|
256
|
-
} catch (error) {
|
|
257
|
-
await CandidateWriter.rollback(resolvedRoot, snapshots);
|
|
258
|
-
return {
|
|
259
|
-
success: false,
|
|
260
|
-
written: [],
|
|
261
|
-
modified: [],
|
|
262
|
-
skipped,
|
|
263
|
-
snapshots,
|
|
264
|
-
errors: [`Transactional write failed and rolled back: ${error instanceof Error ? error.message : String(error)}`],
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Restores all files modified during a CandidateWriter transaction and removes newly created files.
|
|
271
|
-
*/
|
|
272
|
-
public static async rollback(workspaceRoot: string, snapshots: FileSnapshot[]): Promise<void> {
|
|
273
|
-
const resolvedRoot = path.resolve(workspaceRoot);
|
|
274
|
-
for (let i = snapshots.length - 1; i >= 0; i -= 1) {
|
|
275
|
-
const snap = snapshots[i]!;
|
|
276
|
-
const target = path.resolve(resolvedRoot, snap.relativePath);
|
|
277
|
-
try {
|
|
278
|
-
if (snap.existed && snap.contentBefore !== undefined) {
|
|
279
|
-
await fs.writeFile(target, snap.contentBefore, 'utf8');
|
|
280
|
-
} else if (!snap.existed) {
|
|
281
|
-
await fs.rm(target, { force: true });
|
|
282
|
-
}
|
|
283
|
-
} catch {
|
|
284
|
-
// Best-effort rollback continuation
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import type { CandidateChange, CandidateFile } from '../authoring/author.js';
|
|
6
|
+
import { isSafeRelativeProposalPath, normalizeProposalPath } from '../authoring/generation-contract.js';
|
|
7
|
+
|
|
8
|
+
export interface FileSnapshot {
|
|
9
|
+
relativePath: string;
|
|
10
|
+
existed: boolean;
|
|
11
|
+
sha256Before?: string | undefined;
|
|
12
|
+
contentBefore?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CandidateWriteOptions {
|
|
16
|
+
protectedPaths?: string[] | undefined;
|
|
17
|
+
allowModify?: boolean | undefined;
|
|
18
|
+
overwriteCreate?: boolean | undefined;
|
|
19
|
+
validateLocalImports?: boolean | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CandidateWriteResult {
|
|
23
|
+
success: boolean;
|
|
24
|
+
written: string[];
|
|
25
|
+
modified: string[];
|
|
26
|
+
skipped: string[];
|
|
27
|
+
snapshots: FileSnapshot[];
|
|
28
|
+
errors: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PROTECTED_PATHS = new Set([
|
|
32
|
+
'package.json',
|
|
33
|
+
'package-lock.json',
|
|
34
|
+
'pnpm-lock.yaml',
|
|
35
|
+
'yarn.lock',
|
|
36
|
+
'tsconfig.json',
|
|
37
|
+
'.env',
|
|
38
|
+
'.env.local',
|
|
39
|
+
'.env.test',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function sha256(content: string): string {
|
|
43
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isProtectedPath(normPath: string, extraProtected: Set<string>): boolean {
|
|
47
|
+
if (DEFAULT_PROTECTED_PATHS.has(normPath) || extraProtected.has(normPath)) return true;
|
|
48
|
+
if (normPath.startsWith('.git/') || normPath === '.git') return true;
|
|
49
|
+
if (normPath.startsWith('node_modules/') || normPath === 'node_modules') return true;
|
|
50
|
+
if (path.posix.basename(normPath).startsWith('.env')) return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function assertNoSymlinkEscape(canonicalRoot: string, workspaceRoot: string, normPath: string): Promise<string> {
|
|
55
|
+
const target = path.resolve(workspaceRoot, normPath);
|
|
56
|
+
const relativeFromRoot = path.relative(workspaceRoot, target);
|
|
57
|
+
if (relativeFromRoot.startsWith('..') || path.isAbsolute(relativeFromRoot)) {
|
|
58
|
+
throw new Error(`Path escapes workspace root: '${normPath}'`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Walk existing path segments to reject symlinks / junctions pointing outside workspaceRoot
|
|
62
|
+
const segments = normPath.split('/');
|
|
63
|
+
let current = workspaceRoot;
|
|
64
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
65
|
+
current = path.join(current, segments[i]!);
|
|
66
|
+
try {
|
|
67
|
+
const lstat = await fs.lstat(current);
|
|
68
|
+
if (lstat.isSymbolicLink()) {
|
|
69
|
+
const real = await fs.realpath(current);
|
|
70
|
+
const relReal = path.relative(canonicalRoot, real);
|
|
71
|
+
if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
|
|
72
|
+
throw new Error(`Symlink escapes workspace root at '${current}' -> '${real}'`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (error: any) {
|
|
76
|
+
if (error?.code === 'ENOENT') {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check nearest existing parent directory realpath
|
|
84
|
+
let parent = path.dirname(target);
|
|
85
|
+
while (parent && parent !== path.dirname(parent)) {
|
|
86
|
+
if (fsSync.existsSync(parent)) {
|
|
87
|
+
const realParent = fsSync.realpathSync(parent);
|
|
88
|
+
const rel = path.relative(canonicalRoot, realParent);
|
|
89
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
90
|
+
throw new Error(`Resolved parent directory escapes workspace root: '${realParent}'`);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
parent = path.dirname(parent);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateRelativeImports(files: CandidateFile[], workspaceRoot: string): string[] {
|
|
101
|
+
const errors: string[] = [];
|
|
102
|
+
const candidatePathSet = new Set(files.map((f) => normalizeProposalPath(f.relativePath)));
|
|
103
|
+
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
if (!/\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i.test(file.relativePath)) continue;
|
|
106
|
+
const fromDir = path.posix.dirname(normalizeProposalPath(file.relativePath));
|
|
107
|
+
const importRegex = /(?:import|export)\s+(?:type\s+)?(?:[^'"`]+?\s+from\s+)?['"](\.[^'"]+)['"]/g;
|
|
108
|
+
for (const match of file.content.matchAll(importRegex)) {
|
|
109
|
+
const specifier = match[1]!;
|
|
110
|
+
const joined = normalizeProposalPath(path.posix.join(fromDir === '.' ? '' : fromDir, specifier));
|
|
111
|
+
const strippedExt = joined.replace(/\.(?:js|jsx|mjs|cjs|ts|tsx)$/, '');
|
|
112
|
+
const candidates = [
|
|
113
|
+
joined,
|
|
114
|
+
`${joined}.ts`,
|
|
115
|
+
`${joined}.tsx`,
|
|
116
|
+
`${joined}.js`,
|
|
117
|
+
`${strippedExt}.ts`,
|
|
118
|
+
`${strippedExt}.tsx`,
|
|
119
|
+
`${strippedExt}/index.ts`,
|
|
120
|
+
`${strippedExt}/index.tsx`,
|
|
121
|
+
];
|
|
122
|
+
const foundInCandidate = candidates.some((c) => candidatePathSet.has(c));
|
|
123
|
+
const foundOnDisk = candidates.some((c) => fsSync.existsSync(path.resolve(workspaceRoot, c)));
|
|
124
|
+
if (!foundInCandidate && !foundOnDisk) {
|
|
125
|
+
errors.push(`Unresolved local import '${specifier}' in '${file.relativePath}'`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return errors;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class CandidateWriter {
|
|
133
|
+
/**
|
|
134
|
+
* Transactionally validates and applies CandidateChange files to workspaceRoot.
|
|
135
|
+
* Performs path safety, symlink checks, duplicate checks, CREATE/MODIFY checks,
|
|
136
|
+
* snapshot capture, and full rollback if any file write fails.
|
|
137
|
+
*/
|
|
138
|
+
public static async apply(
|
|
139
|
+
workspaceRoot: string,
|
|
140
|
+
candidate: Pick<CandidateChange, 'files'>,
|
|
141
|
+
options: CandidateWriteOptions = {},
|
|
142
|
+
): Promise<CandidateWriteResult> {
|
|
143
|
+
const resolvedRoot = path.resolve(workspaceRoot);
|
|
144
|
+
await fs.mkdir(resolvedRoot, { recursive: true });
|
|
145
|
+
const canonicalRoot = fsSync.realpathSync(resolvedRoot);
|
|
146
|
+
const extraProtected = new Set<string>((options.protectedPaths ?? []).map(normalizeProposalPath));
|
|
147
|
+
|
|
148
|
+
const errors: string[] = [];
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
const prepared: Array<{ file: CandidateFile; normPath: string; target: string; action: 'CREATE' | 'MODIFY' | 'SKIP_EXISTING' }> = [];
|
|
151
|
+
|
|
152
|
+
for (const file of candidate.files) {
|
|
153
|
+
if (!isSafeRelativeProposalPath(file.relativePath)) {
|
|
154
|
+
errors.push(`Unsafe candidate path rejected: '${file.relativePath}'`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const normPath = normalizeProposalPath(file.relativePath);
|
|
158
|
+
if (seen.has(normPath)) {
|
|
159
|
+
errors.push(`Duplicate candidate path rejected: '${normPath}'`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
seen.add(normPath);
|
|
163
|
+
|
|
164
|
+
if (isProtectedPath(normPath, extraProtected)) {
|
|
165
|
+
errors.push(`Protected path cannot be modified: '${normPath}'`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let target: string;
|
|
170
|
+
try {
|
|
171
|
+
target = await assertNoSymlinkEscape(canonicalRoot, resolvedRoot, normPath);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const action = file.action ?? 'CREATE';
|
|
178
|
+
const existsOnDisk = fsSync.existsSync(target);
|
|
179
|
+
|
|
180
|
+
if (action === 'SKIP_EXISTING') {
|
|
181
|
+
prepared.push({ file, normPath, target, action: 'SKIP_EXISTING' });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (action === 'MODIFY') {
|
|
186
|
+
if (options.allowModify === false) {
|
|
187
|
+
errors.push(`MODIFY action disallowed by policy for '${normPath}'`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!existsOnDisk) {
|
|
191
|
+
errors.push(`Cannot MODIFY non-existent file '${normPath}'`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
} else if (action === 'CREATE' && existsOnDisk && !options.overwriteCreate) {
|
|
195
|
+
prepared.push({ file, normPath, target, action: 'SKIP_EXISTING' });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
prepared.push({ file, normPath, target, action });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (options.validateLocalImports) {
|
|
203
|
+
errors.push(...validateRelativeImports(candidate.files, resolvedRoot));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (errors.length > 0) {
|
|
207
|
+
return {
|
|
208
|
+
success: false,
|
|
209
|
+
written: [],
|
|
210
|
+
modified: [],
|
|
211
|
+
skipped: [],
|
|
212
|
+
snapshots: [],
|
|
213
|
+
errors,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const snapshots: FileSnapshot[] = [];
|
|
218
|
+
const written: string[] = [];
|
|
219
|
+
const modified: string[] = [];
|
|
220
|
+
const skipped: string[] = [];
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
for (const item of prepared) {
|
|
224
|
+
if (item.action === 'SKIP_EXISTING') {
|
|
225
|
+
skipped.push(item.normPath);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const existed = fsSync.existsSync(item.target);
|
|
230
|
+
if (existed) {
|
|
231
|
+
const prior = await fs.readFile(item.target, 'utf8');
|
|
232
|
+
snapshots.push({
|
|
233
|
+
relativePath: item.normPath,
|
|
234
|
+
existed: true,
|
|
235
|
+
sha256Before: sha256(prior),
|
|
236
|
+
contentBefore: prior,
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
snapshots.push({ relativePath: item.normPath, existed: false });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
243
|
+
await fs.writeFile(item.target, item.file.content, 'utf8');
|
|
244
|
+
if (item.action === 'MODIFY') modified.push(item.normPath);
|
|
245
|
+
else written.push(item.normPath);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
success: true,
|
|
250
|
+
written,
|
|
251
|
+
modified,
|
|
252
|
+
skipped,
|
|
253
|
+
snapshots,
|
|
254
|
+
errors: [],
|
|
255
|
+
};
|
|
256
|
+
} catch (error) {
|
|
257
|
+
await CandidateWriter.rollback(resolvedRoot, snapshots);
|
|
258
|
+
return {
|
|
259
|
+
success: false,
|
|
260
|
+
written: [],
|
|
261
|
+
modified: [],
|
|
262
|
+
skipped,
|
|
263
|
+
snapshots,
|
|
264
|
+
errors: [`Transactional write failed and rolled back: ${error instanceof Error ? error.message : String(error)}`],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Restores all files modified during a CandidateWriter transaction and removes newly created files.
|
|
271
|
+
*/
|
|
272
|
+
public static async rollback(workspaceRoot: string, snapshots: FileSnapshot[]): Promise<void> {
|
|
273
|
+
const resolvedRoot = path.resolve(workspaceRoot);
|
|
274
|
+
for (let i = snapshots.length - 1; i >= 0; i -= 1) {
|
|
275
|
+
const snap = snapshots[i]!;
|
|
276
|
+
const target = path.resolve(resolvedRoot, snap.relativePath);
|
|
277
|
+
try {
|
|
278
|
+
if (snap.existed && snap.contentBefore !== undefined) {
|
|
279
|
+
await fs.writeFile(target, snap.contentBefore, 'utf8');
|
|
280
|
+
} else if (!snap.existed) {
|
|
281
|
+
await fs.rm(target, { force: true });
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// Best-effort rollback continuation
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|