sela-core 0.1.0-alpha.1
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 +103 -0
- package/bin/sela.js +3 -0
- package/dist/cli/ErrorHandler.d.ts +10 -0
- package/dist/cli/ErrorHandler.d.ts.map +1 -0
- package/dist/cli/ErrorHandler.js +70 -0
- package/dist/cli/commands/bulk.d.ts +3 -0
- package/dist/cli/commands/bulk.d.ts.map +1 -0
- package/dist/cli/commands/bulk.js +158 -0
- package/dist/cli/commands/find.d.ts +3 -0
- package/dist/cli/commands/find.d.ts.map +1 -0
- package/dist/cli/commands/find.js +51 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +166 -0
- package/dist/cli/commands/list.d.ts +3 -0
- package/dist/cli/commands/list.d.ts.map +1 -0
- package/dist/cli/commands/list.js +56 -0
- package/dist/cli/commands/merge.d.ts +13 -0
- package/dist/cli/commands/merge.d.ts.map +1 -0
- package/dist/cli/commands/merge.js +119 -0
- package/dist/cli/commands/refactor.d.ts +3 -0
- package/dist/cli/commands/refactor.d.ts.map +1 -0
- package/dist/cli/commands/refactor.js +30 -0
- package/dist/cli/commands/showReport.d.ts +3 -0
- package/dist/cli/commands/showReport.d.ts.map +1 -0
- package/dist/cli/commands/showReport.js +82 -0
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +51 -0
- package/dist/cli/commands/sync.d.ts +3 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +131 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +94 -0
- package/dist/cli/ui/DnaTable.d.ts +3 -0
- package/dist/cli/ui/DnaTable.d.ts.map +1 -0
- package/dist/cli/ui/DnaTable.js +77 -0
- package/dist/cli/ui/LocatorPicker.d.ts +8 -0
- package/dist/cli/ui/LocatorPicker.d.ts.map +1 -0
- package/dist/cli/ui/LocatorPicker.js +33 -0
- package/dist/cli/ui/ProgressReporter.d.ts +11 -0
- package/dist/cli/ui/ProgressReporter.d.ts.map +1 -0
- package/dist/cli/ui/ProgressReporter.js +33 -0
- package/dist/cli/ui/RefactorWizard.d.ts +26 -0
- package/dist/cli/ui/RefactorWizard.d.ts.map +1 -0
- package/dist/cli/ui/RefactorWizard.js +295 -0
- package/dist/config/ConfigLoader.d.ts +16 -0
- package/dist/config/ConfigLoader.d.ts.map +1 -0
- package/dist/config/ConfigLoader.js +185 -0
- package/dist/config/DryRunGuard.d.ts +14 -0
- package/dist/config/DryRunGuard.d.ts.map +1 -0
- package/dist/config/DryRunGuard.js +80 -0
- package/dist/config/PathScopeGuard.d.ts +42 -0
- package/dist/config/PathScopeGuard.d.ts.map +1 -0
- package/dist/config/PathScopeGuard.js +192 -0
- package/dist/config/SelaConfig.d.ts +178 -0
- package/dist/config/SelaConfig.d.ts.map +1 -0
- package/dist/config/SelaConfig.js +141 -0
- package/dist/config/defineConfig.d.ts +3 -0
- package/dist/config/defineConfig.d.ts.map +1 -0
- package/dist/config/defineConfig.js +9 -0
- package/dist/engine/FixwrightEngine.d.ts +24 -0
- package/dist/engine/FixwrightEngine.d.ts.map +1 -0
- package/dist/engine/FixwrightEngine.js +403 -0
- package/dist/engine/HealingRegistry.d.ts +40 -0
- package/dist/engine/HealingRegistry.d.ts.map +1 -0
- package/dist/engine/HealingRegistry.js +99 -0
- package/dist/engine/SelaEngine.d.ts +57 -0
- package/dist/engine/SelaEngine.d.ts.map +1 -0
- package/dist/engine/SelaEngine.js +1146 -0
- package/dist/engine/singleton.d.ts +3 -0
- package/dist/engine/singleton.d.ts.map +1 -0
- package/dist/engine/singleton.js +5 -0
- package/dist/errors/SelaError.d.ts +146 -0
- package/dist/errors/SelaError.d.ts.map +1 -0
- package/dist/errors/SelaError.js +168 -0
- package/dist/fixtures/expectProxy.d.ts +12 -0
- package/dist/fixtures/expectProxy.d.ts.map +1 -0
- package/dist/fixtures/expectProxy.js +246 -0
- package/dist/fixtures/index.d.ts +30 -0
- package/dist/fixtures/index.d.ts.map +1 -0
- package/dist/fixtures/index.js +765 -0
- package/dist/fixtures/moduleExpect.d.ts +2 -0
- package/dist/fixtures/moduleExpect.d.ts.map +1 -0
- package/dist/fixtures/moduleExpect.js +54 -0
- package/dist/fixtures/proxyTag.d.ts +12 -0
- package/dist/fixtures/proxyTag.d.ts.map +1 -0
- package/dist/fixtures/proxyTag.js +45 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/reporter/SelaReporter.d.ts +165 -0
- package/dist/reporter/SelaReporter.d.ts.map +1 -0
- package/dist/reporter/SelaReporter.js +517 -0
- package/dist/services/ASTSourceUpdater.d.ts +143 -0
- package/dist/services/ASTSourceUpdater.d.ts.map +1 -0
- package/dist/services/ASTSourceUpdater.js +3514 -0
- package/dist/services/AnchorResolver.d.ts +157 -0
- package/dist/services/AnchorResolver.d.ts.map +1 -0
- package/dist/services/AnchorResolver.js +289 -0
- package/dist/services/ArgumentTypeAnalyzer.d.ts +26 -0
- package/dist/services/ArgumentTypeAnalyzer.d.ts.map +1 -0
- package/dist/services/ArgumentTypeAnalyzer.js +92 -0
- package/dist/services/BlastRadiusAnalyzer.d.ts +15 -0
- package/dist/services/BlastRadiusAnalyzer.d.ts.map +1 -0
- package/dist/services/BlastRadiusAnalyzer.js +107 -0
- package/dist/services/ChainValidator.d.ts +76 -0
- package/dist/services/ChainValidator.d.ts.map +1 -0
- package/dist/services/ChainValidator.js +570 -0
- package/dist/services/CrossFileHealer.d.ts +6 -0
- package/dist/services/CrossFileHealer.d.ts.map +1 -0
- package/dist/services/CrossFileHealer.js +135 -0
- package/dist/services/DecisionEngine.d.ts +51 -0
- package/dist/services/DecisionEngine.d.ts.map +1 -0
- package/dist/services/DecisionEngine.js +260 -0
- package/dist/services/DefinitionTracer.d.ts +41 -0
- package/dist/services/DefinitionTracer.d.ts.map +1 -0
- package/dist/services/DefinitionTracer.js +366 -0
- package/dist/services/DnaEditorService.d.ts +31 -0
- package/dist/services/DnaEditorService.d.ts.map +1 -0
- package/dist/services/DnaEditorService.js +217 -0
- package/dist/services/DnaIndexService.d.ts +24 -0
- package/dist/services/DnaIndexService.d.ts.map +1 -0
- package/dist/services/DnaIndexService.js +131 -0
- package/dist/services/HealBudget.d.ts +32 -0
- package/dist/services/HealBudget.d.ts.map +1 -0
- package/dist/services/HealBudget.js +85 -0
- package/dist/services/HealReportService.d.ts +161 -0
- package/dist/services/HealReportService.d.ts.map +1 -0
- package/dist/services/HealReportService.js +1039 -0
- package/dist/services/HealingAdvisory.d.ts +30 -0
- package/dist/services/HealingAdvisory.d.ts.map +1 -0
- package/dist/services/HealingAdvisory.js +51 -0
- package/dist/services/HealingCacheService.d.ts +135 -0
- package/dist/services/HealingCacheService.d.ts.map +1 -0
- package/dist/services/HealingCacheService.js +404 -0
- package/dist/services/HealthReportService.d.ts +10 -0
- package/dist/services/HealthReportService.d.ts.map +1 -0
- package/dist/services/HealthReportService.js +84 -0
- package/dist/services/InitializerUpdater.d.ts +16 -0
- package/dist/services/InitializerUpdater.d.ts.map +1 -0
- package/dist/services/InitializerUpdater.js +73 -0
- package/dist/services/IntentAuditor.d.ts +56 -0
- package/dist/services/IntentAuditor.d.ts.map +1 -0
- package/dist/services/IntentAuditor.js +326 -0
- package/dist/services/InteractiveReview.d.ts +15 -0
- package/dist/services/InteractiveReview.d.ts.map +1 -0
- package/dist/services/InteractiveReview.js +128 -0
- package/dist/services/LLMService.d.ts +121 -0
- package/dist/services/LLMService.d.ts.map +1 -0
- package/dist/services/LLMService.js +475 -0
- package/dist/services/MutationApplier.d.ts +69 -0
- package/dist/services/MutationApplier.d.ts.map +1 -0
- package/dist/services/MutationApplier.js +360 -0
- package/dist/services/PRAutomationService.d.ts +50 -0
- package/dist/services/PRAutomationService.d.ts.map +1 -0
- package/dist/services/PRAutomationService.js +760 -0
- package/dist/services/PendingPromptLedger.d.ts +51 -0
- package/dist/services/PendingPromptLedger.d.ts.map +1 -0
- package/dist/services/PendingPromptLedger.js +205 -0
- package/dist/services/ReportGenerator.d.ts +156 -0
- package/dist/services/ReportGenerator.d.ts.map +1 -0
- package/dist/services/ReportGenerator.js +278 -0
- package/dist/services/ReportMergeService.d.ts +95 -0
- package/dist/services/ReportMergeService.d.ts.map +1 -0
- package/dist/services/ReportMergeService.js +0 -0
- package/dist/services/SafetyGuard.d.ts +112 -0
- package/dist/services/SafetyGuard.d.ts.map +1 -0
- package/dist/services/SafetyGuard.js +87 -0
- package/dist/services/Sanitizer.d.ts +28 -0
- package/dist/services/Sanitizer.d.ts.map +1 -0
- package/dist/services/Sanitizer.js +142 -0
- package/dist/services/SanitizingAnthropic.d.ts +19 -0
- package/dist/services/SanitizingAnthropic.d.ts.map +1 -0
- package/dist/services/SanitizingAnthropic.js +84 -0
- package/dist/services/SelectorSanitizer.d.ts +52 -0
- package/dist/services/SelectorSanitizer.d.ts.map +1 -0
- package/dist/services/SelectorSanitizer.js +318 -0
- package/dist/services/SnapshotService.d.ts +11 -0
- package/dist/services/SnapshotService.d.ts.map +1 -0
- package/dist/services/SnapshotService.js +352 -0
- package/dist/services/SourceLinkService.d.ts +26 -0
- package/dist/services/SourceLinkService.d.ts.map +1 -0
- package/dist/services/SourceLinkService.js +165 -0
- package/dist/services/SourceUpdater.d.ts +45 -0
- package/dist/services/SourceUpdater.d.ts.map +1 -0
- package/dist/services/SourceUpdater.js +1091 -0
- package/dist/services/TemplateDiffService.d.ts +25 -0
- package/dist/services/TemplateDiffService.d.ts.map +1 -0
- package/dist/services/TemplateDiffService.js +334 -0
- package/dist/services/TraceBackEngine.d.ts +67 -0
- package/dist/services/TraceBackEngine.d.ts.map +1 -0
- package/dist/services/TraceBackEngine.js +672 -0
- package/dist/services/WorkspaceSnapshotService.d.ts +71 -0
- package/dist/services/WorkspaceSnapshotService.d.ts.map +1 -0
- package/dist/services/WorkspaceSnapshotService.js +200 -0
- package/dist/services/safety/PipelineContext.d.ts +63 -0
- package/dist/services/safety/PipelineContext.d.ts.map +1 -0
- package/dist/services/safety/PipelineContext.js +34 -0
- package/dist/services/safety/roleClassifier.d.ts +4 -0
- package/dist/services/safety/roleClassifier.d.ts.map +1 -0
- package/dist/services/safety/roleClassifier.js +233 -0
- package/dist/services/safety/rules/ConfidenceTierRule.d.ts +3 -0
- package/dist/services/safety/rules/ConfidenceTierRule.d.ts.map +1 -0
- package/dist/services/safety/rules/ConfidenceTierRule.js +32 -0
- package/dist/services/safety/rules/DangerousSinkRule.d.ts +10 -0
- package/dist/services/safety/rules/DangerousSinkRule.d.ts.map +1 -0
- package/dist/services/safety/rules/DangerousSinkRule.js +53 -0
- package/dist/services/safety/rules/FunctionalRoleRule.d.ts +3 -0
- package/dist/services/safety/rules/FunctionalRoleRule.d.ts.map +1 -0
- package/dist/services/safety/rules/FunctionalRoleRule.js +64 -0
- package/dist/services/safety/rules/HardConfidenceRule.d.ts +3 -0
- package/dist/services/safety/rules/HardConfidenceRule.d.ts.map +1 -0
- package/dist/services/safety/rules/HardConfidenceRule.js +22 -0
- package/dist/services/safety/rules/IntentAuditorRule.d.ts +3 -0
- package/dist/services/safety/rules/IntentAuditorRule.d.ts.map +1 -0
- package/dist/services/safety/rules/IntentAuditorRule.js +97 -0
- package/dist/services/safety/rules/SemanticOppositeRule.d.ts +3 -0
- package/dist/services/safety/rules/SemanticOppositeRule.d.ts.map +1 -0
- package/dist/services/safety/rules/SemanticOppositeRule.js +30 -0
- package/dist/services/safety/rules/StructuralOnlyRule.d.ts +3 -0
- package/dist/services/safety/rules/StructuralOnlyRule.d.ts.map +1 -0
- package/dist/services/safety/rules/StructuralOnlyRule.js +38 -0
- package/dist/services/safety/rules/VisibilityRule.d.ts +3 -0
- package/dist/services/safety/rules/VisibilityRule.d.ts.map +1 -0
- package/dist/services/safety/rules/VisibilityRule.js +53 -0
- package/dist/services/safety/rules/ZeroTrustRule.d.ts +3 -0
- package/dist/services/safety/rules/ZeroTrustRule.d.ts.map +1 -0
- package/dist/services/safety/rules/ZeroTrustRule.js +37 -0
- package/dist/services/safety/rules/index.d.ts +3 -0
- package/dist/services/safety/rules/index.d.ts.map +1 -0
- package/dist/services/safety/rules/index.js +34 -0
- package/dist/services/types.d.ts +59 -0
- package/dist/services/types.d.ts.map +1 -0
- package/dist/services/types.js +2 -0
- package/dist/storage/SnapshotManager.d.ts +5 -0
- package/dist/storage/SnapshotManager.d.ts.map +1 -0
- package/dist/storage/SnapshotManager.js +32 -0
- package/dist/types/index.d.ts +95 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +7 -0
- package/dist/utils/DOMUtils.d.ts +49 -0
- package/dist/utils/DOMUtils.d.ts.map +1 -0
- package/dist/utils/DOMUtils.js +466 -0
- package/dist/utils/FileLock.d.ts +44 -0
- package/dist/utils/FileLock.d.ts.map +1 -0
- package/dist/utils/FileLock.js +189 -0
- package/dist/utils/IsolatedDiff.d.ts +45 -0
- package/dist/utils/IsolatedDiff.d.ts.map +1 -0
- package/dist/utils/IsolatedDiff.js +382 -0
- package/dist/utils/StackUtils.d.ts +11 -0
- package/dist/utils/StackUtils.d.ts.map +1 -0
- package/dist/utils/StackUtils.js +120 -0
- package/dist/utils/SummaryTable.d.ts +17 -0
- package/dist/utils/SummaryTable.d.ts.map +1 -0
- package/dist/utils/SummaryTable.js +37 -0
- package/dist/utils/atomicWrite.d.ts +9 -0
- package/dist/utils/atomicWrite.d.ts.map +1 -0
- package/dist/utils/atomicWrite.js +83 -0
- package/dist/utils/logger.d.ts +59 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +123 -0
- package/dist/vendor/enquirer.d.ts +33 -0
- package/dist/vendor/enquirer.d.ts.map +1 -0
- package/dist/vendor/enquirer.js +11 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SelaEngine = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const LLMService_1 = require("../services/LLMService");
|
|
40
|
+
const WorkspaceSnapshotService_1 = require("../services/WorkspaceSnapshotService");
|
|
41
|
+
const IsolatedDiff_1 = require("../utils/IsolatedDiff");
|
|
42
|
+
const logger_1 = require("../utils/logger");
|
|
43
|
+
const SnapshotService_1 = require("../services/SnapshotService");
|
|
44
|
+
const DOMUtils_1 = require("../utils/DOMUtils");
|
|
45
|
+
const FileLock_1 = require("../utils/FileLock");
|
|
46
|
+
const SourceUpdater_1 = require("../services/SourceUpdater");
|
|
47
|
+
const ASTSourceUpdater_1 = require("../services/ASTSourceUpdater");
|
|
48
|
+
const SafetyGuard_1 = require("../services/SafetyGuard");
|
|
49
|
+
const HealingCacheService_1 = require("../services/HealingCacheService");
|
|
50
|
+
const ConfigLoader_1 = require("../config/ConfigLoader");
|
|
51
|
+
const DryRunGuard_1 = require("../config/DryRunGuard");
|
|
52
|
+
const HealBudget_1 = require("../services/HealBudget");
|
|
53
|
+
const HealReportService_1 = require("../services/HealReportService");
|
|
54
|
+
const PRAutomationService_1 = require("../services/PRAutomationService");
|
|
55
|
+
const PendingPromptLedger_1 = require("../services/PendingPromptLedger");
|
|
56
|
+
const SelaError_1 = require("../errors/SelaError");
|
|
57
|
+
const SelectorSanitizer_1 = require("../services/SelectorSanitizer");
|
|
58
|
+
/**
|
|
59
|
+
* Graceful percentage formatter for engine-side log lines. Mirrors the helper
|
|
60
|
+
* in PRAutomationService / HealReportService so a missing AI/auditor
|
|
61
|
+
* confidence reads as "n/a" instead of "undefined%" or "0%".
|
|
62
|
+
*/
|
|
63
|
+
function fmtPctEngine(v) {
|
|
64
|
+
return typeof v === "number" && Number.isFinite(v) ? `${v}%` : "n/a";
|
|
65
|
+
}
|
|
66
|
+
/** Convert an `internal:*` selector to idiomatic Playwright API notation for terminal display. */
|
|
67
|
+
function fmtSelector(sel) {
|
|
68
|
+
return (0, SelectorSanitizer_1.internalToMethodExpression)(sel, "page") ?? sel;
|
|
69
|
+
}
|
|
70
|
+
const EMPTY_DNA_SUMMARY = {
|
|
71
|
+
tagName: "unknown",
|
|
72
|
+
text: null,
|
|
73
|
+
role: null,
|
|
74
|
+
classes: [],
|
|
75
|
+
attributes: {},
|
|
76
|
+
ancestry: [],
|
|
77
|
+
closestLabel: null,
|
|
78
|
+
rowAnchor: null,
|
|
79
|
+
isGhost: false,
|
|
80
|
+
ghostReason: null,
|
|
81
|
+
isOccluded: false,
|
|
82
|
+
isInShadowDom: false,
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Truthy when the current process is a CI runner. We accept the standard
|
|
86
|
+
* `CI=1`/`CI=true` convention used by GitHub Actions, GitLab, CircleCI,
|
|
87
|
+
* Buildkite, etc. Anything else (empty string, undefined, `0`, `false`)
|
|
88
|
+
* is treated as Local DX mode.
|
|
89
|
+
*/
|
|
90
|
+
function isCiEnvironment() {
|
|
91
|
+
const raw = process.env.CI;
|
|
92
|
+
if (raw === undefined || raw === null)
|
|
93
|
+
return false;
|
|
94
|
+
const v = String(raw).trim().toLowerCase();
|
|
95
|
+
return v !== "" && v !== "0" && v !== "false";
|
|
96
|
+
}
|
|
97
|
+
class SelaEngine {
|
|
98
|
+
llmService;
|
|
99
|
+
snapshotService;
|
|
100
|
+
safetyGuard;
|
|
101
|
+
astDirectiveScanner;
|
|
102
|
+
healingCache;
|
|
103
|
+
config;
|
|
104
|
+
dnaBuffer = new Map();
|
|
105
|
+
healMetaBuffer = new Map();
|
|
106
|
+
testTitleBuffer = new Map();
|
|
107
|
+
constructor() {
|
|
108
|
+
this.config = ConfigLoader_1.ConfigLoader.getInstance();
|
|
109
|
+
// Seed the per-run heal budget (T2). null => unlimited.
|
|
110
|
+
HealBudget_1.sharedHealBudget.configure(this.config.maxHealsPerRun);
|
|
111
|
+
this.llmService = new LLMService_1.LLMService();
|
|
112
|
+
this.snapshotService = new SnapshotService_1.SnapshotService();
|
|
113
|
+
this.safetyGuard = new SafetyGuard_1.SafetyGuard(this.config.thresholds);
|
|
114
|
+
// Dedicated lightweight ts-morph project used only for the leading-comment
|
|
115
|
+
// pre-scan. Keeping it separate from the SourceUpdater's project avoids
|
|
116
|
+
// polluting that cache with files we may never actually mutate.
|
|
117
|
+
this.astDirectiveScanner = new ASTSourceUpdater_1.ASTSourceUpdater();
|
|
118
|
+
// Local healing cache - short-circuits the LLM call when an identical
|
|
119
|
+
// (filePath, oldSelector) pair was healed safely in a prior session.
|
|
120
|
+
// Disabled when SELA_NO_CACHE=1; reads/writes skipped during dry runs.
|
|
121
|
+
this.healingCache = HealingCacheService_1.sharedHealingCache;
|
|
122
|
+
}
|
|
123
|
+
// ─────────────────────────────────────────────────────────────
|
|
124
|
+
// healArgument - repairs a wrong action argument (e.g. selectOption)
|
|
125
|
+
// ─────────────────────────────────────────────────────────────
|
|
126
|
+
async healArgument(page, selector, action, oldArgument, stableId, filePath, line) {
|
|
127
|
+
logger_1.logger.stage("HEALING", `argument for '${action}': "${oldArgument}"`);
|
|
128
|
+
try {
|
|
129
|
+
const { dom } = await DOMUtils_1.DOMUtils.getNeighborhoodDom(page, selector);
|
|
130
|
+
const neighborhoodDom = dom;
|
|
131
|
+
const targetIntent = action === "selectOption"
|
|
132
|
+
? `ARGUMENT_HEALING: The selectOption argument "${oldArgument}" does not exist.
|
|
133
|
+
Look at the <select> element in the DOM and find all <option> elements inside it.
|
|
134
|
+
Return in 'new_selector' ONLY the TEXT CONTENT of the closest matching <option>.
|
|
135
|
+
For example: if the options are "Male" and "Female" and the old argument was "Man", return "Male".
|
|
136
|
+
CRITICAL: Do NOT return a CSS selector like "#my-select". Return plain text only, exactly as it appears in the option.`
|
|
137
|
+
: `Fix the broken argument "${oldArgument}" for action "${action}". Return the correct value.`;
|
|
138
|
+
// Max-heals budget gate (T2): argument heals are LLM-backed too.
|
|
139
|
+
if (!HealBudget_1.sharedHealBudget.tryConsume()) {
|
|
140
|
+
if (HealBudget_1.sharedHealBudget.shouldWarnOnce()) {
|
|
141
|
+
logger_1.logger.warn(`maxHealsPerRun limit (${HealBudget_1.sharedHealBudget.limit()}) reached — ` +
|
|
142
|
+
`halting new LLM heals. Already-applied fixes are preserved.`);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const aiFix = await this.llmService.getFix({
|
|
147
|
+
targetIntent,
|
|
148
|
+
failedSelector: `${selector}::${action}("${oldArgument}")`,
|
|
149
|
+
previousState: {},
|
|
150
|
+
currentDom: neighborhoodDom,
|
|
151
|
+
});
|
|
152
|
+
if (aiFix?.status === "FIXED" && aiFix.new_selector) {
|
|
153
|
+
const newArgument = aiFix.new_selector;
|
|
154
|
+
logger_1.logger.debug(`New argument: "${newArgument}"`);
|
|
155
|
+
SourceUpdater_1.SourceUpdater.updateArgument({ filePath, line }, action, oldArgument, newArgument);
|
|
156
|
+
return newArgument;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
logger_1.logger.error("Argument healing failed", error?.message);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// ─────────────────────────────────────────────────────────────
|
|
165
|
+
// heal - repairs a broken selector
|
|
166
|
+
// ─────────────────────────────────────────────────────────────
|
|
167
|
+
async heal(page, fullSelector, elementSelectorOnly, stableId, filePath, line, healMode = "action") {
|
|
168
|
+
logger_1.logger.stage("HEALING", stableId);
|
|
169
|
+
// Resolve file path for report purposes (project-relative when possible).
|
|
170
|
+
const reportFile = this._toReportFile(filePath);
|
|
171
|
+
const testTitle = this.testTitleBuffer.get(stableId);
|
|
172
|
+
// ── Developer fail-fast directive pre-gate ──────────────────────
|
|
173
|
+
// Scan the failing statement's leading comments for `// sela-fail-fast`
|
|
174
|
+
// BEFORE the expensive LLM round-trip. When present, record a
|
|
175
|
+
// "Skipped by Developer" FailedEvent and propagate the canonical
|
|
176
|
+
// SelaError(AST_HEAL_DISABLED_BY_DIRECTIVE) so retries are skipped too.
|
|
177
|
+
try {
|
|
178
|
+
this.astDirectiveScanner.enforceFailFastDirective({ filePath, line });
|
|
179
|
+
}
|
|
180
|
+
catch (directiveErr) {
|
|
181
|
+
if ((0, SelaError_1.isSelaErrorFrom)(directiveErr, "ASTUpdater") &&
|
|
182
|
+
directiveErr.code === SelaError_1.AST_UPDATER_CODES.HEAL_DISABLED_BY_DIRECTIVE) {
|
|
183
|
+
logger_1.logger.warn(`Skipped by Developer - ${directiveErr.reason}`);
|
|
184
|
+
const skipEvent = {
|
|
185
|
+
kind: "FAILED",
|
|
186
|
+
stableId,
|
|
187
|
+
sourceFile: reportFile,
|
|
188
|
+
sourceLine: line,
|
|
189
|
+
testTitle,
|
|
190
|
+
timestamp: new Date().toISOString(),
|
|
191
|
+
framePath: [],
|
|
192
|
+
inIframe: false,
|
|
193
|
+
inShadowDom: false,
|
|
194
|
+
oldSelector: elementSelectorOnly,
|
|
195
|
+
reason: "Skipped by Developer (sela-fail-fast directive)",
|
|
196
|
+
dnaBefore: null,
|
|
197
|
+
};
|
|
198
|
+
HealReportService_1.sharedHealReport.record(skipEvent);
|
|
199
|
+
throw directiveErr;
|
|
200
|
+
}
|
|
201
|
+
throw directiveErr;
|
|
202
|
+
}
|
|
203
|
+
// Snapshot for report event context (loaded once below, used at all exit paths).
|
|
204
|
+
let lastKnownStateForReport = null;
|
|
205
|
+
let aiFixForReport = null;
|
|
206
|
+
try {
|
|
207
|
+
// load() now always returns ElementSnapshotV2 (v1 snapshots are
|
|
208
|
+
// migrated transparently; v2 fields default to safe values).
|
|
209
|
+
const lastKnownState = await this.snapshotService.load(stableId);
|
|
210
|
+
lastKnownStateForReport = lastKnownState;
|
|
211
|
+
// ── Extract v2 DNA context ────────────────────────────────
|
|
212
|
+
const dnaAncestry = lastKnownState?.ancestry ?? [];
|
|
213
|
+
const dnaAnchors = lastKnownState?.anchors;
|
|
214
|
+
logger_1.logger.debug("Step 2: extracting neighborhood DOM...");
|
|
215
|
+
const { dom: neighborhoodDom, successfulPath } = await DOMUtils_1.DOMUtils.getNeighborhoodDom(page, fullSelector);
|
|
216
|
+
if (!neighborhoodDom || neighborhoodDom.length === 0) {
|
|
217
|
+
throw new Error("Failed to extract any DOM context for AI");
|
|
218
|
+
}
|
|
219
|
+
logger_1.logger.debug(`Step 2: DOM extracted (${neighborhoodDom.length} chars)`);
|
|
220
|
+
const contextHint = successfulPath.length > 0
|
|
221
|
+
? `\n\n[CONTEXT HINT]: The provided DOM is extracted from INSIDE this frame path: "${successfulPath.join(" >> ")}". ` +
|
|
222
|
+
`If you find the element, your suggested selector should be relative to THIS context.`
|
|
223
|
+
: "";
|
|
224
|
+
const oldText = lastKnownState?.text ?? null;
|
|
225
|
+
const dnaRole = lastKnownState?.role ?? undefined;
|
|
226
|
+
const intentDescription = lastKnownState
|
|
227
|
+
? `The element that was a ${lastKnownState.tagName} with text "${lastKnownState.text}"`
|
|
228
|
+
: `Element identified by ${stableId}`;
|
|
229
|
+
const contentChangeInstructions = `
|
|
230
|
+
|
|
231
|
+
[CONTENT CHANGE DETECTION]:
|
|
232
|
+
If the element's VISIBLE TEXT has changed compared to its previous value${oldText ? ` ("${oldText}")` : ""}, you MUST include a "contentChange" field.
|
|
233
|
+
NOTE: This change will NOT be applied automatically to the source code to prevent masking potential bugs.
|
|
234
|
+
It will only be presented as a suggested fix in the terminal.
|
|
235
|
+
{
|
|
236
|
+
"contentChange": {
|
|
237
|
+
"oldText": "<the previous visible text>",
|
|
238
|
+
"newText": "<the new visible text as it appears in the current DOM>"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
This is required so that any expect(...).toHaveText("${oldText ?? "..."}") assertions in the test file can be automatically updated to match the new text.
|
|
242
|
+
If the text has NOT changed, omit the "contentChange" field entirely.`;
|
|
243
|
+
// ── Semantic anchor context hint for the LLM ─────────────────────
|
|
244
|
+
// When DNA v2 anchor data exists, tell the AI which label and row
|
|
245
|
+
// key the element was associated with so it can use semantic landmarks
|
|
246
|
+
// to disambiguate identical controls (e.g. multiple "Edit" buttons).
|
|
247
|
+
const anchorContextHint = (() => {
|
|
248
|
+
const anchors = lastKnownState?.anchors;
|
|
249
|
+
if (!anchors)
|
|
250
|
+
return "";
|
|
251
|
+
const lines = [];
|
|
252
|
+
if (anchors.closestLabel) {
|
|
253
|
+
lines.push(`- Label: "${anchors.closestLabel}" - the UI label or heading associated with this element.`);
|
|
254
|
+
}
|
|
255
|
+
if (anchors.rowAnchor) {
|
|
256
|
+
lines.push(`- Row key: "${anchors.rowAnchor}" - unique cell text identifying the table row this element belonged to.`);
|
|
257
|
+
}
|
|
258
|
+
if (lines.length === 0)
|
|
259
|
+
return "";
|
|
260
|
+
return ("\n\n[ELEMENT SEMANTIC CONTEXT]:\n" +
|
|
261
|
+
"The DNA snapshot recorded these semantic landmarks for the element you must find:\n" +
|
|
262
|
+
lines.join("\n") +
|
|
263
|
+
"\n" +
|
|
264
|
+
"Prioritize selectors that preserve these relationships (e.g. filter by row key, then target by label).");
|
|
265
|
+
})();
|
|
266
|
+
// ── Cache pre-check ────────────────────────────────────────────
|
|
267
|
+
// When a prior session healed this exact (filePath, oldSelector)
|
|
268
|
+
// pair AND SafetyGuard cleared it, replay the cached fix instead
|
|
269
|
+
// of paying the LLM round-trip. Disabled when SELA_NO_CACHE=1.
|
|
270
|
+
// The synthetic aiFix is fed through SafetyGuard exactly like a
|
|
271
|
+
// fresh LLM response - cache is a latency optimisation, never a
|
|
272
|
+
// safety bypass.
|
|
273
|
+
let aiFix = null;
|
|
274
|
+
let cacheHit = false;
|
|
275
|
+
// Fingerprint inputs the cache key depends on. Recomputed every
|
|
276
|
+
// heal so a DNA rewrite - which changes `dnaContentHash` -
|
|
277
|
+
// automatically invalidates every prior entry without an explicit
|
|
278
|
+
// purge.
|
|
279
|
+
const dnaContentHash = (0, HealingCacheService_1.hashDnaContent)(lastKnownState ?? null);
|
|
280
|
+
const cacheKeyInput = {
|
|
281
|
+
filePath,
|
|
282
|
+
line,
|
|
283
|
+
dnaContentHash,
|
|
284
|
+
brokenSelector: fullSelector,
|
|
285
|
+
mode: healMode,
|
|
286
|
+
};
|
|
287
|
+
if (!this.config.noCache) {
|
|
288
|
+
const cached = this.healingCache.get(cacheKeyInput);
|
|
289
|
+
if (cached) {
|
|
290
|
+
logger_1.logger.debug(`Cache hit (0ms LLM) - replaying healed selector "${cached.healedSelector}"`);
|
|
291
|
+
aiFix = {
|
|
292
|
+
status: "FIXED",
|
|
293
|
+
new_selector: cached.healedSelector,
|
|
294
|
+
chainSegments: cached.chainSegments,
|
|
295
|
+
segments: cached.segments ?? [
|
|
296
|
+
{ type: "element", selector: cached.healedSelector },
|
|
297
|
+
],
|
|
298
|
+
confidence: cached.confidence,
|
|
299
|
+
contentChange: cached.contentChange,
|
|
300
|
+
explanation: cached.explanation ?? "Replayed from local healing cache",
|
|
301
|
+
};
|
|
302
|
+
cacheHit = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!cacheHit) {
|
|
306
|
+
// ── Max-heals budget gate (T2) ───────────────────────────────
|
|
307
|
+
// Reserve a slot BEFORE the await (synchronous check-and-reserve).
|
|
308
|
+
// Cache hits skip this branch entirely, so replays never count.
|
|
309
|
+
if (!HealBudget_1.sharedHealBudget.tryConsume()) {
|
|
310
|
+
if (HealBudget_1.sharedHealBudget.shouldWarnOnce()) {
|
|
311
|
+
logger_1.logger.warn(`maxHealsPerRun limit (${HealBudget_1.sharedHealBudget.limit()}) reached — ` +
|
|
312
|
+
`halting new LLM heals. Already-applied fixes are preserved.`);
|
|
313
|
+
}
|
|
314
|
+
// Surfaced as a FAILED event by the catch block below.
|
|
315
|
+
throw new Error("maxHealsPerRun reached");
|
|
316
|
+
}
|
|
317
|
+
logger_1.logger.debug("Step 3: requesting AI fix from LLM...");
|
|
318
|
+
aiFix = await this.llmService.getFix({
|
|
319
|
+
targetIntent: intentDescription,
|
|
320
|
+
failedSelector: fullSelector,
|
|
321
|
+
previousState: lastKnownState || {},
|
|
322
|
+
currentDom: neighborhoodDom +
|
|
323
|
+
contextHint +
|
|
324
|
+
anchorContextHint +
|
|
325
|
+
contentChangeInstructions,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
aiFixForReport = aiFix;
|
|
329
|
+
if (aiFix && aiFix.status === "FIXED") {
|
|
330
|
+
logger_1.logger.debug("Step 3: AI responded");
|
|
331
|
+
// ── Build new selector ────────────────────────────────────
|
|
332
|
+
const aiSegments = aiFix.segments || [];
|
|
333
|
+
const newSegmentsFromAI = aiSegments.filter((aiSeg) => !successfulPath.includes(aiSeg.selector));
|
|
334
|
+
const newFullSelector = [
|
|
335
|
+
...successfulPath,
|
|
336
|
+
...newSegmentsFromAI.map((s) => s.selector),
|
|
337
|
+
].join(" >> ");
|
|
338
|
+
const rawElementSelector = newSegmentsFromAI.map((s) => s.selector).join(" >> ") ||
|
|
339
|
+
newFullSelector;
|
|
340
|
+
const framePrefix = successfulPath.join(" >> ");
|
|
341
|
+
const elementOnlySelector = framePrefix && rawElementSelector.startsWith(framePrefix + " >> ")
|
|
342
|
+
? rawElementSelector.slice(framePrefix.length + 4)
|
|
343
|
+
: rawElementSelector;
|
|
344
|
+
// ── Step 3.5: Fetch live text (lightweight zero-trust probe) ─
|
|
345
|
+
const liveText = await this.fetchLiveText(page, elementOnlySelector, successfulPath);
|
|
346
|
+
const dnaBaselineText = oldText ?? undefined;
|
|
347
|
+
if (liveText !== null) {
|
|
348
|
+
logger_1.logger.debug(`Step 3.5: live text fetched: "${liveText}"` +
|
|
349
|
+
(dnaBaselineText ? ` (DNA baseline: "${dnaBaselineText}")` : ""));
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
logger_1.logger.debug("Step 3.5: live text fetch skipped (element not yet rendered or non-text)");
|
|
353
|
+
}
|
|
354
|
+
// ── Step 3.6: Atomic v2 capture of the candidate element ──
|
|
355
|
+
// Single page.evaluate() call - collects visibility, ancestry,
|
|
356
|
+
// and anchors of the healed element for SafetyGuard use.
|
|
357
|
+
// Returns null on any failure; never blocks the heal.
|
|
358
|
+
const liveSnapshot = await this.snapshotService.captureElement(page, elementOnlySelector, successfulPath);
|
|
359
|
+
const liveVisibility = liveSnapshot?.visibility;
|
|
360
|
+
const liveAncestry = liveSnapshot?.ancestry;
|
|
361
|
+
const liveAnchors = liveSnapshot?.anchors;
|
|
362
|
+
if (liveSnapshot) {
|
|
363
|
+
const ghostFlag = liveVisibility?.isGhost
|
|
364
|
+
? ` 👻 GHOST(${liveVisibility.ghostReason})`
|
|
365
|
+
: "";
|
|
366
|
+
const occFlag = liveVisibility?.isOccluded ? " 🫥 OCCLUDED" : "";
|
|
367
|
+
logger_1.logger.debug(`Step 3.6: live v2 capture complete` +
|
|
368
|
+
`${ghostFlag}${occFlag} - ancestry depth: ${liveAncestry?.length ?? 0}`);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
logger_1.logger.debug("Step 3.6: live v2 capture skipped");
|
|
372
|
+
}
|
|
373
|
+
// ── Safety: delegate all go/no-go decisions to SafetyGuard ─
|
|
374
|
+
const branch = ConfigLoader_1.ConfigLoader.getBranch() ?? undefined;
|
|
375
|
+
const safetyDecision = await this.safetyGuard.evaluate({ mode: healMode, branch }, {
|
|
376
|
+
confidence: aiFix.confidence,
|
|
377
|
+
contentChange: aiFix.contentChange,
|
|
378
|
+
explanation: aiFix.explanation,
|
|
379
|
+
liveText: liveText ?? undefined,
|
|
380
|
+
dnaBaselineText,
|
|
381
|
+
dnaRole,
|
|
382
|
+
// v2 structural context
|
|
383
|
+
liveVisibility,
|
|
384
|
+
liveAncestry,
|
|
385
|
+
liveAnchors,
|
|
386
|
+
dnaAncestry,
|
|
387
|
+
dnaAnchors,
|
|
388
|
+
});
|
|
389
|
+
// Three-way action (decision #1, quarantine):
|
|
390
|
+
// APPLY → write + use the heal (fall through to the write path)
|
|
391
|
+
// HOLD → propose but DO NOT write or execute this run
|
|
392
|
+
// REJECT → refuse outright
|
|
393
|
+
const disposition = safetyDecision.disposition ?? (0, SafetyGuard_1.dispositionFor)(safetyDecision.level);
|
|
394
|
+
logger_1.logger.debug(`SafetyGuard [${safetyDecision.level}] (${disposition}) ${safetyDecision.reason}`);
|
|
395
|
+
if (disposition !== "APPLY") {
|
|
396
|
+
if (safetyDecision.level === "BLOCKED" &&
|
|
397
|
+
safetyDecision.meta?.auditorVerdict === "SUSPICIOUS") {
|
|
398
|
+
const strictness = this.config.thresholds.auditorStrictness;
|
|
399
|
+
const conf = safetyDecision.meta.auditorConfidence ?? "?";
|
|
400
|
+
const suggestion = strictness === "strict" ? "Balanced" : "Loose";
|
|
401
|
+
logger_1.logger.warn(`Auditor status: SUSPICIOUS (Confidence: ${conf}%)`);
|
|
402
|
+
logger_1.logger.warn(`Policy: '${strictness}' (Branch: ${branch ?? "unknown"}). Auto-fix blocked.`);
|
|
403
|
+
logger_1.logger.info(`To allow this, change 'auditorStrictness' to '${suggestion}' in sela.config.ts.`);
|
|
404
|
+
}
|
|
405
|
+
if (disposition === "HOLD") {
|
|
406
|
+
logger_1.logger.warn(`Heal QUARANTINED (${safetyDecision.level}) - proposed but NOT ` +
|
|
407
|
+
`written to source and NOT executed this run. Approve to apply. ` +
|
|
408
|
+
`Candidate: ${fmtSelector(newFullSelector)}`);
|
|
409
|
+
}
|
|
410
|
+
// ── Report: PROTECTED ───────────────────────────────────────
|
|
411
|
+
// REJECT → the auto-fix was blocked (FAIL_HARD / BLOCKED). If the
|
|
412
|
+
// auditor verdict is INCONSISTENT this is a "Security Win" - a
|
|
413
|
+
// masked regression was caught.
|
|
414
|
+
// HOLD → the heal is quarantined for review; recorded with
|
|
415
|
+
// safetyLevel REQUIRES_REVIEW so the report can render it as an
|
|
416
|
+
// amber proposal, distinct from a red block.
|
|
417
|
+
// In neither case is source written or the candidate executed.
|
|
418
|
+
const protectedEvent = {
|
|
419
|
+
kind: "PROTECTED",
|
|
420
|
+
stableId,
|
|
421
|
+
sourceFile: reportFile,
|
|
422
|
+
sourceLine: line,
|
|
423
|
+
testTitle,
|
|
424
|
+
timestamp: new Date().toISOString(),
|
|
425
|
+
framePath: successfulPath,
|
|
426
|
+
inIframe: successfulPath.length > 0,
|
|
427
|
+
inShadowDom: liveSnapshot?.isInShadowDom ?? false,
|
|
428
|
+
oldSelector: elementSelectorOnly,
|
|
429
|
+
candidateNewSelector: newFullSelector,
|
|
430
|
+
reason: safetyDecision.reason,
|
|
431
|
+
safetyLevel: safetyDecision.level,
|
|
432
|
+
auditor: this._auditorBlockFromDecision(safetyDecision.meta),
|
|
433
|
+
aiConfidence: aiFix.confidence,
|
|
434
|
+
aiExplanation: aiFix.explanation ?? "",
|
|
435
|
+
dnaBefore: (0, HealReportService_1.summariseSnapshot)(lastKnownState),
|
|
436
|
+
dnaAfter: (0, HealReportService_1.summariseSnapshot)(liveSnapshot ?? null),
|
|
437
|
+
contentChange: aiFix.contentChange,
|
|
438
|
+
};
|
|
439
|
+
HealReportService_1.sharedHealReport.record(protectedEvent);
|
|
440
|
+
const rejectionCode = disposition === "HOLD"
|
|
441
|
+
? (safetyDecision.code ?? SelaError_1.SAFETY_GUARD_CODES.HELD_FOR_REVIEW)
|
|
442
|
+
: (safetyDecision.code ?? SelaError_1.SAFETY_GUARD_CODES.GENERIC_REJECTION);
|
|
443
|
+
throw new SelaError_1.SelaError({
|
|
444
|
+
subsystem: "SafetyGuard",
|
|
445
|
+
code: rejectionCode,
|
|
446
|
+
reason: safetyDecision.reason,
|
|
447
|
+
context: {
|
|
448
|
+
filePath: reportFile,
|
|
449
|
+
line,
|
|
450
|
+
selector: elementSelectorOnly,
|
|
451
|
+
healedSelector: newFullSelector,
|
|
452
|
+
stableId,
|
|
453
|
+
testTitle,
|
|
454
|
+
branch,
|
|
455
|
+
safetyLevel: safetyDecision.level,
|
|
456
|
+
auditorVerdict: safetyDecision.meta?.auditorVerdict,
|
|
457
|
+
auditorConfidence: safetyDecision.meta?.auditorConfidence,
|
|
458
|
+
aiConfidence: aiFix.confidence,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
// ── Heal-completion score summary ─────────────────────────────────
|
|
463
|
+
// Emits a single consolidated line showing AI confidence plus the
|
|
464
|
+
// auditor's structural adjustments (ancestry drift / anchor bonus).
|
|
465
|
+
const breakdown = safetyDecision.meta?.auditScoreBreakdown;
|
|
466
|
+
if (breakdown && (breakdown.penalty !== 0 || breakdown.bonus !== 0)) {
|
|
467
|
+
const parts = [];
|
|
468
|
+
if (breakdown.penalty !== 0)
|
|
469
|
+
parts.push(`${breakdown.penalty} ancestry drift`);
|
|
470
|
+
if (breakdown.bonus !== 0)
|
|
471
|
+
parts.push(`+${breakdown.bonus} anchor match`);
|
|
472
|
+
logger_1.logger.debug(`Score - AI: ${fmtPctEngine(aiFix.confidence)} | ` +
|
|
473
|
+
`Auditor: ${fmtPctEngine(breakdown.adjustedConfidence)} ` +
|
|
474
|
+
`(raw ${fmtPctEngine(breakdown.rawConfidence)}, ${parts.join(", ")})`);
|
|
475
|
+
}
|
|
476
|
+
logger_1.logger.debug(`Rebuilt full runtime selector: ${newFullSelector}`);
|
|
477
|
+
// ── PRIMARY: composeHeal trace-back pipeline (Stages 1-4) ──────────
|
|
478
|
+
// Trace the failing call-site to its source declaration and apply a
|
|
479
|
+
// write-disciplined Method-Shift / in-place mutation through the
|
|
480
|
+
// type-safety gate. Restricted to plain in-place writes (commentOnly /
|
|
481
|
+
// branch / autoCommit / dry-run keep the legacy git-aware path).
|
|
482
|
+
// Falls back to the legacy regex/semantic engine on ABORT / PROMPT.
|
|
483
|
+
let updateResult;
|
|
484
|
+
const plainInPlace = (!this.config.updateStrategy ||
|
|
485
|
+
this.config.updateStrategy === "inPlace") &&
|
|
486
|
+
!this.config.autoCommit;
|
|
487
|
+
const resolvedForCompose = SourceUpdater_1.SourceUpdater.resolveFilePath(filePath);
|
|
488
|
+
if (plainInPlace && !DryRunGuard_1.DryRunGuard.active() && resolvedForCompose) {
|
|
489
|
+
try {
|
|
490
|
+
WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.preWrite(resolvedForCompose);
|
|
491
|
+
const composed = ASTSourceUpdater_1.ASTSourceUpdater.composeHeal({
|
|
492
|
+
filePath: resolvedForCompose,
|
|
493
|
+
line,
|
|
494
|
+
newSelector: newFullSelector,
|
|
495
|
+
apply: true,
|
|
496
|
+
applyOptions: { respectDryRun: true },
|
|
497
|
+
});
|
|
498
|
+
const strat = composed.decision?.strategy;
|
|
499
|
+
if (composed.kind === "decision" &&
|
|
500
|
+
composed.outcome?.status === "applied" &&
|
|
501
|
+
(strat === "MUTATE_IN_PLACE" || strat === "FORK_AT_TEST")) {
|
|
502
|
+
WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.postWrite(resolvedForCompose);
|
|
503
|
+
const wl = composed.writtenLine ?? line;
|
|
504
|
+
logger_1.logger.stage("ANALYZING AST", `composeHeal primary path (${strat})`);
|
|
505
|
+
updateResult = {
|
|
506
|
+
success: true,
|
|
507
|
+
reason: `composeHeal[${strat}]: ${composed.decision.reason}`,
|
|
508
|
+
lineUpdated: wl - 1,
|
|
509
|
+
healedLocatorString: newFullSelector,
|
|
510
|
+
definitionSite: { file: resolvedForCompose, line: wl - 1 },
|
|
511
|
+
blastRadius: composed.decision.blastRadius,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
const why = composed.kind === "abort"
|
|
516
|
+
? `abort:${composed.trace.code ?? composed.trace.status}`
|
|
517
|
+
: `${strat ?? "no-decision"}/${composed.outcome?.status ?? "not-applied"}`;
|
|
518
|
+
logger_1.logger.debug(`composeHeal declined (${why}) - falling back to legacy heal`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
logger_1.logger.debug(`composeHeal primary failed (${e?.message}) - falling back to legacy heal`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// ── FALLBACK: legacy regex/semantic heal (also handles dry-run /
|
|
526
|
+
// commentOnly / branch / autoCommit / cross-file). ──────────────
|
|
527
|
+
if (!updateResult) {
|
|
528
|
+
updateResult = SourceUpdater_1.SourceUpdater.update({ filePath, line }, elementSelectorOnly, newFullSelector, neighborhoodDom, undefined, fullSelector, aiFix.segments, aiFix.contentChange, aiFix.chainSegments, aiFix.originalChainHint, this.config.updateStrategy, this.config.autoCommit);
|
|
529
|
+
}
|
|
530
|
+
// Resolve the line that was actually mutated. Order of precedence:
|
|
531
|
+
// 1. updateResult.lineUpdated (the strategy's literal write location)
|
|
532
|
+
// 2. updateResult.definitionSite.line (semantic JumpToDef target,
|
|
533
|
+
// used when the mutation crossed into a const/variable declaration)
|
|
534
|
+
// 3. line (the original failure line - last-resort fallback)
|
|
535
|
+
// For cross-file definitionSite.file, we still read the new line from
|
|
536
|
+
// the SAME file that was mutated.
|
|
537
|
+
const defSite = updateResult.definitionSite;
|
|
538
|
+
const sameFileDef = defSite && defSite.file
|
|
539
|
+
? path.resolve(defSite.file) ===
|
|
540
|
+
(SourceUpdater_1.SourceUpdater.resolveFilePath(filePath) ?? "")
|
|
541
|
+
: true;
|
|
542
|
+
const writtenLine = updateResult.lineUpdated ??
|
|
543
|
+
(sameFileDef ? defSite?.line : undefined) ??
|
|
544
|
+
line;
|
|
545
|
+
const mutatedFilePath = !sameFileDef && defSite ? defSite.file : filePath;
|
|
546
|
+
// Clean-Room isolated diff. We diff the in-memory snapshot recorded
|
|
547
|
+
// by WorkspaceSnapshotService (contentBefore - disk state right
|
|
548
|
+
// before Sela's first write to this file) against the post-write
|
|
549
|
+
// disk state. The result contains EXCLUSIVELY Sela's selector swap;
|
|
550
|
+
// any developer dirty edits sit identically in both buffers and
|
|
551
|
+
// therefore cancel out. Falls back to "" when the snapshot is
|
|
552
|
+
// missing (cross-file healing into an untracked path, etc.).
|
|
553
|
+
const gitUnifiedDiff = this._captureIsolatedDiff(mutatedFilePath);
|
|
554
|
+
const firstChange = this._extractFirstChangeFromDiff(gitUnifiedDiff);
|
|
555
|
+
// Backward-compat: keep oldCodeLine/newCodeLine populated from the
|
|
556
|
+
// first -/+ pair inside the diff. When git produced no diff (file
|
|
557
|
+
// untracked, git unavailable, …) fall back to a direct disk read of
|
|
558
|
+
// the mutated line - strictly inferior but preserves old reports.
|
|
559
|
+
const oldCodeLine = firstChange.oldLine ??
|
|
560
|
+
this._safeReadLine(mutatedFilePath, writtenLine);
|
|
561
|
+
const newCodeLine = firstChange.newLine ??
|
|
562
|
+
this._safeReadLine(mutatedFilePath, writtenLine);
|
|
563
|
+
const canonicalSelector = updateResult.healedLocatorString ?? newFullSelector;
|
|
564
|
+
logger_1.logger.debug(`Canonical selector (disk == runtime): "${canonicalSelector}"`);
|
|
565
|
+
// Buffer CLI-ready metadata - merged into DNA at commitUpdates() time.
|
|
566
|
+
// Sanitize filePath: strip any residual "at " stack-trace prefix + whitespace,
|
|
567
|
+
// then resolve to an absolute path before converting to project-relative form.
|
|
568
|
+
const match = filePath.match(/at\s+(.*)/i);
|
|
569
|
+
// 2. אם מצאנו התאמה ניקח אותה, אחרת ניקח את המחרוזת המקורית.
|
|
570
|
+
// בנוסף, ננקה רווחים וסוגריים (לפעמים נתיבים ב-Stack trace מוקפים בסוגריים).
|
|
571
|
+
const cleanFilePath = (match ? match[1] : filePath)
|
|
572
|
+
.replace(/^\(|\)$/g, "") // מסיר סוגריים אם קיימים ( )
|
|
573
|
+
.trim();
|
|
574
|
+
// 3. הפיכה לנתיב אבסולוטי ואז ליחסי (כמו שעשית, שזה מעולה)
|
|
575
|
+
const absFilePath = cleanFilePath && path.isAbsolute(cleanFilePath)
|
|
576
|
+
? cleanFilePath
|
|
577
|
+
: cleanFilePath
|
|
578
|
+
? path.resolve(process.cwd(), cleanFilePath)
|
|
579
|
+
: "";
|
|
580
|
+
const relativeSourceFile = absFilePath
|
|
581
|
+
? path.relative(process.cwd(), absFilePath)
|
|
582
|
+
: "";
|
|
583
|
+
this.healMetaBuffer.set(stableId, {
|
|
584
|
+
selector: canonicalSelector,
|
|
585
|
+
sourceFile: relativeSourceFile,
|
|
586
|
+
sourceLine: line,
|
|
587
|
+
originalSelector: elementSelectorOnly,
|
|
588
|
+
healedSelector: canonicalSelector,
|
|
589
|
+
lastHealed: new Date().toISOString(),
|
|
590
|
+
status: "healed",
|
|
591
|
+
definitionSite: updateResult.definitionSite,
|
|
592
|
+
blastRadius: updateResult.blastRadius,
|
|
593
|
+
});
|
|
594
|
+
// ── Report: HEALED event ───────────────────────────────────────
|
|
595
|
+
const healedEvent = {
|
|
596
|
+
kind: "HEALED",
|
|
597
|
+
stableId,
|
|
598
|
+
sourceFile: relativeSourceFile || reportFile,
|
|
599
|
+
sourceLine: line,
|
|
600
|
+
testTitle,
|
|
601
|
+
timestamp: new Date().toISOString(),
|
|
602
|
+
framePath: successfulPath,
|
|
603
|
+
inIframe: successfulPath.length > 0,
|
|
604
|
+
inShadowDom: liveSnapshot?.isInShadowDom ?? false,
|
|
605
|
+
dryRun: DryRunGuard_1.DryRunGuard.active() ? true : undefined,
|
|
606
|
+
oldSelector: elementSelectorOnly,
|
|
607
|
+
newSelector: canonicalSelector,
|
|
608
|
+
oldCodeLine,
|
|
609
|
+
newCodeLine,
|
|
610
|
+
newLineNumber: writtenLine,
|
|
611
|
+
gitUnifiedDiff: gitUnifiedDiff || undefined,
|
|
612
|
+
aiConfidence: aiFix.confidence,
|
|
613
|
+
aiExplanation: aiFix.explanation ?? "",
|
|
614
|
+
aiAlternatives: this._buildAlternatives(aiFix.chainSegments, aiFix.segments),
|
|
615
|
+
reasoningSteps: this._buildReasoningSteps({
|
|
616
|
+
aiFix,
|
|
617
|
+
dnaText: lastKnownState?.text ?? null,
|
|
618
|
+
dnaTag: lastKnownState?.tagName ?? null,
|
|
619
|
+
safetyLevel: safetyDecision.level,
|
|
620
|
+
strategy: updateResult.reason || "ast-update",
|
|
621
|
+
writtenLine,
|
|
622
|
+
blastRadius: updateResult.blastRadius,
|
|
623
|
+
successfulPath,
|
|
624
|
+
}),
|
|
625
|
+
auditor: this._auditorBlockFromDecision(safetyDecision.meta),
|
|
626
|
+
dnaBefore: (0, HealReportService_1.summariseSnapshot)(lastKnownState) ?? EMPTY_DNA_SUMMARY,
|
|
627
|
+
dnaAfter: (0, HealReportService_1.summariseSnapshot)(liveSnapshot ?? null),
|
|
628
|
+
diffStrategy: updateResult.reason || "ast-update",
|
|
629
|
+
blastRadius: updateResult.blastRadius,
|
|
630
|
+
definitionSite: updateResult.definitionSite,
|
|
631
|
+
contentChange: aiFix.contentChange,
|
|
632
|
+
};
|
|
633
|
+
HealReportService_1.sharedHealReport.record(healedEvent);
|
|
634
|
+
// ── Cache write - persist this safe heal for future sessions ──
|
|
635
|
+
// Best-effort: failures are logged inside the service and never
|
|
636
|
+
// block the heal return. Skipped automatically during dry runs
|
|
637
|
+
// and when SELA_NO_CACHE=1.
|
|
638
|
+
if (!this.config.noCache && !cacheHit) {
|
|
639
|
+
this.healingCache
|
|
640
|
+
.set({
|
|
641
|
+
...cacheKeyInput,
|
|
642
|
+
healedSelector: canonicalSelector,
|
|
643
|
+
chainSegments: aiFix.chainSegments,
|
|
644
|
+
segments: aiFix.segments,
|
|
645
|
+
confidence: aiFix.confidence,
|
|
646
|
+
contentChange: aiFix.contentChange,
|
|
647
|
+
explanation: aiFix.explanation,
|
|
648
|
+
alternatives: this._buildAlternatives(aiFix.chainSegments, aiFix.segments),
|
|
649
|
+
safe: true,
|
|
650
|
+
})
|
|
651
|
+
.catch((err) => logger_1.logger.debug(`HealingCache set() failed: ${err?.message ?? err}`));
|
|
652
|
+
}
|
|
653
|
+
logger_1.logger.success(`Healed ${fmtSelector(elementSelectorOnly)} → ${fmtSelector(canonicalSelector)}` +
|
|
654
|
+
(DryRunGuard_1.DryRunGuard.active() ? " (dry-run)" : ""));
|
|
655
|
+
return canonicalSelector;
|
|
656
|
+
}
|
|
657
|
+
// AI did not return FIXED - record as FAILED.
|
|
658
|
+
const failedEvent = {
|
|
659
|
+
kind: "FAILED",
|
|
660
|
+
stableId,
|
|
661
|
+
sourceFile: reportFile,
|
|
662
|
+
sourceLine: line,
|
|
663
|
+
testTitle,
|
|
664
|
+
timestamp: new Date().toISOString(),
|
|
665
|
+
framePath: [],
|
|
666
|
+
inIframe: false,
|
|
667
|
+
inShadowDom: lastKnownStateForReport?.isInShadowDom ?? false,
|
|
668
|
+
oldSelector: elementSelectorOnly,
|
|
669
|
+
reason: "AI could not provide a fix (NOT_FOUND)",
|
|
670
|
+
aiConfidence: aiFix?.confidence,
|
|
671
|
+
aiExplanation: aiFix?.explanation,
|
|
672
|
+
dnaBefore: (0, HealReportService_1.summariseSnapshot)(lastKnownStateForReport),
|
|
673
|
+
};
|
|
674
|
+
HealReportService_1.sharedHealReport.record(failedEvent);
|
|
675
|
+
throw new Error("AI could not provide a fix");
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
logger_1.logger.error(`Healing failed for ${stableId}`, error?.message);
|
|
679
|
+
// Avoid double-recording when a SafetyGuard PROTECTED event was already pushed.
|
|
680
|
+
// Prefer the structured instanceof check; fall back to message-substring
|
|
681
|
+
// detection for any legacy throw sites that have not yet been migrated.
|
|
682
|
+
const isSafetyBlock = (0, SelaError_1.isSelaErrorFrom)(error, "SafetyGuard") ||
|
|
683
|
+
(typeof error?.message === "string" &&
|
|
684
|
+
error.message.includes("[Sela-SafetyGuard]")) ||
|
|
685
|
+
(typeof error?.message === "string" &&
|
|
686
|
+
error.message.includes("[SafetyGuard]"));
|
|
687
|
+
const isNotFoundAlreadyRecorded = typeof error?.message === "string" &&
|
|
688
|
+
error.message === "AI could not provide a fix";
|
|
689
|
+
if (!isSafetyBlock && !isNotFoundAlreadyRecorded) {
|
|
690
|
+
const failedEvent = {
|
|
691
|
+
kind: "FAILED",
|
|
692
|
+
stableId,
|
|
693
|
+
sourceFile: reportFile,
|
|
694
|
+
sourceLine: line,
|
|
695
|
+
testTitle,
|
|
696
|
+
timestamp: new Date().toISOString(),
|
|
697
|
+
framePath: [],
|
|
698
|
+
inIframe: false,
|
|
699
|
+
inShadowDom: lastKnownStateForReport?.isInShadowDom ?? false,
|
|
700
|
+
oldSelector: elementSelectorOnly,
|
|
701
|
+
reason: error?.message ?? "Unknown heal failure",
|
|
702
|
+
aiConfidence: aiFixForReport?.confidence,
|
|
703
|
+
aiExplanation: aiFixForReport?.explanation,
|
|
704
|
+
dnaBefore: (0, HealReportService_1.summariseSnapshot)(lastKnownStateForReport),
|
|
705
|
+
};
|
|
706
|
+
HealReportService_1.sharedHealReport.record(failedEvent);
|
|
707
|
+
}
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// ─────────────────────────────────────────────────────────────
|
|
712
|
+
// Report helpers
|
|
713
|
+
// ─────────────────────────────────────────────────────────────
|
|
714
|
+
_toReportFile(filePath) {
|
|
715
|
+
if (!filePath)
|
|
716
|
+
return "";
|
|
717
|
+
const match = filePath.match(/at\s+(.*)/i);
|
|
718
|
+
const cleaned = (match ? match[1] : filePath)
|
|
719
|
+
.replace(/^\(|\)$/g, "")
|
|
720
|
+
.trim();
|
|
721
|
+
const abs = path.isAbsolute(cleaned)
|
|
722
|
+
? cleaned
|
|
723
|
+
: path.resolve(process.cwd(), cleaned);
|
|
724
|
+
return path.relative(process.cwd(), abs) || abs;
|
|
725
|
+
}
|
|
726
|
+
_safeReadLine(filePath, lineNumber) {
|
|
727
|
+
try {
|
|
728
|
+
const cleaned = this._toReportFile(filePath);
|
|
729
|
+
const abs = path.isAbsolute(cleaned)
|
|
730
|
+
? cleaned
|
|
731
|
+
: path.resolve(process.cwd(), cleaned);
|
|
732
|
+
if (!fs.existsSync(abs))
|
|
733
|
+
return "";
|
|
734
|
+
const lines = fs.readFileSync(abs, "utf8").split(/\r?\n/);
|
|
735
|
+
const idx = Math.max(0, Math.min(lines.length - 1, lineNumber - 1));
|
|
736
|
+
return lines[idx] ?? "";
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return "";
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Clean-Room isolated diff: render a unified diff between the file's
|
|
744
|
+
* pre-Sela disk snapshot (`contentBefore`) and the post-write disk state
|
|
745
|
+
* (`contentAfter`) captured by `WorkspaceSnapshotService`. Returns "" when
|
|
746
|
+
* the file was never tracked (no snapshot) or the AST mutation produced
|
|
747
|
+
* no net change.
|
|
748
|
+
*
|
|
749
|
+
* This is what makes the HTML report immune to developer dirty edits: any
|
|
750
|
+
* pre-existing uncommitted changes live in BOTH buffers identically and
|
|
751
|
+
* are therefore filtered out by the diff. The output contains only Sela's
|
|
752
|
+
* selector swap.
|
|
753
|
+
*/
|
|
754
|
+
_captureIsolatedDiff(filePath) {
|
|
755
|
+
try {
|
|
756
|
+
const cleaned = this._toReportFile(filePath);
|
|
757
|
+
const abs = path.isAbsolute(cleaned)
|
|
758
|
+
? cleaned
|
|
759
|
+
: path.resolve(process.cwd(), cleaned);
|
|
760
|
+
if (!abs)
|
|
761
|
+
return "";
|
|
762
|
+
const snap = WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.getSnapshot(abs);
|
|
763
|
+
if (!snap || snap.contentAfter === null)
|
|
764
|
+
return "";
|
|
765
|
+
if (snap.contentBefore === snap.contentAfter)
|
|
766
|
+
return "";
|
|
767
|
+
return (0, IsolatedDiff_1.generateUnifiedDiff)(snap.contentBefore, snap.contentAfter, {
|
|
768
|
+
filePath: snap.relativePath,
|
|
769
|
+
context: 1,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
logger_1.logger.debug(`isolated diff capture skipped: ${err?.message ?? err}`);
|
|
774
|
+
return "";
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Walk a unified diff and pull out the first removed line and the first
|
|
779
|
+
* added line so the legacy report fields (`oldCodeLine`, `newCodeLine`)
|
|
780
|
+
* stay populated for callers that haven't migrated to the full
|
|
781
|
+
* `gitUnifiedDiff` payload yet.
|
|
782
|
+
*
|
|
783
|
+
* Carefully skips the diff preamble (--- a/file, +++ b/file, "diff ",
|
|
784
|
+
* "index ", rename headers) - those lines would otherwise be mistaken for
|
|
785
|
+
* a -/+ change pair and produce nonsense like the original "line replaced
|
|
786
|
+
* by itself" output. Real change lines only count once the parser has seen
|
|
787
|
+
* a "@@" hunk header.
|
|
788
|
+
*/
|
|
789
|
+
_extractFirstChangeFromDiff(diff) {
|
|
790
|
+
if (!diff)
|
|
791
|
+
return {};
|
|
792
|
+
let inHunk = false;
|
|
793
|
+
let oldLine;
|
|
794
|
+
let newLine;
|
|
795
|
+
for (const row of diff.split(/\r?\n/)) {
|
|
796
|
+
if (!inHunk) {
|
|
797
|
+
if (row.startsWith("@@"))
|
|
798
|
+
inHunk = true;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
// Defensive: a fresh @@ header starts a new hunk but does not reset
|
|
802
|
+
// the values we've already captured.
|
|
803
|
+
if (row.startsWith("@@"))
|
|
804
|
+
continue;
|
|
805
|
+
if (row.startsWith("---") || row.startsWith("+++"))
|
|
806
|
+
continue;
|
|
807
|
+
if (row.startsWith("\\"))
|
|
808
|
+
continue;
|
|
809
|
+
if (row.startsWith("-") && oldLine === undefined) {
|
|
810
|
+
oldLine = row.slice(1);
|
|
811
|
+
}
|
|
812
|
+
else if (row.startsWith("+") && newLine === undefined) {
|
|
813
|
+
newLine = row.slice(1);
|
|
814
|
+
}
|
|
815
|
+
if (oldLine !== undefined && newLine !== undefined)
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
return { oldLine, newLine };
|
|
819
|
+
}
|
|
820
|
+
_auditorBlockFromDecision(meta) {
|
|
821
|
+
if (!meta || !meta.auditorVerdict)
|
|
822
|
+
return null;
|
|
823
|
+
// Keep auditor confidence as `undefined` when SafetyGuard did not produce
|
|
824
|
+
// one - the renderer fmtPct helper turns it into "n/a" rather than 0%.
|
|
825
|
+
const conf = typeof meta.auditorConfidence === "number" &&
|
|
826
|
+
Number.isFinite(meta.auditorConfidence)
|
|
827
|
+
? meta.auditorConfidence
|
|
828
|
+
: undefined;
|
|
829
|
+
return {
|
|
830
|
+
verdict: meta.auditorVerdict,
|
|
831
|
+
confidence: conf,
|
|
832
|
+
reason: meta.auditScoreBreakdown
|
|
833
|
+
? `Adjusted ${meta.auditScoreBreakdown.rawConfidence}% → ${meta.auditScoreBreakdown.adjustedConfidence}% (penalty ${meta.auditScoreBreakdown.penalty}, bonus +${meta.auditScoreBreakdown.bonus}).`
|
|
834
|
+
: "Auditor verdict recorded by SafetyGuard.",
|
|
835
|
+
inversionType: null,
|
|
836
|
+
rawConfidence: meta.auditScoreBreakdown?.rawConfidence,
|
|
837
|
+
penalty: meta.auditScoreBreakdown?.penalty,
|
|
838
|
+
bonus: meta.auditScoreBreakdown?.bonus,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
_buildAlternatives(chainSegments, segments) {
|
|
842
|
+
const out = [];
|
|
843
|
+
if (chainSegments && chainSegments.length) {
|
|
844
|
+
for (const seg of chainSegments) {
|
|
845
|
+
switch (seg.type) {
|
|
846
|
+
case "locator":
|
|
847
|
+
out.push(`locator(${JSON.stringify(seg.selector)})`);
|
|
848
|
+
break;
|
|
849
|
+
case "frameLocator":
|
|
850
|
+
out.push(`frameLocator(${JSON.stringify(seg.selector)})`);
|
|
851
|
+
break;
|
|
852
|
+
case "getByRole":
|
|
853
|
+
out.push(`getByRole(${JSON.stringify(seg.role)}${seg.name ? ", { name: " + JSON.stringify(seg.name) + (seg.exact ? ", exact: true" : "") + " }" : ""})`);
|
|
854
|
+
break;
|
|
855
|
+
case "getByLabel":
|
|
856
|
+
out.push(`getByLabel(${JSON.stringify(seg.text)}${seg.exact ? ", { exact: true }" : ""})`);
|
|
857
|
+
break;
|
|
858
|
+
case "getByText":
|
|
859
|
+
out.push(`getByText(${JSON.stringify(seg.text)}${seg.exact ? ", { exact: true }" : ""})`);
|
|
860
|
+
break;
|
|
861
|
+
case "getByPlaceholder":
|
|
862
|
+
out.push(`getByPlaceholder(${JSON.stringify(seg.text)})`);
|
|
863
|
+
break;
|
|
864
|
+
case "getByTestId":
|
|
865
|
+
out.push(`getByTestId(${JSON.stringify(seg.testId)})`);
|
|
866
|
+
break;
|
|
867
|
+
case "getByAltText":
|
|
868
|
+
out.push(`getByAltText(${JSON.stringify(seg.text)})`);
|
|
869
|
+
break;
|
|
870
|
+
case "getByTitle":
|
|
871
|
+
out.push(`getByTitle(${JSON.stringify(seg.text)})`);
|
|
872
|
+
break;
|
|
873
|
+
case "filter":
|
|
874
|
+
out.push(`filter(${JSON.stringify(seg)})`);
|
|
875
|
+
break;
|
|
876
|
+
case "first":
|
|
877
|
+
out.push("first()");
|
|
878
|
+
break;
|
|
879
|
+
case "last":
|
|
880
|
+
out.push("last()");
|
|
881
|
+
break;
|
|
882
|
+
case "nth":
|
|
883
|
+
out.push(`nth(${seg.index})`);
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (segments && segments.length) {
|
|
889
|
+
out.push(segments.map((s) => s.selector).join(" >> "));
|
|
890
|
+
}
|
|
891
|
+
return Array.from(new Set(out)).slice(0, 8);
|
|
892
|
+
}
|
|
893
|
+
_buildReasoningSteps(ctx) {
|
|
894
|
+
const steps = [];
|
|
895
|
+
if (ctx.dnaTag || ctx.dnaText) {
|
|
896
|
+
steps.push(`Recovered DNA baseline - previously a <${ctx.dnaTag ?? "?"}>` +
|
|
897
|
+
(ctx.dnaText ? ` with text "${ctx.dnaText}"` : "") +
|
|
898
|
+
".");
|
|
899
|
+
}
|
|
900
|
+
if (ctx.successfulPath.length > 0) {
|
|
901
|
+
steps.push(`Resolved DOM context inside ${ctx.successfulPath.length} nested frame(s): ${ctx.successfulPath.join(" >> ")}.`);
|
|
902
|
+
}
|
|
903
|
+
const firstSeg = ctx.aiFix.chainSegments?.[0];
|
|
904
|
+
if (firstSeg) {
|
|
905
|
+
steps.push(`AI preserved the ${firstSeg.type} pattern - semantic locator over raw CSS path.`);
|
|
906
|
+
}
|
|
907
|
+
if (ctx.aiFix.explanation) {
|
|
908
|
+
steps.push(`AI reasoning: ${ctx.aiFix.explanation}`);
|
|
909
|
+
}
|
|
910
|
+
steps.push(`Confidence: ${fmtPctEngine(ctx.aiFix.confidence)}.`);
|
|
911
|
+
if (ctx.aiFix.contentChange) {
|
|
912
|
+
steps.push(`Detected content drift: "${ctx.aiFix.contentChange.oldText}" → "${ctx.aiFix.contentChange.newText}".`);
|
|
913
|
+
}
|
|
914
|
+
steps.push(`SafetyGuard verdict: ${ctx.safetyLevel} - change cleared for write.`);
|
|
915
|
+
steps.push(`Source updated via ${ctx.strategy} at line ${ctx.writtenLine}.`);
|
|
916
|
+
if (ctx.blastRadius != null && ctx.blastRadius > 0) {
|
|
917
|
+
steps.push(`Blast radius: ${ctx.blastRadius} dependent test reference(s) inherited the fix.`);
|
|
918
|
+
}
|
|
919
|
+
return steps;
|
|
920
|
+
}
|
|
921
|
+
preloadTestTitle(stableId, testTitle) {
|
|
922
|
+
this.testTitleBuffer.set(stableId, testTitle);
|
|
923
|
+
}
|
|
924
|
+
async saveSnapshot(stableId, data) {
|
|
925
|
+
this.dnaBuffer.set(stableId, data);
|
|
926
|
+
}
|
|
927
|
+
async commitUpdates() {
|
|
928
|
+
if (this.dnaBuffer.size > 0) {
|
|
929
|
+
for (const [key, dna] of this.dnaBuffer.entries()) {
|
|
930
|
+
const meta = this.healMetaBuffer.get(key);
|
|
931
|
+
const toSave = meta ? { ...dna, ...meta } : dna;
|
|
932
|
+
await this.snapshotService.save(key, toSave);
|
|
933
|
+
}
|
|
934
|
+
this.dnaBuffer.clear();
|
|
935
|
+
this.healMetaBuffer.clear();
|
|
936
|
+
this.testTitleBuffer.clear();
|
|
937
|
+
SourceUpdater_1.SourceUpdater.flushAdvisories();
|
|
938
|
+
}
|
|
939
|
+
// ── Sela Insights - render the transparent healing report ───────
|
|
940
|
+
let reportHtmlPath = null;
|
|
941
|
+
const healedEvents = HealReportService_1.sharedHealReport.getHealedEvents();
|
|
942
|
+
const protectedEvents = HealReportService_1.sharedHealReport.getProtectedEvents();
|
|
943
|
+
if (HealReportService_1.sharedHealReport.hasContent()) {
|
|
944
|
+
// SELA-5: in a LOCAL multi-worker run every worker shares one cwd, so a
|
|
945
|
+
// direct flushToDisk is a last-writer-wins race on sela-report.json.
|
|
946
|
+
// Each worker instead writes a private shard; the single main-process
|
|
947
|
+
// reporter fans them in at onEnd. CI keeps the direct flush — it runs
|
|
948
|
+
// one worker per sharded machine and merges across machines separately.
|
|
949
|
+
const inLocalWorker = !isCiEnvironment() && process.env["TEST_WORKER_INDEX"] !== undefined;
|
|
950
|
+
if (inLocalWorker) {
|
|
951
|
+
try {
|
|
952
|
+
const shard = HealReportService_1.sharedHealReport.flushShard(process.cwd());
|
|
953
|
+
if (shard) {
|
|
954
|
+
logger_1.logger.debug(`Heal shard written: ${path.relative(process.cwd(), shard)}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
logger_1.logger.warn(`Failed to write heal shard: ${err.message}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
try {
|
|
963
|
+
logger_1.logger.stage("GENERATING REPORT");
|
|
964
|
+
reportHtmlPath = HealReportService_1.sharedHealReport.flushToDisk(process.cwd());
|
|
965
|
+
if (reportHtmlPath) {
|
|
966
|
+
logger_1.logger.success(`Insights report written: ${path.relative(process.cwd(), reportHtmlPath)}`);
|
|
967
|
+
logger_1.logger.info(" Open with: npx sela show-report");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
logger_1.logger.warn(`Failed to write insights report: ${err.message}`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// ── Dual-Mode post-run lifecycle ─────────────────────────────────
|
|
976
|
+
//
|
|
977
|
+
// CI (process.env.CI truthy) → Clean-Room PR automation (existing flow).
|
|
978
|
+
// Local DX (CI falsy) → Write-and-notify: AST mutations are
|
|
979
|
+
// already on disk; the reporter prints a
|
|
980
|
+
// diff + vscode:// deeplink + report link
|
|
981
|
+
// via printHealReview() (no Y/N prompt).
|
|
982
|
+
//
|
|
983
|
+
// The local branch deliberately bypasses PR automation because:
|
|
984
|
+
// • AST fixes are ALREADY persisted to disk (the test passed on them).
|
|
985
|
+
// • Local devs want to inspect + cherry-pick, not auto-commit.
|
|
986
|
+
if (isCiEnvironment()) {
|
|
987
|
+
if (this.config.prAutomation.enabled) {
|
|
988
|
+
// SELA-5: serialise PR automation (git stash / checkout / commit /
|
|
989
|
+
// push) behind a repo-root lock. CI normally runs one worker per
|
|
990
|
+
// sharded machine, so the lock is uncontended; if a machine is
|
|
991
|
+
// misconfigured with workers>1 it prevents concurrent git operations
|
|
992
|
+
// from corrupting the index / worktree. NOTE: this guards against git
|
|
993
|
+
// corruption only — aggregating heals from multiple workers into a
|
|
994
|
+
// single PR is intentionally out of scope (run one worker per shard).
|
|
995
|
+
const prLock = new FileLock_1.FileLock((0, FileLock_1.lockDirFor)(path.join(process.cwd(), ".sela-pr")), { staleMs: 120_000 });
|
|
996
|
+
try {
|
|
997
|
+
const ran = await prLock.withLock(async () => {
|
|
998
|
+
const workerIdx = process.env["TEST_WORKER_INDEX"];
|
|
999
|
+
if (workerIdx !== undefined && workerIdx !== "0") {
|
|
1000
|
+
logger_1.logger.warn("[PR] PR automation under workers>1 is unsupported — " +
|
|
1001
|
+
"run one worker per CI shard. Serialising to protect the git " +
|
|
1002
|
+
"index; heals from other workers may land in separate commits.");
|
|
1003
|
+
}
|
|
1004
|
+
const branchInfo = (0, PRAutomationService_1.detectBranch)(process.cwd());
|
|
1005
|
+
const decision = (0, PRAutomationService_1.decideEffectiveStrategy)(this.config.prAutomation, branchInfo.branch, healedEvents);
|
|
1006
|
+
if (decision.downgradeReason) {
|
|
1007
|
+
logger_1.logger.warn(`[PR] Strategy downgraded: '${decision.configured}' → '${decision.effective}' ` +
|
|
1008
|
+
`(reason: ${decision.downgradeReason}, ` +
|
|
1009
|
+
`minAI: ${fmtPctEngine(decision.minAiConfidence)}, ` +
|
|
1010
|
+
`minAuditor: ${fmtPctEngine(decision.minAuditorConfidence)})`);
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
logger_1.logger.debug(`[PR] Effective strategy: '${decision.effective}' on branch '${branchInfo.branch ?? "unknown"}'`);
|
|
1014
|
+
}
|
|
1015
|
+
const ctx = { cwd: process.cwd(), reportHtmlPath };
|
|
1016
|
+
await (0, PRAutomationService_1.execute)(this.config.prAutomation, decision, healedEvents, branchInfo, ctx);
|
|
1017
|
+
await (0, PRAutomationService_1.handleBugDetected)(this.config.prAutomation.onBugDetected, protectedEvents, branchInfo, ctx);
|
|
1018
|
+
});
|
|
1019
|
+
if (ran === undefined) {
|
|
1020
|
+
logger_1.logger.warn("[PR] Skipped PR automation — another worker holds the PR lock.");
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
logger_1.logger.warn(`[PR] PR automation step failed: ${err.message}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
else if (healedEvents.length > 0) {
|
|
1029
|
+
// Local DX: serialise the mutated snapshots to a cross-process
|
|
1030
|
+
// ledger. The SelaReporter drains it from `onEnd()` in the main
|
|
1031
|
+
// process and prints a non-blocking summary (diff + vscode://
|
|
1032
|
+
// deeplink). Mutations are already on disk - the ledger only
|
|
1033
|
+
// exists so the reporter knows what to narrate.
|
|
1034
|
+
try {
|
|
1035
|
+
(0, PendingPromptLedger_1.writePendingPromptsLedger)({
|
|
1036
|
+
cwd: process.cwd(),
|
|
1037
|
+
workspace: WorkspaceSnapshotService_1.sharedWorkspaceSnapshot,
|
|
1038
|
+
healedEvents,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
catch (err) {
|
|
1042
|
+
logger_1.logger.warn(`Pending-prompts ledger write failed: ${err.message}`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Clear the report buffer last so any downstream consumers (e.g. test
|
|
1046
|
+
// re-runs in the same process) start fresh on the next session.
|
|
1047
|
+
if (HealReportService_1.sharedHealReport.hasContent()) {
|
|
1048
|
+
HealReportService_1.sharedHealReport.clear();
|
|
1049
|
+
}
|
|
1050
|
+
// Drop the Clean-Room workspace snapshot ledger so the next session
|
|
1051
|
+
// starts with an empty `contentBefore` map. Without this, a long-lived
|
|
1052
|
+
// Node process (`vitest --watch`, IDE test runner) would carry stale
|
|
1053
|
+
// pre-Sela snapshots from one session into the next, causing the
|
|
1054
|
+
// isolated-diff layer to compare against the wrong baseline.
|
|
1055
|
+
WorkspaceSnapshotService_1.sharedWorkspaceSnapshot.clear();
|
|
1056
|
+
}
|
|
1057
|
+
// ─────────────────────────────────────────────────────────────
|
|
1058
|
+
// fetchLiveText - frame-aware innerText fetch (zero-trust probe)
|
|
1059
|
+
//
|
|
1060
|
+
// Navigates the frame chain encoded in successfulPath, then calls
|
|
1061
|
+
// .first().innerText() on the element-only selector. Uses a short
|
|
1062
|
+
// 1.5 s timeout so it never adds meaningful latency. Returns null
|
|
1063
|
+
// on ANY failure - the caller must treat null as "no data" and skip
|
|
1064
|
+
// live verification rather than blocking the heal.
|
|
1065
|
+
// ─────────────────────────────────────────────────────────────
|
|
1066
|
+
async fetchLiveText(page, elementOnlySelector, framePath) {
|
|
1067
|
+
try {
|
|
1068
|
+
let ctx = page;
|
|
1069
|
+
for (const frameSel of framePath) {
|
|
1070
|
+
ctx = ctx.frameLocator(frameSel);
|
|
1071
|
+
}
|
|
1072
|
+
const text = await ctx
|
|
1073
|
+
.locator(elementOnlySelector)
|
|
1074
|
+
.first()
|
|
1075
|
+
.innerText({ timeout: 1500 });
|
|
1076
|
+
return text.trim().length > 0 ? text.trim() : null;
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
_rebuildSelectorWithFrames(fullSelector, elementSelectorOnly, aiNewSelector) {
|
|
1083
|
+
const fullParts = fullSelector.split(" >> ").map((s) => s.trim());
|
|
1084
|
+
const elementParts = elementSelectorOnly.split(" >> ").map((s) => s.trim());
|
|
1085
|
+
const aiParts = aiNewSelector.split(" >> ").map((s) => s.trim());
|
|
1086
|
+
const frameParts = fullParts.slice(0, fullParts.length - elementParts.length);
|
|
1087
|
+
if (frameParts.length === 0)
|
|
1088
|
+
return aiNewSelector;
|
|
1089
|
+
const aiHasFrameSegments = aiParts.some((p) => p.includes("-frame") ||
|
|
1090
|
+
p === "iframe" ||
|
|
1091
|
+
p === "frame" ||
|
|
1092
|
+
p.includes("frame-"));
|
|
1093
|
+
if (aiHasFrameSegments) {
|
|
1094
|
+
logger_1.logger.debug(`AI returned full frame path - using directly: ${aiNewSelector}`);
|
|
1095
|
+
return aiNewSelector;
|
|
1096
|
+
}
|
|
1097
|
+
return [...frameParts, ...aiParts].join(" >> ");
|
|
1098
|
+
}
|
|
1099
|
+
getAllFiles(dirPath, arrayOfFiles = []) {
|
|
1100
|
+
const files = fs.readdirSync(dirPath);
|
|
1101
|
+
const ignoreList = [
|
|
1102
|
+
"node_modules",
|
|
1103
|
+
".git",
|
|
1104
|
+
"sela-snapshots",
|
|
1105
|
+
"dist",
|
|
1106
|
+
"test-results",
|
|
1107
|
+
"tests/reset-demo.ts",
|
|
1108
|
+
];
|
|
1109
|
+
files.forEach((file) => {
|
|
1110
|
+
const fullPath = path.join(dirPath, file);
|
|
1111
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
1112
|
+
if (!ignoreList.includes(file))
|
|
1113
|
+
this.getAllFiles(fullPath, arrayOfFiles);
|
|
1114
|
+
}
|
|
1115
|
+
else if (file.endsWith(".ts") || file.endsWith(".js")) {
|
|
1116
|
+
arrayOfFiles.push(fullPath);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
return arrayOfFiles;
|
|
1120
|
+
}
|
|
1121
|
+
// ─────────────────────────────────────────────────────────────
|
|
1122
|
+
// captureSuccessfulElement - capture and buffer a v2 DNA snapshot.
|
|
1123
|
+
//
|
|
1124
|
+
// Replaces the previous DOMUtils.getElementDNA() path with a single
|
|
1125
|
+
// captureElement() call that collects all v2 metrics atomically.
|
|
1126
|
+
// ─────────────────────────────────────────────────────────────
|
|
1127
|
+
async captureSuccessfulElement(page, selector, stableId) {
|
|
1128
|
+
try {
|
|
1129
|
+
const snapshot = await this.snapshotService.captureElement(page, selector);
|
|
1130
|
+
if (snapshot) {
|
|
1131
|
+
const testTitle = this.testTitleBuffer.get(stableId);
|
|
1132
|
+
const enriched = {
|
|
1133
|
+
...snapshot,
|
|
1134
|
+
selector,
|
|
1135
|
+
...(testTitle ? { testTitle } : {}),
|
|
1136
|
+
};
|
|
1137
|
+
this.dnaBuffer.set(stableId, enriched);
|
|
1138
|
+
logger_1.logger.debug(`DNA v2 captured for: ${stableId}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
logger_1.logger.debug(`DNA capture skipped: ${error.message}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
exports.SelaEngine = SelaEngine;
|