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.
Files changed (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,467 +1,467 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { AstPatcher } from './ast-patcher.js';
4
- import { StatementPatcher, StatementPatchPlan } from './statement-patcher.js';
5
- import { AssertionHasher, CompositeIntentFingerprint } from './assertion-hasher.js';
6
- import { IntentValidator, IntentV2ValidationResult, VerifiedExecutionProof } from './intent-validator.js';
7
- import { DiffAllowlistValidator } from './diff-allowlist.js';
8
- import { ApprovalService } from './approval-service.js';
9
- import { RepairEvidence } from '../core/types/repair-evidence.js';
10
- import { ResolvedElement } from '../trace/snapshot-resolver.js';
11
- import { CandidateExtractor } from '../selector/candidate-extractor.js';
12
- import { ExecutionRunner, ExecutionResult } from '../sandbox/execution-runner.js';
13
- import { PROOF_VERSION } from '../core/version.js';
14
- import {
15
- BusinessIntent,
16
- BusinessOracleResult,
17
- evaluateBuiltInBusinessOracles,
18
- extractBusinessIntent,
19
- } from '../intelligence/business-intent.js';
20
-
21
- export type RepairLifecycleState =
22
- | 'ANALYZED'
23
- | 'PROPOSED'
24
- | 'PATCHED_IN_ISOLATION'
25
- | 'INTENT_VALIDATED'
26
- | 'TARGET_TEST_PASSED'
27
- | 'AFFECTED_TESTS_PASSED'
28
- | 'FULL_SUITE_PASSED'
29
- | 'READY_FOR_APPROVAL'
30
- | 'REJECTED';
31
-
32
- export interface RepairProposal {
33
- state: RepairLifecycleState;
34
- originalActionId: string;
35
- originalSelector: string;
36
- replacementSelector: string;
37
- candidateElement: ResolvedElement;
38
- candidateScore: number;
39
- testFilePath: string;
40
- pomFilePath: string;
41
- className: string;
42
- memberName: string;
43
- intendedAction: string;
44
- originalCompositeFingerprint: CompositeIntentFingerprint;
45
- proposedPatchedSource: string;
46
- originalSource: string;
47
- pattern: string;
48
- fullDiff?: string | undefined;
49
- diffAllowlistPassed: boolean;
50
- businessIntent?: BusinessIntent | undefined;
51
- }
52
-
53
- export interface RepairVerificationInput {
54
- proposal: RepairProposal;
55
- worktreePath: string;
56
- worktreeId: string;
57
- workspaceRoot?: string | undefined;
58
- configPath?: string | undefined;
59
- project?: string | undefined;
60
- businessOracleStatus?: 'verified' | 'contradicted' | 'unavailable' | undefined;
61
- }
62
-
63
- export interface VerifiedRepairResult {
64
- state: RepairLifecycleState;
65
- evidence: RepairEvidence;
66
- executionResult: ExecutionResult;
67
- intentValidation: IntentV2ValidationResult;
68
- executionProof: VerifiedExecutionProof;
69
- isReadyForApproval: boolean;
70
- businessOracleResults?: BusinessOracleResult[] | undefined;
71
- }
72
-
73
- export interface RepairPlanInput {
74
- originalActionId: string;
75
- originalSelector: string;
76
- candidateElement: ResolvedElement;
77
- candidateScore: number;
78
- testFilePath: string;
79
- pomFilePath: string;
80
- className: string;
81
- memberName: string;
82
- intendedAction: string;
83
- replacementSelector?: string | undefined;
84
- worktreeId?: string | undefined;
85
- }
86
-
87
- export interface StatementRepairPlanInput {
88
- originalActionId: string;
89
- originalSelector?: string | undefined;
90
- testFilePath: string;
91
- plan: StatementPatchPlan;
92
- candidateElement?: ResolvedElement | undefined;
93
- candidateScore?: number | undefined;
94
- intendedAction?: string | undefined;
95
- worktreeId?: string | undefined;
96
- }
97
-
98
- export class RepairPlanner {
99
- /**
100
- * Generates a modern, user-centric Playwright locator expression prioritizing getByRole and getByTestId.
101
- * Employs robust string escaping; avoids bare tag fallbacks.
102
- */
103
- public static generateSelectorExpression(el: ResolvedElement, pageProperty = 'this.page'): string {
104
- return CandidateExtractor.generateLocator(el, pageProperty);
105
- }
106
-
107
- /**
108
- * Generates a verified in-memory statement patch proposal using StatementPatcher.
109
- * Enables healing of inline test statements, timing synchronization, and sleep elimination.
110
- */
111
- public static planStatementRepair(input: StatementRepairPlanInput): RepairProposal {
112
- const origSource = fs.readFileSync(input.testFilePath, 'utf-8');
113
- const originalCompositeFingerprint = AssertionHasher.computeCompositeFingerprint(input.testFilePath);
114
-
115
- const patcher = new StatementPatcher();
116
- const patchResult = patcher.patchStatementInMemory(origSource, input.plan, path.basename(input.testFilePath));
117
-
118
- // Verify all original assertions are strictly preserved in post-patch
119
- const origAssertions = AssertionHasher.extractAssertionRecordsFromSource(origSource);
120
- const postAssertions = AssertionHasher.extractAssertionRecordsFromSource(patchResult.patchedSource);
121
- const postSet = new Set(postAssertions.map((a) => JSON.stringify(a)));
122
- const allOriginalAssertionsPreserved = origAssertions.every((a) => postSet.has(JSON.stringify(a)));
123
- if (!allOriginalAssertionsPreserved) {
124
- throw new Error('AST_ASSERTION_TAMPERING_PROHIBITED: Statement patch altered or removed test assertions.');
125
- }
126
-
127
- const dummyCandidate: ResolvedElement = input.candidateElement ?? {
128
- tag: 'statement',
129
- attributes: {},
130
- text: patchResult.replacementStatement,
131
- children: [],
132
- role: 'statement',
133
- };
134
-
135
- return {
136
- state: 'PROPOSED',
137
- originalActionId: input.originalActionId,
138
- originalSelector: input.originalSelector ?? patchResult.originalStatement,
139
- replacementSelector: patchResult.replacementStatement,
140
- candidateElement: dummyCandidate,
141
- candidateScore: input.candidateScore ?? 1.0,
142
- testFilePath: input.testFilePath,
143
- pomFilePath: input.testFilePath,
144
- className: 'InlineSpec',
145
- memberName: input.plan.kind,
146
- intendedAction: input.intendedAction ?? input.plan.kind,
147
- originalCompositeFingerprint,
148
- proposedPatchedSource: patchResult.patchedSource,
149
- originalSource: origSource,
150
- pattern: 'statement-patch',
151
- fullDiff: patchResult.diff,
152
- diffAllowlistPassed: true,
153
- businessIntent: extractBusinessIntent({
154
- id: input.originalActionId,
155
- sourceText: origSource,
156
- sourcePath: input.testFilePath,
157
- }),
158
- };
159
- }
160
-
161
- /**
162
- * Stage 1 & 2: Analyzes candidate and generates an in-memory repair proposal.
163
- * Does NOT modify source files on disk. Does NOT claim repair success.
164
- */
165
- public static planRepair(input: RepairPlanInput): RepairProposal {
166
- // 0. Read original source and discover page property identifier
167
- const origSource = fs.readFileSync(input.pomFilePath, 'utf-8');
168
- const pageProperty = CandidateExtractor.discoverPageProperty(origSource);
169
-
170
- // 1. Synthesize replacement locator expression with escaping
171
- const replacementLocator = input.replacementSelector ?? RepairPlanner.generateSelectorExpression(input.candidateElement, pageProperty);
172
-
173
- // 2. Validate replacement against Playwright locator grammar
174
- const grammarCheck = DiffAllowlistValidator.validateLocatorGrammar(replacementLocator);
175
- if (!grammarCheck.isValid) {
176
- throw new Error(`Invalid candidate locator generated: ${grammarCheck.reason}`);
177
- }
178
-
179
- // 3. Compute baseline composite fingerprint of the test spec
180
- const originalCompositeFingerprint = AssertionHasher.computeCompositeFingerprint(input.testFilePath);
181
-
182
- // 4. Generate in-memory AST patch without writing to disk
183
- const patcher = new AstPatcher();
184
- const inMemPatch = patcher.patchPageObjectInMemory(origSource, {
185
- className: input.className,
186
- memberName: input.memberName,
187
- replacementExpression: replacementLocator,
188
- });
189
-
190
- // 5. Validate diff allowlist in-memory
191
- const diffResult = DiffAllowlistValidator.validate({
192
- originalSource: origSource,
193
- patchedSource: inMemPatch.patchedSource,
194
- targetClassName: input.className,
195
- targetMemberName: input.memberName,
196
- });
197
-
198
- if (!diffResult.isAllowed) {
199
- throw new Error(`Diff allowlist rejected proposed patch: ${diffResult.reason}`);
200
- }
201
-
202
- return {
203
- state: 'PROPOSED',
204
- originalActionId: input.originalActionId,
205
- originalSelector: input.originalSelector,
206
- replacementSelector: replacementLocator,
207
- candidateElement: input.candidateElement,
208
- candidateScore: input.candidateScore,
209
- testFilePath: input.testFilePath,
210
- pomFilePath: input.pomFilePath,
211
- className: input.className,
212
- memberName: input.memberName,
213
- intendedAction: input.intendedAction,
214
- originalCompositeFingerprint,
215
- proposedPatchedSource: inMemPatch.patchedSource,
216
- originalSource: origSource,
217
- pattern: inMemPatch.pattern,
218
- fullDiff: inMemPatch.diff,
219
- diffAllowlistPassed: true,
220
- businessIntent: extractBusinessIntent({
221
- id: input.originalActionId,
222
- sourceText: fs.readFileSync(input.testFilePath, 'utf8'),
223
- sourcePath: input.testFilePath,
224
- }),
225
- };
226
- }
227
-
228
- /**
229
- * Stages 3-8: Applies patch strictly inside the isolated Git worktree, runs test execution,
230
- * validates Intent Invariant v2 with actual execution proof, and derives verified evidence.
231
- */
232
- public static async executeAndVerifyInWorktree(
233
- input: RepairVerificationInput
234
- ): Promise<VerifiedRepairResult> {
235
- const proposal = input.proposal;
236
- const rootDir = input.workspaceRoot ?? process.cwd();
237
-
238
- // Canonical containment checks
239
- const canonicalWorktree = fs.existsSync(input.worktreePath)
240
- ? fs.realpathSync(input.worktreePath)
241
- : path.resolve(input.worktreePath);
242
-
243
- const relPom = path.isAbsolute(proposal.pomFilePath)
244
- ? path.relative(rootDir, proposal.pomFilePath)
245
- : proposal.pomFilePath;
246
- const relSpec = path.isAbsolute(proposal.testFilePath)
247
- ? path.relative(rootDir, proposal.testFilePath)
248
- : proposal.testFilePath;
249
-
250
- const worktreePomPath = path.resolve(canonicalWorktree, relPom);
251
- const worktreeSpecPath = path.resolve(canonicalWorktree, relSpec);
252
-
253
- const relPomCheck = path.relative(canonicalWorktree, worktreePomPath);
254
- if (relPomCheck.startsWith('..') || path.isAbsolute(relPomCheck)) {
255
- throw new Error(`Security Violation: Worktree POM path '${worktreePomPath}' escapes worktree boundary '${canonicalWorktree}'.`);
256
- }
257
-
258
- const relSpecCheck = path.relative(canonicalWorktree, worktreeSpecPath);
259
- if (relSpecCheck.startsWith('..') || path.isAbsolute(relSpecCheck)) {
260
- throw new Error(`Security Violation: Worktree Spec path '${worktreeSpecPath}' escapes worktree boundary '${canonicalWorktree}'.`);
261
- }
262
-
263
- // Stage 3: Apply patch ONLY in isolated worktree copy
264
- const targetFilePath = proposal.pattern === 'statement-patch' ? worktreeSpecPath : worktreePomPath;
265
- await fs.promises.mkdir(path.dirname(targetFilePath), { recursive: true });
266
- await fs.promises.writeFile(targetFilePath, proposal.proposedPatchedSource, 'utf-8');
267
- let currentState: RepairLifecycleState = 'PATCHED_IN_ISOLATION';
268
-
269
- // Stage 4: Execute target test inside worktree (normalize to forward slashes for Playwright regex matcher)
270
- const normalizedRelSpec = relSpec.replace(/\\/g, '/');
271
- const execArgs = ['playwright', 'test', normalizedRelSpec];
272
- if (input.configPath) {
273
- const relConfig = path.isAbsolute(input.configPath)
274
- ? path.relative(rootDir, input.configPath)
275
- : input.configPath;
276
- execArgs.push('--config', relConfig.replace(/\\/g, '/'));
277
- }
278
- if (input.project) {
279
- execArgs.push('--project', input.project);
280
- }
281
-
282
- const execResult = await ExecutionRunner.run('npx', {
283
- cwd: canonicalWorktree,
284
- args: execArgs,
285
- timeoutMs: 60000,
286
- });
287
-
288
- // Verify test was actually discovered and passed
289
- const passedMatch = execResult.stdout.match(/(\d+)\s+passed/i);
290
- const passedCount = passedMatch ? parseInt(passedMatch[1] ?? '0', 10) : 0;
291
- const executionPassed = execResult.exitCode === 0 && passedCount > 0 && !execResult.timedOut;
292
-
293
- if (executionPassed) {
294
- currentState = 'TARGET_TEST_PASSED';
295
- }
296
-
297
- // Verify assertion integrity dynamically by comparing pre-patch and post-patch assertions
298
- const worktreeSpecSource = fs.existsSync(worktreeSpecPath)
299
- ? fs.readFileSync(worktreeSpecPath, 'utf-8')
300
- : '';
301
- const postExecCompositeFingerprint = AssertionHasher.computeCompositeFingerprintFromSource(worktreeSpecSource);
302
-
303
- let assertionIntegrityValid = false;
304
- if (proposal.pattern === 'statement-patch') {
305
- const origAssertions = AssertionHasher.extractAssertionRecordsFromSource(proposal.originalSource);
306
- const postAssertions = AssertionHasher.extractAssertionRecordsFromSource(worktreeSpecSource);
307
- const postSet = new Set(postAssertions.map((a) => JSON.stringify(a)));
308
- assertionIntegrityValid = origAssertions.length > 0
309
- ? origAssertions.every((a) => postSet.has(JSON.stringify(a)))
310
- : true;
311
- } else {
312
- assertionIntegrityValid =
313
- proposal.originalCompositeFingerprint.assertionFingerprint === postExecCompositeFingerprint.assertionFingerprint;
314
- }
315
-
316
- const executionProof: VerifiedExecutionProof = {
317
- testFilePath: worktreeSpecPath,
318
- targetTestIdentity: relSpec,
319
- command: `npx ${execArgs.join(' ')}`,
320
- worktreeId: input.worktreeId,
321
- repositoryIdentity: rootDir,
322
- originalSourceHash: ApprovalService.hashContent(proposal.originalSource),
323
- modifiedSourceHash: ApprovalService.hashContent(proposal.proposedPatchedSource),
324
- diffHash: ApprovalService.hashContent(proposal.fullDiff ?? proposal.proposedPatchedSource),
325
- exitCode: execResult.exitCode,
326
- stdout: execResult.stdout,
327
- stderr: execResult.stderr,
328
- passed: executionPassed,
329
- executionOutcome: executionPassed ? 'PASS' : 'FAIL',
330
- durationMs: execResult.durationMs,
331
- targetTestEvidence: [`Passed tests: ${passedCount}`, `Exit code: ${execResult.exitCode}`],
332
- assertionIntegrityValid,
333
- timestamp: Date.now(),
334
- proofVersion: PROOF_VERSION,
335
- };
336
-
337
- // Missing source evidence remains unavailable; it must never be treated
338
- // as proof of business correctness.
339
- const originalSpecPath = path.isAbsolute(proposal.testFilePath)
340
- ? proposal.testFilePath
341
- : path.resolve(rootDir, proposal.testFilePath);
342
- const originalSpecSource = fs.existsSync(originalSpecPath)
343
- ? fs.readFileSync(originalSpecPath, 'utf-8')
344
- : undefined;
345
- const currentSpecSource = fs.existsSync(worktreeSpecPath)
346
- ? fs.readFileSync(worktreeSpecPath, 'utf-8')
347
- : undefined;
348
- const builtInOracleEvaluation = evaluateBuiltInBusinessOracles({
349
- intent: proposal.businessIntent ?? extractBusinessIntent({
350
- id: proposal.originalActionId,
351
- sourceText: originalSpecSource ?? '',
352
- sourcePath: originalSpecPath,
353
- }),
354
- originalSourceText: originalSpecSource,
355
- currentSourceText: currentSpecSource,
356
- executionPassed,
357
- });
358
- const effectiveBusinessOracleStatus = input.businessOracleStatus === 'contradicted'
359
- || builtInOracleEvaluation.status === 'contradicted'
360
- ? 'contradicted'
361
- : input.businessOracleStatus ?? builtInOracleEvaluation.status;
362
-
363
- // Stage 5: Validate Intent Invariant v2 with actual execution proof
364
- const intentValidation = IntentValidator.validateV2({
365
- testFilePath: worktreeSpecPath,
366
- originalCompositeFingerprint: proposal.originalCompositeFingerprint,
367
- pageObjectOriginalSource: proposal.originalSource,
368
- pageObjectPatchedSource: proposal.proposedPatchedSource,
369
- pageObjectClassName: proposal.className,
370
- targetMemberName: proposal.memberName,
371
- candidateElement: proposal.candidateElement,
372
- intendedAction: proposal.intendedAction,
373
- originalSelector: proposal.originalSelector,
374
- executionPassed,
375
- executionProof,
376
- businessOracleStatus: effectiveBusinessOracleStatus,
377
- pattern: proposal.pattern,
378
- });
379
-
380
- const isFullyVerified = executionPassed && intentValidation.isValid;
381
- currentState = isFullyVerified ? 'READY_FOR_APPROVAL' : 'REJECTED';
382
-
383
- const evidence: RepairEvidence = {
384
- originalActionId: proposal.originalActionId,
385
- originalSelector: proposal.originalSelector,
386
- candidateSelector: proposal.replacementSelector,
387
- candidateScore: proposal.candidateScore,
388
- sourceLocation: {
389
- filePath: proposal.pomFilePath,
390
- className: proposal.className,
391
- memberName: proposal.memberName,
392
- nodeKind: proposal.pattern === 'property-initializer' ? 'property' : (proposal.pattern as any),
393
- line: 1,
394
- },
395
- targetIdentityEvidence: [
396
- `Tag: ${proposal.candidateElement.tag}`,
397
- `Role: ${proposal.candidateElement.role ?? 'none'}`,
398
- `Name: ${proposal.candidateElement.accessibleName ?? 'none'}`,
399
- ],
400
- assertionFingerprint: proposal.originalCompositeFingerprint.assertionFingerprint,
401
- businessOracleStatus: effectiveBusinessOracleStatus,
402
- verificationWorktreeId: input.worktreeId,
403
- repairedTestDurationMs: execResult.durationMs,
404
- timestamp: Date.now(),
405
- repairedSuccessfully: isFullyVerified,
406
- };
407
-
408
- return {
409
- state: currentState,
410
- evidence,
411
- executionResult: execResult,
412
- intentValidation,
413
- executionProof,
414
- isReadyForApproval: isFullyVerified,
415
- businessOracleResults: builtInOracleEvaluation.results,
416
- };
417
- }
418
-
419
- /**
420
- * Backward-compatible planAndExecute wrapper.
421
- * Generates in-memory repair proposal and validates intent in-memory.
422
- * Strictly PROHIBITS direct disk mutation without isolated worktree execution.
423
- */
424
- public static planAndExecute(input: RepairPlanInput): RepairEvidence {
425
- const proposal = RepairPlanner.planRepair(input);
426
-
427
- // Validate Intent Invariant (assertion immutability & action semantics)
428
- const validation = IntentValidator.validateProposal({
429
- testFilePath: input.testFilePath,
430
- originalCompositeFingerprint: proposal.originalCompositeFingerprint,
431
- candidateElement: input.candidateElement,
432
- intendedAction: input.intendedAction,
433
- originalSelector: input.originalSelector,
434
- businessOracleStatus: 'unavailable',
435
- });
436
-
437
- if (!validation.isValid) {
438
- throw new Error(`Intent validation failed: ${validation.reason ?? 'Invalid action semantics or identity mismatch'}`);
439
- }
440
-
441
- return {
442
- originalActionId: input.originalActionId,
443
- originalSelector: input.originalSelector,
444
- candidateSelector: proposal.replacementSelector,
445
- candidateScore: input.candidateScore,
446
- sourceLocation: {
447
- filePath: input.pomFilePath,
448
- className: input.className,
449
- memberName: input.memberName,
450
- nodeKind: proposal.pattern === 'property-initializer' ? 'property' : (proposal.pattern as any),
451
- line: 1,
452
- },
453
- targetIdentityEvidence: [
454
- `Tag: ${input.candidateElement.tag}`,
455
- `Role: ${input.candidateElement.role ?? 'none'}`,
456
- `Name: ${input.candidateElement.accessibleName ?? 'none'}`,
457
- ],
458
- assertionFingerprint: proposal.originalCompositeFingerprint.assertionFingerprint,
459
- businessOracleStatus: 'unavailable',
460
- verificationWorktreeId: input.worktreeId ?? 'unverified-in-memory',
461
- repairedTestDurationMs: 0,
462
- timestamp: Date.now(),
463
- repairedSuccessfully: false, // Direct modification prohibited; requires isolated worktree execution
464
- notes: 'Direct disk mutation prohibited. Proposal generated in-memory. Use executeAndVerifyInWorktree() for isolated execution proof.',
465
- };
466
- }
467
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { AstPatcher } from './ast-patcher.js';
4
+ import { StatementPatcher, StatementPatchPlan } from './statement-patcher.js';
5
+ import { AssertionHasher, CompositeIntentFingerprint } from './assertion-hasher.js';
6
+ import { IntentValidator, IntentV2ValidationResult, VerifiedExecutionProof } from './intent-validator.js';
7
+ import { DiffAllowlistValidator } from './diff-allowlist.js';
8
+ import { ApprovalService } from './approval-service.js';
9
+ import { RepairEvidence } from '../core/types/repair-evidence.js';
10
+ import { ResolvedElement } from '../trace/snapshot-resolver.js';
11
+ import { CandidateExtractor } from '../selector/candidate-extractor.js';
12
+ import { ExecutionRunner, ExecutionResult } from '../sandbox/execution-runner.js';
13
+ import { PROOF_VERSION } from '../core/version.js';
14
+ import {
15
+ BusinessIntent,
16
+ BusinessOracleResult,
17
+ evaluateBuiltInBusinessOracles,
18
+ extractBusinessIntent,
19
+ } from '../intelligence/business-intent.js';
20
+
21
+ export type RepairLifecycleState =
22
+ | 'ANALYZED'
23
+ | 'PROPOSED'
24
+ | 'PATCHED_IN_ISOLATION'
25
+ | 'INTENT_VALIDATED'
26
+ | 'TARGET_TEST_PASSED'
27
+ | 'AFFECTED_TESTS_PASSED'
28
+ | 'FULL_SUITE_PASSED'
29
+ | 'READY_FOR_APPROVAL'
30
+ | 'REJECTED';
31
+
32
+ export interface RepairProposal {
33
+ state: RepairLifecycleState;
34
+ originalActionId: string;
35
+ originalSelector: string;
36
+ replacementSelector: string;
37
+ candidateElement: ResolvedElement;
38
+ candidateScore: number;
39
+ testFilePath: string;
40
+ pomFilePath: string;
41
+ className: string;
42
+ memberName: string;
43
+ intendedAction: string;
44
+ originalCompositeFingerprint: CompositeIntentFingerprint;
45
+ proposedPatchedSource: string;
46
+ originalSource: string;
47
+ pattern: string;
48
+ fullDiff?: string | undefined;
49
+ diffAllowlistPassed: boolean;
50
+ businessIntent?: BusinessIntent | undefined;
51
+ }
52
+
53
+ export interface RepairVerificationInput {
54
+ proposal: RepairProposal;
55
+ worktreePath: string;
56
+ worktreeId: string;
57
+ workspaceRoot?: string | undefined;
58
+ configPath?: string | undefined;
59
+ project?: string | undefined;
60
+ businessOracleStatus?: 'verified' | 'contradicted' | 'unavailable' | undefined;
61
+ }
62
+
63
+ export interface VerifiedRepairResult {
64
+ state: RepairLifecycleState;
65
+ evidence: RepairEvidence;
66
+ executionResult: ExecutionResult;
67
+ intentValidation: IntentV2ValidationResult;
68
+ executionProof: VerifiedExecutionProof;
69
+ isReadyForApproval: boolean;
70
+ businessOracleResults?: BusinessOracleResult[] | undefined;
71
+ }
72
+
73
+ export interface RepairPlanInput {
74
+ originalActionId: string;
75
+ originalSelector: string;
76
+ candidateElement: ResolvedElement;
77
+ candidateScore: number;
78
+ testFilePath: string;
79
+ pomFilePath: string;
80
+ className: string;
81
+ memberName: string;
82
+ intendedAction: string;
83
+ replacementSelector?: string | undefined;
84
+ worktreeId?: string | undefined;
85
+ }
86
+
87
+ export interface StatementRepairPlanInput {
88
+ originalActionId: string;
89
+ originalSelector?: string | undefined;
90
+ testFilePath: string;
91
+ plan: StatementPatchPlan;
92
+ candidateElement?: ResolvedElement | undefined;
93
+ candidateScore?: number | undefined;
94
+ intendedAction?: string | undefined;
95
+ worktreeId?: string | undefined;
96
+ }
97
+
98
+ export class RepairPlanner {
99
+ /**
100
+ * Generates a modern, user-centric Playwright locator expression prioritizing getByRole and getByTestId.
101
+ * Employs robust string escaping; avoids bare tag fallbacks.
102
+ */
103
+ public static generateSelectorExpression(el: ResolvedElement, pageProperty = 'this.page'): string {
104
+ return CandidateExtractor.generateLocator(el, pageProperty);
105
+ }
106
+
107
+ /**
108
+ * Generates a verified in-memory statement patch proposal using StatementPatcher.
109
+ * Enables healing of inline test statements, timing synchronization, and sleep elimination.
110
+ */
111
+ public static planStatementRepair(input: StatementRepairPlanInput): RepairProposal {
112
+ const origSource = fs.readFileSync(input.testFilePath, 'utf-8');
113
+ const originalCompositeFingerprint = AssertionHasher.computeCompositeFingerprint(input.testFilePath);
114
+
115
+ const patcher = new StatementPatcher();
116
+ const patchResult = patcher.patchStatementInMemory(origSource, input.plan, path.basename(input.testFilePath));
117
+
118
+ // Verify all original assertions are strictly preserved in post-patch
119
+ const origAssertions = AssertionHasher.extractAssertionRecordsFromSource(origSource);
120
+ const postAssertions = AssertionHasher.extractAssertionRecordsFromSource(patchResult.patchedSource);
121
+ const postSet = new Set(postAssertions.map((a) => JSON.stringify(a)));
122
+ const allOriginalAssertionsPreserved = origAssertions.every((a) => postSet.has(JSON.stringify(a)));
123
+ if (!allOriginalAssertionsPreserved) {
124
+ throw new Error('AST_ASSERTION_TAMPERING_PROHIBITED: Statement patch altered or removed test assertions.');
125
+ }
126
+
127
+ const dummyCandidate: ResolvedElement = input.candidateElement ?? {
128
+ tag: 'statement',
129
+ attributes: {},
130
+ text: patchResult.replacementStatement,
131
+ children: [],
132
+ role: 'statement',
133
+ };
134
+
135
+ return {
136
+ state: 'PROPOSED',
137
+ originalActionId: input.originalActionId,
138
+ originalSelector: input.originalSelector ?? patchResult.originalStatement,
139
+ replacementSelector: patchResult.replacementStatement,
140
+ candidateElement: dummyCandidate,
141
+ candidateScore: input.candidateScore ?? 1.0,
142
+ testFilePath: input.testFilePath,
143
+ pomFilePath: input.testFilePath,
144
+ className: 'InlineSpec',
145
+ memberName: input.plan.kind,
146
+ intendedAction: input.intendedAction ?? input.plan.kind,
147
+ originalCompositeFingerprint,
148
+ proposedPatchedSource: patchResult.patchedSource,
149
+ originalSource: origSource,
150
+ pattern: 'statement-patch',
151
+ fullDiff: patchResult.diff,
152
+ diffAllowlistPassed: true,
153
+ businessIntent: extractBusinessIntent({
154
+ id: input.originalActionId,
155
+ sourceText: origSource,
156
+ sourcePath: input.testFilePath,
157
+ }),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Stage 1 & 2: Analyzes candidate and generates an in-memory repair proposal.
163
+ * Does NOT modify source files on disk. Does NOT claim repair success.
164
+ */
165
+ public static planRepair(input: RepairPlanInput): RepairProposal {
166
+ // 0. Read original source and discover page property identifier
167
+ const origSource = fs.readFileSync(input.pomFilePath, 'utf-8');
168
+ const pageProperty = CandidateExtractor.discoverPageProperty(origSource);
169
+
170
+ // 1. Synthesize replacement locator expression with escaping
171
+ const replacementLocator = input.replacementSelector ?? RepairPlanner.generateSelectorExpression(input.candidateElement, pageProperty);
172
+
173
+ // 2. Validate replacement against Playwright locator grammar
174
+ const grammarCheck = DiffAllowlistValidator.validateLocatorGrammar(replacementLocator);
175
+ if (!grammarCheck.isValid) {
176
+ throw new Error(`Invalid candidate locator generated: ${grammarCheck.reason}`);
177
+ }
178
+
179
+ // 3. Compute baseline composite fingerprint of the test spec
180
+ const originalCompositeFingerprint = AssertionHasher.computeCompositeFingerprint(input.testFilePath);
181
+
182
+ // 4. Generate in-memory AST patch without writing to disk
183
+ const patcher = new AstPatcher();
184
+ const inMemPatch = patcher.patchPageObjectInMemory(origSource, {
185
+ className: input.className,
186
+ memberName: input.memberName,
187
+ replacementExpression: replacementLocator,
188
+ });
189
+
190
+ // 5. Validate diff allowlist in-memory
191
+ const diffResult = DiffAllowlistValidator.validate({
192
+ originalSource: origSource,
193
+ patchedSource: inMemPatch.patchedSource,
194
+ targetClassName: input.className,
195
+ targetMemberName: input.memberName,
196
+ });
197
+
198
+ if (!diffResult.isAllowed) {
199
+ throw new Error(`Diff allowlist rejected proposed patch: ${diffResult.reason}`);
200
+ }
201
+
202
+ return {
203
+ state: 'PROPOSED',
204
+ originalActionId: input.originalActionId,
205
+ originalSelector: input.originalSelector,
206
+ replacementSelector: replacementLocator,
207
+ candidateElement: input.candidateElement,
208
+ candidateScore: input.candidateScore,
209
+ testFilePath: input.testFilePath,
210
+ pomFilePath: input.pomFilePath,
211
+ className: input.className,
212
+ memberName: input.memberName,
213
+ intendedAction: input.intendedAction,
214
+ originalCompositeFingerprint,
215
+ proposedPatchedSource: inMemPatch.patchedSource,
216
+ originalSource: origSource,
217
+ pattern: inMemPatch.pattern,
218
+ fullDiff: inMemPatch.diff,
219
+ diffAllowlistPassed: true,
220
+ businessIntent: extractBusinessIntent({
221
+ id: input.originalActionId,
222
+ sourceText: fs.readFileSync(input.testFilePath, 'utf8'),
223
+ sourcePath: input.testFilePath,
224
+ }),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Stages 3-8: Applies patch strictly inside the isolated Git worktree, runs test execution,
230
+ * validates Intent Invariant v2 with actual execution proof, and derives verified evidence.
231
+ */
232
+ public static async executeAndVerifyInWorktree(
233
+ input: RepairVerificationInput
234
+ ): Promise<VerifiedRepairResult> {
235
+ const proposal = input.proposal;
236
+ const rootDir = input.workspaceRoot ?? process.cwd();
237
+
238
+ // Canonical containment checks
239
+ const canonicalWorktree = fs.existsSync(input.worktreePath)
240
+ ? fs.realpathSync(input.worktreePath)
241
+ : path.resolve(input.worktreePath);
242
+
243
+ const relPom = path.isAbsolute(proposal.pomFilePath)
244
+ ? path.relative(rootDir, proposal.pomFilePath)
245
+ : proposal.pomFilePath;
246
+ const relSpec = path.isAbsolute(proposal.testFilePath)
247
+ ? path.relative(rootDir, proposal.testFilePath)
248
+ : proposal.testFilePath;
249
+
250
+ const worktreePomPath = path.resolve(canonicalWorktree, relPom);
251
+ const worktreeSpecPath = path.resolve(canonicalWorktree, relSpec);
252
+
253
+ const relPomCheck = path.relative(canonicalWorktree, worktreePomPath);
254
+ if (relPomCheck.startsWith('..') || path.isAbsolute(relPomCheck)) {
255
+ throw new Error(`Security Violation: Worktree POM path '${worktreePomPath}' escapes worktree boundary '${canonicalWorktree}'.`);
256
+ }
257
+
258
+ const relSpecCheck = path.relative(canonicalWorktree, worktreeSpecPath);
259
+ if (relSpecCheck.startsWith('..') || path.isAbsolute(relSpecCheck)) {
260
+ throw new Error(`Security Violation: Worktree Spec path '${worktreeSpecPath}' escapes worktree boundary '${canonicalWorktree}'.`);
261
+ }
262
+
263
+ // Stage 3: Apply patch ONLY in isolated worktree copy
264
+ const targetFilePath = proposal.pattern === 'statement-patch' ? worktreeSpecPath : worktreePomPath;
265
+ await fs.promises.mkdir(path.dirname(targetFilePath), { recursive: true });
266
+ await fs.promises.writeFile(targetFilePath, proposal.proposedPatchedSource, 'utf-8');
267
+ let currentState: RepairLifecycleState = 'PATCHED_IN_ISOLATION';
268
+
269
+ // Stage 4: Execute target test inside worktree (normalize to forward slashes for Playwright regex matcher)
270
+ const normalizedRelSpec = relSpec.replace(/\\/g, '/');
271
+ const execArgs = ['playwright', 'test', normalizedRelSpec];
272
+ if (input.configPath) {
273
+ const relConfig = path.isAbsolute(input.configPath)
274
+ ? path.relative(rootDir, input.configPath)
275
+ : input.configPath;
276
+ execArgs.push('--config', relConfig.replace(/\\/g, '/'));
277
+ }
278
+ if (input.project) {
279
+ execArgs.push('--project', input.project);
280
+ }
281
+
282
+ const execResult = await ExecutionRunner.run('npx', {
283
+ cwd: canonicalWorktree,
284
+ args: execArgs,
285
+ timeoutMs: 60000,
286
+ });
287
+
288
+ // Verify test was actually discovered and passed
289
+ const passedMatch = execResult.stdout.match(/(\d+)\s+passed/i);
290
+ const passedCount = passedMatch ? parseInt(passedMatch[1] ?? '0', 10) : 0;
291
+ const executionPassed = execResult.exitCode === 0 && passedCount > 0 && !execResult.timedOut;
292
+
293
+ if (executionPassed) {
294
+ currentState = 'TARGET_TEST_PASSED';
295
+ }
296
+
297
+ // Verify assertion integrity dynamically by comparing pre-patch and post-patch assertions
298
+ const worktreeSpecSource = fs.existsSync(worktreeSpecPath)
299
+ ? fs.readFileSync(worktreeSpecPath, 'utf-8')
300
+ : '';
301
+ const postExecCompositeFingerprint = AssertionHasher.computeCompositeFingerprintFromSource(worktreeSpecSource);
302
+
303
+ let assertionIntegrityValid = false;
304
+ if (proposal.pattern === 'statement-patch') {
305
+ const origAssertions = AssertionHasher.extractAssertionRecordsFromSource(proposal.originalSource);
306
+ const postAssertions = AssertionHasher.extractAssertionRecordsFromSource(worktreeSpecSource);
307
+ const postSet = new Set(postAssertions.map((a) => JSON.stringify(a)));
308
+ assertionIntegrityValid = origAssertions.length > 0
309
+ ? origAssertions.every((a) => postSet.has(JSON.stringify(a)))
310
+ : true;
311
+ } else {
312
+ assertionIntegrityValid =
313
+ proposal.originalCompositeFingerprint.assertionFingerprint === postExecCompositeFingerprint.assertionFingerprint;
314
+ }
315
+
316
+ const executionProof: VerifiedExecutionProof = {
317
+ testFilePath: worktreeSpecPath,
318
+ targetTestIdentity: relSpec,
319
+ command: `npx ${execArgs.join(' ')}`,
320
+ worktreeId: input.worktreeId,
321
+ repositoryIdentity: rootDir,
322
+ originalSourceHash: ApprovalService.hashContent(proposal.originalSource),
323
+ modifiedSourceHash: ApprovalService.hashContent(proposal.proposedPatchedSource),
324
+ diffHash: ApprovalService.hashContent(proposal.fullDiff ?? proposal.proposedPatchedSource),
325
+ exitCode: execResult.exitCode,
326
+ stdout: execResult.stdout,
327
+ stderr: execResult.stderr,
328
+ passed: executionPassed,
329
+ executionOutcome: executionPassed ? 'PASS' : 'FAIL',
330
+ durationMs: execResult.durationMs,
331
+ targetTestEvidence: [`Passed tests: ${passedCount}`, `Exit code: ${execResult.exitCode}`],
332
+ assertionIntegrityValid,
333
+ timestamp: Date.now(),
334
+ proofVersion: PROOF_VERSION,
335
+ };
336
+
337
+ // Missing source evidence remains unavailable; it must never be treated
338
+ // as proof of business correctness.
339
+ const originalSpecPath = path.isAbsolute(proposal.testFilePath)
340
+ ? proposal.testFilePath
341
+ : path.resolve(rootDir, proposal.testFilePath);
342
+ const originalSpecSource = fs.existsSync(originalSpecPath)
343
+ ? fs.readFileSync(originalSpecPath, 'utf-8')
344
+ : undefined;
345
+ const currentSpecSource = fs.existsSync(worktreeSpecPath)
346
+ ? fs.readFileSync(worktreeSpecPath, 'utf-8')
347
+ : undefined;
348
+ const builtInOracleEvaluation = evaluateBuiltInBusinessOracles({
349
+ intent: proposal.businessIntent ?? extractBusinessIntent({
350
+ id: proposal.originalActionId,
351
+ sourceText: originalSpecSource ?? '',
352
+ sourcePath: originalSpecPath,
353
+ }),
354
+ originalSourceText: originalSpecSource,
355
+ currentSourceText: currentSpecSource,
356
+ executionPassed,
357
+ });
358
+ const effectiveBusinessOracleStatus = input.businessOracleStatus === 'contradicted'
359
+ || builtInOracleEvaluation.status === 'contradicted'
360
+ ? 'contradicted'
361
+ : input.businessOracleStatus ?? builtInOracleEvaluation.status;
362
+
363
+ // Stage 5: Validate Intent Invariant v2 with actual execution proof
364
+ const intentValidation = IntentValidator.validateV2({
365
+ testFilePath: worktreeSpecPath,
366
+ originalCompositeFingerprint: proposal.originalCompositeFingerprint,
367
+ pageObjectOriginalSource: proposal.originalSource,
368
+ pageObjectPatchedSource: proposal.proposedPatchedSource,
369
+ pageObjectClassName: proposal.className,
370
+ targetMemberName: proposal.memberName,
371
+ candidateElement: proposal.candidateElement,
372
+ intendedAction: proposal.intendedAction,
373
+ originalSelector: proposal.originalSelector,
374
+ executionPassed,
375
+ executionProof,
376
+ businessOracleStatus: effectiveBusinessOracleStatus,
377
+ pattern: proposal.pattern,
378
+ });
379
+
380
+ const isFullyVerified = executionPassed && intentValidation.isValid;
381
+ currentState = isFullyVerified ? 'READY_FOR_APPROVAL' : 'REJECTED';
382
+
383
+ const evidence: RepairEvidence = {
384
+ originalActionId: proposal.originalActionId,
385
+ originalSelector: proposal.originalSelector,
386
+ candidateSelector: proposal.replacementSelector,
387
+ candidateScore: proposal.candidateScore,
388
+ sourceLocation: {
389
+ filePath: proposal.pomFilePath,
390
+ className: proposal.className,
391
+ memberName: proposal.memberName,
392
+ nodeKind: proposal.pattern === 'property-initializer' ? 'property' : (proposal.pattern as any),
393
+ line: 1,
394
+ },
395
+ targetIdentityEvidence: [
396
+ `Tag: ${proposal.candidateElement.tag}`,
397
+ `Role: ${proposal.candidateElement.role ?? 'none'}`,
398
+ `Name: ${proposal.candidateElement.accessibleName ?? 'none'}`,
399
+ ],
400
+ assertionFingerprint: proposal.originalCompositeFingerprint.assertionFingerprint,
401
+ businessOracleStatus: effectiveBusinessOracleStatus,
402
+ verificationWorktreeId: input.worktreeId,
403
+ repairedTestDurationMs: execResult.durationMs,
404
+ timestamp: Date.now(),
405
+ repairedSuccessfully: isFullyVerified,
406
+ };
407
+
408
+ return {
409
+ state: currentState,
410
+ evidence,
411
+ executionResult: execResult,
412
+ intentValidation,
413
+ executionProof,
414
+ isReadyForApproval: isFullyVerified,
415
+ businessOracleResults: builtInOracleEvaluation.results,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Backward-compatible planAndExecute wrapper.
421
+ * Generates in-memory repair proposal and validates intent in-memory.
422
+ * Strictly PROHIBITS direct disk mutation without isolated worktree execution.
423
+ */
424
+ public static planAndExecute(input: RepairPlanInput): RepairEvidence {
425
+ const proposal = RepairPlanner.planRepair(input);
426
+
427
+ // Validate Intent Invariant (assertion immutability & action semantics)
428
+ const validation = IntentValidator.validateProposal({
429
+ testFilePath: input.testFilePath,
430
+ originalCompositeFingerprint: proposal.originalCompositeFingerprint,
431
+ candidateElement: input.candidateElement,
432
+ intendedAction: input.intendedAction,
433
+ originalSelector: input.originalSelector,
434
+ businessOracleStatus: 'unavailable',
435
+ });
436
+
437
+ if (!validation.isValid) {
438
+ throw new Error(`Intent validation failed: ${validation.reason ?? 'Invalid action semantics or identity mismatch'}`);
439
+ }
440
+
441
+ return {
442
+ originalActionId: input.originalActionId,
443
+ originalSelector: input.originalSelector,
444
+ candidateSelector: proposal.replacementSelector,
445
+ candidateScore: input.candidateScore,
446
+ sourceLocation: {
447
+ filePath: input.pomFilePath,
448
+ className: input.className,
449
+ memberName: input.memberName,
450
+ nodeKind: proposal.pattern === 'property-initializer' ? 'property' : (proposal.pattern as any),
451
+ line: 1,
452
+ },
453
+ targetIdentityEvidence: [
454
+ `Tag: ${input.candidateElement.tag}`,
455
+ `Role: ${input.candidateElement.role ?? 'none'}`,
456
+ `Name: ${input.candidateElement.accessibleName ?? 'none'}`,
457
+ ],
458
+ assertionFingerprint: proposal.originalCompositeFingerprint.assertionFingerprint,
459
+ businessOracleStatus: 'unavailable',
460
+ verificationWorktreeId: input.worktreeId ?? 'unverified-in-memory',
461
+ repairedTestDurationMs: 0,
462
+ timestamp: Date.now(),
463
+ repairedSuccessfully: false, // Direct modification prohibited; requires isolated worktree execution
464
+ notes: 'Direct disk mutation prohibited. Proposal generated in-memory. Use executeAndVerifyInWorktree() for isolated execution proof.',
465
+ };
466
+ }
467
+ }