@veraxhq/verax 0.3.0 → 0.4.0
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 +28 -20
- package/bin/verax.js +11 -18
- package/package.json +28 -7
- package/src/cli/commands/baseline.js +1 -2
- package/src/cli/commands/default.js +72 -81
- package/src/cli/commands/doctor.js +29 -0
- package/src/cli/commands/ga.js +3 -0
- package/src/cli/commands/gates.js +1 -1
- package/src/cli/commands/inspect.js +6 -133
- package/src/cli/commands/release-check.js +2 -0
- package/src/cli/commands/run.js +74 -246
- package/src/cli/commands/security-check.js +2 -1
- package/src/cli/commands/truth.js +0 -1
- package/src/cli/entry.js +82 -309
- package/src/cli/util/angular-component-extractor.js +2 -2
- package/src/cli/util/angular-navigation-detector.js +2 -2
- package/src/cli/util/ast-interactive-detector.js +4 -6
- package/src/cli/util/ast-network-detector.js +3 -3
- package/src/cli/util/ast-promise-extractor.js +581 -0
- package/src/cli/util/ast-usestate-detector.js +3 -3
- package/src/cli/util/atomic-write.js +12 -1
- package/src/cli/util/console-reporter.js +72 -0
- package/src/cli/util/detection-engine.js +105 -41
- package/src/cli/util/determinism-runner.js +2 -1
- package/src/cli/util/determinism-writer.js +1 -1
- package/src/cli/util/digest-engine.js +359 -0
- package/src/cli/util/dom-diff.js +226 -0
- package/src/cli/util/env-url.js +0 -4
- package/src/cli/util/evidence-engine.js +287 -0
- package/src/cli/util/expectation-extractor.js +217 -367
- package/src/cli/util/findings-writer.js +19 -126
- package/src/cli/util/framework-detector.js +572 -0
- package/src/cli/util/idgen.js +1 -1
- package/src/cli/util/interaction-planner.js +529 -0
- package/src/cli/util/learn-writer.js +2 -2
- package/src/cli/util/ledger-writer.js +110 -0
- package/src/cli/util/monorepo-resolver.js +162 -0
- package/src/cli/util/observation-engine.js +127 -278
- package/src/cli/util/observe-writer.js +2 -2
- package/src/cli/util/paths.js +12 -3
- package/src/cli/util/project-discovery.js +284 -3
- package/src/cli/util/project-writer.js +2 -2
- package/src/cli/util/run-id.js +23 -27
- package/src/cli/util/run-result.js +778 -0
- package/src/cli/util/selector-resolver.js +235 -0
- package/src/cli/util/summary-writer.js +2 -1
- package/src/cli/util/svelte-navigation-detector.js +3 -3
- package/src/cli/util/svelte-sfc-extractor.js +0 -1
- package/src/cli/util/svelte-state-detector.js +1 -2
- package/src/cli/util/trust-activation-integration.js +496 -0
- package/src/cli/util/trust-activation-wrapper.js +85 -0
- package/src/cli/util/trust-integration-hooks.js +164 -0
- package/src/cli/util/types.js +153 -0
- package/src/cli/util/url-validation.js +40 -0
- package/src/cli/util/vue-navigation-detector.js +4 -3
- package/src/cli/util/vue-sfc-extractor.js +1 -2
- package/src/cli/util/vue-state-detector.js +1 -1
- package/src/types/fs-augment.d.ts +23 -0
- package/src/types/global.d.ts +137 -0
- package/src/types/internal-types.d.ts +35 -0
- package/src/verax/cli/finding-explainer.js +3 -56
- package/src/verax/cli/init.js +4 -18
- package/src/verax/core/action-classifier.js +4 -3
- package/src/verax/core/artifacts/registry.js +0 -15
- package/src/verax/core/artifacts/verifier.js +18 -8
- package/src/verax/core/baseline/baseline.snapshot.js +2 -0
- package/src/verax/core/capabilities/gates.js +7 -1
- package/src/verax/core/confidence/confidence-compute.js +14 -7
- package/src/verax/core/confidence/confidence.loader.js +1 -0
- package/src/verax/core/confidence-engine-refactor.js +8 -3
- package/src/verax/core/confidence-engine.js +162 -23
- package/src/verax/core/contracts/types.js +1 -0
- package/src/verax/core/contracts/validators.js +79 -4
- package/src/verax/core/decision-snapshot.js +3 -30
- package/src/verax/core/decisions/decision.trace.js +2 -0
- package/src/verax/core/determinism/contract-writer.js +2 -2
- package/src/verax/core/determinism/contract.js +1 -1
- package/src/verax/core/determinism/diff.js +42 -1
- package/src/verax/core/determinism/engine.js +7 -6
- package/src/verax/core/determinism/finding-identity.js +3 -2
- package/src/verax/core/determinism/normalize.js +32 -4
- package/src/verax/core/determinism/report-writer.js +1 -0
- package/src/verax/core/determinism/run-fingerprint.js +7 -2
- package/src/verax/core/dynamic-route-intelligence.js +8 -7
- package/src/verax/core/evidence/evidence-capture-service.js +1 -0
- package/src/verax/core/evidence/evidence-intent-ledger.js +2 -1
- package/src/verax/core/evidence-builder.js +2 -2
- package/src/verax/core/execution-mode-context.js +1 -1
- package/src/verax/core/execution-mode-detector.js +5 -3
- package/src/verax/core/failures/exit-codes.js +39 -37
- package/src/verax/core/failures/failure-summary.js +1 -1
- package/src/verax/core/failures/failure.factory.js +3 -3
- package/src/verax/core/failures/failure.ledger.js +3 -2
- package/src/verax/core/ga/ga.artifact.js +1 -1
- package/src/verax/core/ga/ga.contract.js +3 -2
- package/src/verax/core/ga/ga.enforcer.js +1 -0
- package/src/verax/core/guardrails/policy.loader.js +1 -0
- package/src/verax/core/guardrails/truth-reconciliation.js +1 -1
- package/src/verax/core/guardrails-engine.js +2 -2
- package/src/verax/core/incremental-store.js +1 -0
- package/src/verax/core/integrity/budget.js +138 -0
- package/src/verax/core/integrity/determinism.js +342 -0
- package/src/verax/core/integrity/integrity.js +208 -0
- package/src/verax/core/integrity/poisoning.js +108 -0
- package/src/verax/core/integrity/transaction.js +140 -0
- package/src/verax/core/observe/run-timeline.js +2 -0
- package/src/verax/core/perf/perf.report.js +2 -0
- package/src/verax/core/pipeline-tracker.js +5 -0
- package/src/verax/core/release/provenance.builder.js +73 -214
- package/src/verax/core/release/release.enforcer.js +14 -9
- package/src/verax/core/release/reproducibility.check.js +1 -0
- package/src/verax/core/release/sbom.builder.js +32 -23
- package/src/verax/core/replay-validator.js +2 -0
- package/src/verax/core/replay.js +4 -0
- package/src/verax/core/report/cross-index.js +6 -3
- package/src/verax/core/report/human-summary.js +141 -1
- package/src/verax/core/route-intelligence.js +4 -3
- package/src/verax/core/run-id.js +6 -3
- package/src/verax/core/run-manifest.js +4 -3
- package/src/verax/core/security/secrets.scan.js +10 -7
- package/src/verax/core/security/security.enforcer.js +4 -0
- package/src/verax/core/security/supplychain.policy.js +9 -1
- package/src/verax/core/security/vuln.scan.js +2 -2
- package/src/verax/core/truth/truth.certificate.js +3 -1
- package/src/verax/core/ui-feedback-intelligence.js +12 -46
- package/src/verax/detect/conditional-ui-silent-failure.js +84 -0
- package/src/verax/detect/confidence-engine.js +100 -660
- package/src/verax/detect/confidence-helper.js +1 -0
- package/src/verax/detect/detection-engine.js +1 -18
- package/src/verax/detect/dynamic-route-findings.js +17 -14
- package/src/verax/detect/expectation-chain-detector.js +1 -1
- package/src/verax/detect/expectation-model.js +3 -5
- package/src/verax/detect/failure-cause-inference.js +293 -0
- package/src/verax/detect/findings-writer.js +126 -166
- package/src/verax/detect/flow-detector.js +2 -2
- package/src/verax/detect/form-silent-failure.js +98 -0
- package/src/verax/detect/index.js +51 -234
- package/src/verax/detect/invariants-enforcer.js +147 -0
- package/src/verax/detect/journey-stall-detector.js +4 -4
- package/src/verax/detect/navigation-silent-failure.js +82 -0
- package/src/verax/detect/problem-aggregator.js +361 -0
- package/src/verax/detect/route-findings.js +7 -6
- package/src/verax/detect/summary-writer.js +477 -0
- package/src/verax/detect/test-failure-cause-inference.js +314 -0
- package/src/verax/detect/ui-feedback-findings.js +18 -18
- package/src/verax/detect/verdict-engine.js +3 -57
- package/src/verax/detect/view-switch-correlator.js +2 -2
- package/src/verax/flow/flow-engine.js +2 -1
- package/src/verax/flow/flow-spec.js +0 -6
- package/src/verax/index.js +48 -412
- package/src/verax/intel/ts-program.js +1 -0
- package/src/verax/intel/vue-navigation-extractor.js +3 -0
- package/src/verax/learn/action-contract-extractor.js +67 -682
- package/src/verax/learn/ast-contract-extractor.js +1 -1
- package/src/verax/learn/flow-extractor.js +1 -0
- package/src/verax/learn/project-detector.js +5 -0
- package/src/verax/learn/react-router-extractor.js +2 -0
- package/src/verax/learn/route-validator.js +1 -4
- package/src/verax/learn/source-instrumenter.js +1 -0
- package/src/verax/learn/state-extractor.js +2 -1
- package/src/verax/learn/static-extractor.js +1 -0
- package/src/verax/observe/coverage-gaps.js +132 -0
- package/src/verax/observe/expectation-handler.js +126 -0
- package/src/verax/observe/incremental-skip.js +46 -0
- package/src/verax/observe/index.js +735 -84
- package/src/verax/observe/interaction-executor.js +192 -0
- package/src/verax/observe/interaction-runner.js +782 -530
- package/src/verax/observe/network-firewall.js +86 -0
- package/src/verax/observe/observation-builder.js +169 -0
- package/src/verax/observe/observe-context.js +1 -1
- package/src/verax/observe/observe-helpers.js +2 -1
- package/src/verax/observe/observe-runner.js +28 -24
- package/src/verax/observe/observers/budget-observer.js +3 -3
- package/src/verax/observe/observers/console-observer.js +4 -4
- package/src/verax/observe/observers/coverage-observer.js +4 -4
- package/src/verax/observe/observers/interaction-observer.js +3 -3
- package/src/verax/observe/observers/navigation-observer.js +4 -4
- package/src/verax/observe/observers/network-observer.js +4 -4
- package/src/verax/observe/observers/safety-observer.js +1 -1
- package/src/verax/observe/observers/ui-feedback-observer.js +4 -4
- package/src/verax/observe/page-traversal.js +138 -0
- package/src/verax/observe/snapshot-ops.js +94 -0
- package/src/verax/observe/ui-signal-sensor.js +2 -148
- package/src/verax/scan-summary-writer.js +10 -42
- package/src/verax/shared/artifact-manager.js +30 -13
- package/src/verax/shared/caching.js +1 -0
- package/src/verax/shared/expectation-tracker.js +1 -0
- package/src/verax/shared/zip-artifacts.js +6 -0
- package/src/verax/core/confidence-engine.js.backup +0 -471
- package/src/verax/shared/config-loader.js +0 -169
- /package/src/verax/shared/{expectation-proof.js → expectation-validation.js} +0 -0
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import { resolve, dirname } from 'path';
|
|
2
|
-
import { mkdirSync } from 'fs';
|
|
2
|
+
import { mkdirSync, readFileSync, existsSync, writeFileSync } from 'fs';
|
|
3
3
|
import { createBrowser, navigateToUrl, closeBrowser } from './browser.js';
|
|
4
|
+
import { discoverAllInteractions } from './interaction-discovery.js';
|
|
4
5
|
import { captureScreenshot } from './evidence-capture.js';
|
|
5
|
-
import {
|
|
6
|
+
import { runInteraction } from './interaction-runner.js';
|
|
7
|
+
import { writeTraces } from './traces-writer.js';
|
|
8
|
+
import { getBaseOrigin, isExternalUrl } from './domain-boundary.js';
|
|
9
|
+
|
|
10
|
+
// STAGE D1: Extracted modules for observe orchestration
|
|
11
|
+
import { setupNetworkFirewall } from './network-firewall.js';
|
|
12
|
+
|
|
13
|
+
// STAGE D2.1: Extracted modules for snapshot operations
|
|
14
|
+
import { initializeSnapshot, finalizeSnapshot } from './snapshot-ops.js';
|
|
15
|
+
|
|
16
|
+
// STAGE D2.2: Extracted modules for coverage gap accumulation and warnings
|
|
17
|
+
import { accumulateCoverageGaps, buildCoverageObject, generateCoverageWarnings } from './coverage-gaps.js';
|
|
18
|
+
|
|
19
|
+
// STAGE D2.4: Extracted modules for incremental skip handling
|
|
20
|
+
import { buildIncrementalPhantomTrace } from './incremental-skip.js';
|
|
21
|
+
|
|
6
22
|
import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
|
|
23
|
+
import { executeProvenExpectations } from './expectation-executor.js';
|
|
24
|
+
import { isProvenExpectation } from '../shared/expectation-prover.js';
|
|
7
25
|
import { PageFrontier } from './page-frontier.js';
|
|
26
|
+
import { deriveObservedExpectation, shouldAttemptRepeatObservedExpectation, evaluateObservedExpectation } from './observed-expectation.js';
|
|
27
|
+
import { computeRouteBudget } from '../core/budget-engine.js';
|
|
28
|
+
import { shouldSkipInteractionIncremental } from '../core/incremental-store.js';
|
|
8
29
|
import SilenceTracker from '../core/silence-model.js';
|
|
9
|
-
import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordEnvironment } from '../core/determinism-model.js';
|
|
10
|
-
import { setupManifestAndExpectations, processTraversalResults, writeDeterminismArtifacts } from './observe-helpers.js';
|
|
30
|
+
import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordTruncation, recordEnvironment } from '../core/determinism-model.js';
|
|
11
31
|
|
|
12
32
|
/**
|
|
13
33
|
* OBSERVE PHASE - Execute interactions and capture runtime behavior
|
|
@@ -24,8 +44,8 @@ import { setupManifestAndExpectations, processTraversalResults, writeDeterminism
|
|
|
24
44
|
* All silence is explicit in output - no silent success.
|
|
25
45
|
*
|
|
26
46
|
* PHASE 4: Safety mode enabled by default
|
|
27
|
-
* - Blocks risky/write actions
|
|
28
|
-
* - Blocks POST/PUT/PATCH/DELETE
|
|
47
|
+
* - Blocks risky/write actions unconditionally
|
|
48
|
+
* - Blocks POST/PUT/PATCH/DELETE (read-only mode enforced)
|
|
29
49
|
* - Blocks cross-origin unless --allow-cross-origin
|
|
30
50
|
*
|
|
31
51
|
* PHASE 5: Deterministic artifact paths
|
|
@@ -50,51 +70,79 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
|
|
|
50
70
|
recordEnvironment(decisionRecorder, { browserType: 'chromium', viewport: { width: 1280, height: 720 } });
|
|
51
71
|
|
|
52
72
|
// Phase 5: Detect projectDir if not provided (for backwards compatibility with tests)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (!resolvedProjectDir) {
|
|
56
|
-
resolvedProjectDir = process.cwd();
|
|
73
|
+
if (!projectDir) {
|
|
74
|
+
projectDir = process.cwd();
|
|
57
75
|
}
|
|
58
|
-
|
|
76
|
+
|
|
59
77
|
// Phase 4: Extract safety flags
|
|
60
|
-
const {
|
|
78
|
+
const { allowRiskyActions = false, allowCrossOrigin = false } = safetyFlags;
|
|
61
79
|
let blockedNetworkWrites = [];
|
|
62
80
|
let blockedCrossOrigin = [];
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
page,
|
|
69
|
-
baseOrigin,
|
|
70
|
-
safetyFlags: { allowWrites, allowCrossOrigin },
|
|
71
|
-
silenceTracker,
|
|
72
|
-
blockedNetworkWrites,
|
|
73
|
-
blockedCrossOrigin
|
|
74
|
-
};
|
|
75
|
-
await setupNetworkInterception(tempContext);
|
|
81
|
+
|
|
82
|
+
// Phase 4: Setup network interception firewall (STAGE D1: moved to module)
|
|
83
|
+
const firewallResult = await setupNetworkFirewall(page, baseOrigin, allowCrossOrigin, silenceTracker);
|
|
84
|
+
blockedNetworkWrites = firewallResult.blockedNetworkWrites;
|
|
85
|
+
blockedCrossOrigin = firewallResult.blockedCrossOrigin;
|
|
76
86
|
|
|
77
87
|
try {
|
|
78
88
|
await navigateToUrl(page, url, scanBudget);
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
if (manifestPath) {
|
|
82
|
-
resolvedProjectDir = dirname(dirname(dirname(manifestPath)));
|
|
83
|
-
}
|
|
90
|
+
const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
|
|
84
91
|
if (!runId) {
|
|
85
92
|
throw new Error('runId is required');
|
|
86
93
|
}
|
|
87
94
|
const { getScreenshotDir } = await import('../core/run-id.js');
|
|
88
|
-
const screenshotsDir = getScreenshotDir(
|
|
95
|
+
const screenshotsDir = getScreenshotDir(projectDir, runId);
|
|
89
96
|
mkdirSync(screenshotsDir, { recursive: true });
|
|
90
97
|
|
|
91
98
|
const timestamp = Date.now();
|
|
92
99
|
const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
|
|
93
100
|
await captureScreenshot(page, initialScreenshot);
|
|
94
101
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
// 1) Execute PROVEN expectations first (if manifest exists)
|
|
103
|
+
let manifest = null;
|
|
104
|
+
let expectationResults = null;
|
|
105
|
+
let expectationCoverageGaps = [];
|
|
106
|
+
let incrementalMode = false;
|
|
107
|
+
let snapshotDiff = null;
|
|
108
|
+
let oldSnapshot = null;
|
|
109
|
+
|
|
110
|
+
if (manifestPath && existsSync(manifestPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const manifestContent = readFileSync(manifestPath, 'utf-8');
|
|
113
|
+
// @ts-expect-error - readFileSync with encoding returns string
|
|
114
|
+
manifest = JSON.parse(manifestContent);
|
|
115
|
+
|
|
116
|
+
// STAGE D2.1: Snapshot initialization (moved to snapshot-ops module)
|
|
117
|
+
const snapshotResult = await initializeSnapshot(projectDir, manifest);
|
|
118
|
+
oldSnapshot = snapshotResult.oldSnapshot;
|
|
119
|
+
snapshotDiff = snapshotResult.snapshotDiff;
|
|
120
|
+
incrementalMode = snapshotResult.incrementalMode;
|
|
121
|
+
|
|
122
|
+
const provenCount = (manifest.staticExpectations || []).filter(exp => isProvenExpectation(exp)).length;
|
|
123
|
+
if (provenCount > 0) {
|
|
124
|
+
expectationResults = await executeProvenExpectations(
|
|
125
|
+
page,
|
|
126
|
+
manifest,
|
|
127
|
+
url,
|
|
128
|
+
screenshotsDir,
|
|
129
|
+
scanBudget,
|
|
130
|
+
startTime,
|
|
131
|
+
projectDir
|
|
132
|
+
);
|
|
133
|
+
expectationCoverageGaps = expectationResults.coverageGaps || [];
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Record manifest load/expectation execution failure as silence
|
|
137
|
+
silenceTracker.record({
|
|
138
|
+
scope: 'discovery',
|
|
139
|
+
reason: 'discovery_error',
|
|
140
|
+
description: 'Manifest load or expectation execution failed',
|
|
141
|
+
context: { error: err?.message },
|
|
142
|
+
impact: 'incomplete_check'
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
98
146
|
|
|
99
147
|
// Reset to start URL before traversal
|
|
100
148
|
await navigateToUrl(page, url, scanBudget);
|
|
@@ -108,72 +156,623 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
|
|
|
108
156
|
let totalInteractionsExecuted = 0;
|
|
109
157
|
let remainingInteractionsGaps = [];
|
|
110
158
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
159
|
+
let nextPageUrl = frontier.getNextUrl();
|
|
160
|
+
|
|
161
|
+
while (nextPageUrl && Date.now() - startTime < scanBudget.maxScanDurationMs) {
|
|
162
|
+
if (frontier.isPageLimitExceeded()) {
|
|
163
|
+
// PHASE 6: Record truncation decision
|
|
164
|
+
recordTruncation(decisionRecorder, 'pages', {
|
|
165
|
+
limit: scanBudget.maxPages,
|
|
166
|
+
reached: frontier.pagesVisited
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
silenceTracker.record({
|
|
170
|
+
scope: 'page',
|
|
171
|
+
reason: 'page_limit_exceeded',
|
|
172
|
+
description: `Reached maximum of ${scanBudget.maxPages} pages visited`,
|
|
173
|
+
context: { pagesVisited: frontier.pagesVisited, maxPages: scanBudget.maxPages },
|
|
174
|
+
impact: 'blocks_nav'
|
|
175
|
+
});
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check if we're already on the target page (from navigation via link click)
|
|
180
|
+
const currentUrl = page.url();
|
|
181
|
+
const normalizedNext = frontier.normalizeUrl(nextPageUrl);
|
|
182
|
+
const normalizedCurrent = frontier.normalizeUrl(currentUrl);
|
|
183
|
+
const alreadyOnPage = normalizedCurrent === normalizedNext;
|
|
184
|
+
|
|
185
|
+
if (!alreadyOnPage) {
|
|
186
|
+
// Navigate to next page
|
|
187
|
+
try {
|
|
188
|
+
await navigateToUrl(page, nextPageUrl, scanBudget);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Record navigation failure as silence and skip
|
|
191
|
+
silenceTracker.record({
|
|
192
|
+
scope: 'navigation',
|
|
193
|
+
reason: 'navigation_timeout',
|
|
194
|
+
description: 'Navigation to page failed',
|
|
195
|
+
context: { targetUrl: nextPageUrl },
|
|
196
|
+
impact: 'blocks_nav'
|
|
197
|
+
});
|
|
198
|
+
const normalizedFailed = frontier.normalizeUrl(nextPageUrl);
|
|
199
|
+
if (!frontier.visited.has(normalizedFailed)) {
|
|
200
|
+
frontier.visited.add(normalizedFailed);
|
|
201
|
+
frontier.markVisited();
|
|
202
|
+
}
|
|
203
|
+
nextPageUrl = frontier.getNextUrl();
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Mark as visited and increment counter
|
|
209
|
+
// getNextUrl() marks as visited in the set but doesn't call markVisited() to increment counter
|
|
210
|
+
// So we need to call markVisited() here when we process a page
|
|
211
|
+
if (!alreadyOnPage) {
|
|
212
|
+
// We navigated via getNextUrl() - it already marked in visited set, now increment counter
|
|
213
|
+
// (getNextUrl() marks in visited set but doesn't increment pagesVisited)
|
|
214
|
+
frontier.markVisited();
|
|
215
|
+
} else {
|
|
216
|
+
// We navigated via link click (alreadyOnPage=true) - mark as visited and increment
|
|
217
|
+
if (!frontier.visited.has(normalizedNext)) {
|
|
218
|
+
frontier.visited.add(normalizedNext);
|
|
219
|
+
frontier.markVisited();
|
|
220
|
+
} else {
|
|
221
|
+
// Already marked as visited, but still increment counter since we're processing it
|
|
222
|
+
frontier.markVisited();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Discover ALL links on this page and add to frontier BEFORE executing interactions
|
|
227
|
+
try {
|
|
228
|
+
const currentLinks = await page.locator('a[href]').all();
|
|
229
|
+
for (const link of currentLinks) {
|
|
230
|
+
try {
|
|
231
|
+
const href = await link.getAttribute('href');
|
|
232
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
233
|
+
const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
|
|
234
|
+
if (!isExternalUrl(resolvedUrl, baseOrigin)) {
|
|
235
|
+
frontier.addUrl(resolvedUrl);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Record invalid URL discovery as silence
|
|
240
|
+
silenceTracker.record({
|
|
241
|
+
scope: 'discovery',
|
|
242
|
+
reason: 'discovery_error',
|
|
243
|
+
description: 'Invalid or unreadable link during discovery',
|
|
244
|
+
context: { pageUrl: page.url() },
|
|
245
|
+
impact: 'incomplete_check'
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// Record link discovery failure as silence
|
|
251
|
+
silenceTracker.record({
|
|
252
|
+
scope: 'discovery',
|
|
253
|
+
reason: 'discovery_error',
|
|
254
|
+
description: 'Link discovery failed on page',
|
|
255
|
+
context: { pageUrl: page.url() },
|
|
256
|
+
impact: 'incomplete_check'
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// SCALE INTELLIGENCE: Compute adaptive budget for this route
|
|
261
|
+
// Reuse currentUrl from above (already captured at line 95)
|
|
262
|
+
const routeBudget = manifest ? computeRouteBudget(manifest, currentUrl, scanBudget) : scanBudget;
|
|
263
|
+
|
|
264
|
+
// Discover ALL interactions on this page
|
|
265
|
+
// Note: discoverAllInteractions already returns sorted interactions deterministically
|
|
266
|
+
const { interactions } = await discoverAllInteractions(page, baseOrigin, routeBudget);
|
|
267
|
+
totalInteractionsDiscovered += interactions.length;
|
|
268
|
+
|
|
269
|
+
// SCALE INTELLIGENCE: Apply adaptive budget cap (interactions are already sorted deterministically)
|
|
270
|
+
// Stable sorting ensures determinism: same interactions → same order
|
|
271
|
+
const sortedInteractions = interactions.slice(0, routeBudget.maxInteractionsPerPage);
|
|
272
|
+
|
|
273
|
+
// Track if we navigated during interaction execution
|
|
274
|
+
let navigatedToNewPage = false;
|
|
275
|
+
let navigatedPageUrl = null;
|
|
276
|
+
let remainingInteractionsStartIndex = 0;
|
|
277
|
+
|
|
278
|
+
// Execute discovered interactions on this page (sorted for determinism)
|
|
279
|
+
for (let i = 0; i < sortedInteractions.length; i++) {
|
|
280
|
+
if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
|
|
281
|
+
// PHASE 6: Record truncation decision
|
|
282
|
+
recordTruncation(decisionRecorder, 'time', {
|
|
283
|
+
limit: scanBudget.maxScanDurationMs,
|
|
284
|
+
elapsed: Date.now() - startTime
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Mark remaining interactions as COVERAGE_GAP
|
|
288
|
+
silenceTracker.record({
|
|
289
|
+
scope: 'interaction',
|
|
290
|
+
reason: 'scan_time_exceeded',
|
|
291
|
+
description: `Scan time limit (${scanBudget.maxScanDurationMs}ms) exceeded`,
|
|
292
|
+
context: {
|
|
293
|
+
elapsed: Date.now() - startTime,
|
|
294
|
+
maxDuration: scanBudget.maxScanDurationMs,
|
|
295
|
+
remainingInteractions: sortedInteractions.length - i
|
|
296
|
+
},
|
|
297
|
+
impact: 'blocks_nav',
|
|
298
|
+
count: sortedInteractions.length - i
|
|
299
|
+
});
|
|
300
|
+
remainingInteractionsStartIndex = i;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage) {
|
|
305
|
+
// PHASE 6: Record truncation decision
|
|
306
|
+
recordTruncation(decisionRecorder, 'interactions', {
|
|
307
|
+
limit: routeBudget.maxInteractionsPerPage,
|
|
308
|
+
reached: totalInteractionsExecuted,
|
|
309
|
+
scope: 'per_page'
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Route-specific budget exceeded
|
|
313
|
+
silenceTracker.record({
|
|
314
|
+
scope: 'interaction',
|
|
315
|
+
reason: 'route_interaction_limit_exceeded',
|
|
316
|
+
description: `Reached max ${routeBudget.maxInteractionsPerPage} interactions per page`,
|
|
317
|
+
context: {
|
|
318
|
+
currentPage: page.url(),
|
|
319
|
+
executed: totalInteractionsExecuted,
|
|
320
|
+
maxPerPage: routeBudget.maxInteractionsPerPage,
|
|
321
|
+
remainingInteractions: sortedInteractions.length - i
|
|
322
|
+
},
|
|
323
|
+
impact: 'affects_expectations',
|
|
324
|
+
count: sortedInteractions.length - i
|
|
325
|
+
});
|
|
326
|
+
remainingInteractionsStartIndex = i;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (totalInteractionsExecuted >= scanBudget.maxTotalInteractions) {
|
|
331
|
+
// PHASE 6: Record truncation decision
|
|
332
|
+
recordTruncation(decisionRecorder, 'interactions', {
|
|
333
|
+
limit: scanBudget.maxTotalInteractions,
|
|
334
|
+
reached: totalInteractionsExecuted,
|
|
335
|
+
scope: 'total'
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Mark remaining interactions as COVERAGE_GAP with reason 'budget_exceeded'
|
|
339
|
+
silenceTracker.record({
|
|
340
|
+
scope: 'interaction',
|
|
341
|
+
reason: 'interaction_limit_exceeded',
|
|
342
|
+
description: `Reached max ${scanBudget.maxTotalInteractions} total interactions`,
|
|
343
|
+
context: {
|
|
344
|
+
executed: totalInteractionsExecuted,
|
|
345
|
+
maxTotal: scanBudget.maxTotalInteractions,
|
|
346
|
+
remainingInteractions: sortedInteractions.length - i
|
|
347
|
+
},
|
|
348
|
+
impact: 'blocks_nav',
|
|
349
|
+
count: sortedInteractions.length - i
|
|
350
|
+
});
|
|
351
|
+
remainingInteractionsStartIndex = i;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const interaction = sortedInteractions[i];
|
|
356
|
+
|
|
357
|
+
// SCALE INTELLIGENCE: Check if interaction should be skipped in incremental mode
|
|
358
|
+
if (incrementalMode && manifest && oldSnapshot && snapshotDiff) {
|
|
359
|
+
const shouldSkip = shouldSkipInteractionIncremental(interaction, currentUrl, oldSnapshot, snapshotDiff);
|
|
360
|
+
if (shouldSkip) {
|
|
361
|
+
// Create a trace for skipped interaction (marked as incremental - will not produce findings)
|
|
362
|
+
const skippedTrace = buildIncrementalPhantomTrace({ interaction, currentUrl });
|
|
363
|
+
traces.push(skippedTrace);
|
|
364
|
+
|
|
365
|
+
// Track incremental skip as silence
|
|
366
|
+
silenceTracker.record({
|
|
367
|
+
scope: 'interaction',
|
|
368
|
+
reason: 'incremental_unchanged',
|
|
369
|
+
description: `Skipped re-observation (unchanged in incremental mode): ${interaction.label}`,
|
|
370
|
+
context: {
|
|
371
|
+
currentPage: currentUrl,
|
|
372
|
+
selector: interaction.selector,
|
|
373
|
+
interactionLabel: interaction.label,
|
|
374
|
+
type: interaction.type
|
|
375
|
+
},
|
|
376
|
+
impact: 'affects_expectations'
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
skippedInteractions.push({
|
|
380
|
+
interaction: {
|
|
381
|
+
type: interaction.type,
|
|
382
|
+
selector: interaction.selector,
|
|
383
|
+
label: interaction.label,
|
|
384
|
+
text: interaction.text
|
|
385
|
+
},
|
|
386
|
+
outcome: 'SKIPPED',
|
|
387
|
+
reason: 'incremental_unchanged',
|
|
388
|
+
url: currentUrl,
|
|
389
|
+
evidence: {
|
|
390
|
+
selector: interaction.selector,
|
|
391
|
+
label: interaction.label,
|
|
392
|
+
incremental: true
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Skip dangerous interactions (logout, delete, etc.) with explicit reason
|
|
400
|
+
const skipCheck = frontier.shouldSkipInteraction(interaction);
|
|
401
|
+
if (skipCheck.skip) {
|
|
402
|
+
// Track safety skip as silence
|
|
403
|
+
silenceTracker.record({
|
|
404
|
+
scope: 'interaction',
|
|
405
|
+
reason: skipCheck.reason === 'destructive' ? 'destructive_text' : 'unsafe_pattern',
|
|
406
|
+
description: `Skipped potentially dangerous interaction: ${interaction.label}`,
|
|
407
|
+
context: {
|
|
408
|
+
currentPage: page.url(),
|
|
409
|
+
selector: interaction.selector,
|
|
410
|
+
interactionLabel: interaction.label,
|
|
411
|
+
text: interaction.text,
|
|
412
|
+
skipReason: skipCheck.reason,
|
|
413
|
+
skipMessage: skipCheck.message
|
|
414
|
+
},
|
|
415
|
+
impact: 'unknown_behavior'
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
skippedInteractions.push({
|
|
419
|
+
interaction: {
|
|
420
|
+
type: interaction.type,
|
|
421
|
+
selector: interaction.selector,
|
|
422
|
+
label: interaction.label,
|
|
423
|
+
text: interaction.text
|
|
424
|
+
},
|
|
425
|
+
outcome: 'SKIPPED',
|
|
426
|
+
reason: skipCheck.reason || 'safety_policy',
|
|
427
|
+
url: page.url(),
|
|
428
|
+
evidence: {
|
|
429
|
+
selector: interaction.selector,
|
|
430
|
+
label: interaction.label,
|
|
431
|
+
text: interaction.text,
|
|
432
|
+
sourcePage: page.url()
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Phase 4: Check action classification and safety mode
|
|
439
|
+
const { shouldBlockAction } = await import('../core/action-classifier.js');
|
|
440
|
+
const allowWrites = allowRiskyActions;
|
|
441
|
+
const blockCheck = shouldBlockAction(interaction, { allowWrites, allowRiskyActions });
|
|
442
|
+
|
|
443
|
+
if (blockCheck.shouldBlock) {
|
|
444
|
+
// Track blocked action as silence
|
|
445
|
+
silenceTracker.record({
|
|
446
|
+
scope: 'safety',
|
|
447
|
+
reason: 'blocked_action',
|
|
448
|
+
description: `Action blocked by safety mode: ${interaction.label} (${blockCheck.classification})`,
|
|
449
|
+
context: {
|
|
450
|
+
currentPage: page.url(),
|
|
451
|
+
selector: interaction.selector,
|
|
452
|
+
interactionLabel: interaction.label,
|
|
453
|
+
text: interaction.text,
|
|
454
|
+
classification: blockCheck.classification,
|
|
455
|
+
blockReason: blockCheck.reason
|
|
456
|
+
},
|
|
457
|
+
impact: 'action_blocked'
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
skippedInteractions.push({
|
|
461
|
+
interaction: {
|
|
462
|
+
type: interaction.type,
|
|
463
|
+
selector: interaction.selector,
|
|
464
|
+
label: interaction.label,
|
|
465
|
+
text: interaction.text
|
|
466
|
+
},
|
|
467
|
+
outcome: 'BLOCKED',
|
|
468
|
+
reason: 'safety_mode',
|
|
469
|
+
classification: blockCheck.classification,
|
|
470
|
+
url: page.url(),
|
|
471
|
+
evidence: {
|
|
472
|
+
selector: interaction.selector,
|
|
473
|
+
label: interaction.label,
|
|
474
|
+
text: interaction.text,
|
|
475
|
+
classification: blockCheck.classification,
|
|
476
|
+
sourcePage: page.url()
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Create a minimal trace for blocked interactions so they appear in output
|
|
481
|
+
const blockedTrace = {
|
|
482
|
+
interaction: {
|
|
483
|
+
type: interaction.type,
|
|
484
|
+
selector: interaction.selector,
|
|
485
|
+
label: interaction.label,
|
|
486
|
+
text: interaction.text
|
|
487
|
+
},
|
|
488
|
+
before: {
|
|
489
|
+
url: page.url(),
|
|
490
|
+
screenshot: null
|
|
491
|
+
},
|
|
492
|
+
after: {
|
|
493
|
+
url: page.url(),
|
|
494
|
+
screenshot: null
|
|
495
|
+
},
|
|
496
|
+
policy: {
|
|
497
|
+
actionBlocked: true,
|
|
498
|
+
classification: blockCheck.classification,
|
|
499
|
+
reason: blockCheck.reason
|
|
500
|
+
},
|
|
501
|
+
outcome: 'BLOCKED_BY_SAFETY_MODE',
|
|
502
|
+
timestamp: Date.now()
|
|
503
|
+
};
|
|
504
|
+
traces.push(blockedTrace);
|
|
505
|
+
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const beforeUrl = page.url();
|
|
510
|
+
const interactionIndex = totalInteractionsExecuted;
|
|
511
|
+
const trace = await runInteraction(
|
|
512
|
+
page,
|
|
513
|
+
interaction,
|
|
514
|
+
timestamp,
|
|
515
|
+
interactionIndex,
|
|
516
|
+
screenshotsDir,
|
|
517
|
+
baseOrigin,
|
|
518
|
+
startTime,
|
|
519
|
+
routeBudget, // Use route-specific budget
|
|
520
|
+
null,
|
|
521
|
+
silenceTracker // Pass silence tracker
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Mark trace with incremental flag if applicable
|
|
525
|
+
if (incrementalMode && trace) {
|
|
526
|
+
trace.incremental = false; // This interaction was executed, not skipped
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let repeatTrace = null;
|
|
530
|
+
|
|
531
|
+
if (trace) {
|
|
532
|
+
const matchingExpectation = expectationResults?.results?.find(r => r.trace?.interaction?.selector === trace.interaction.selector);
|
|
533
|
+
if (matchingExpectation) {
|
|
534
|
+
trace.expectationDriven = true;
|
|
535
|
+
trace.expectationId = matchingExpectation.expectationId;
|
|
536
|
+
trace.expectationOutcome = matchingExpectation.outcome;
|
|
537
|
+
} else {
|
|
538
|
+
const observedExpectation = deriveObservedExpectation(interaction, trace, baseOrigin);
|
|
539
|
+
if (observedExpectation) {
|
|
540
|
+
trace.observedExpectation = observedExpectation;
|
|
541
|
+
trace.resultType = 'OBSERVED_EXPECTATION';
|
|
542
|
+
observedExpectations.push(observedExpectation);
|
|
543
|
+
|
|
544
|
+
const repeatEligible = shouldAttemptRepeatObservedExpectation(observedExpectation, trace);
|
|
545
|
+
const budgetAllowsRepeat = repeatEligible &&
|
|
546
|
+
(Date.now() - startTime) < scanBudget.maxScanDurationMs &&
|
|
547
|
+
(totalInteractionsExecuted + 1) < scanBudget.maxTotalInteractions;
|
|
548
|
+
|
|
549
|
+
if (budgetAllowsRepeat) {
|
|
550
|
+
const repeatIndex = totalInteractionsExecuted + 1;
|
|
551
|
+
const repeatResult = await repeatObservedInteraction(
|
|
552
|
+
page,
|
|
553
|
+
interaction,
|
|
554
|
+
observedExpectation,
|
|
555
|
+
timestamp,
|
|
556
|
+
repeatIndex,
|
|
557
|
+
screenshotsDir,
|
|
558
|
+
baseOrigin,
|
|
559
|
+
startTime,
|
|
560
|
+
scanBudget
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
if (repeatResult) {
|
|
564
|
+
const repeatEvaluation = repeatResult.repeatEvaluation;
|
|
565
|
+
trace.observedExpectation.repeatAttempted = true;
|
|
566
|
+
trace.observedExpectation.repeated = repeatEvaluation.outcome === 'VERIFIED';
|
|
567
|
+
trace.observedExpectation.repeatOutcome = repeatEvaluation.outcome;
|
|
568
|
+
trace.observedExpectation.repeatReason = repeatEvaluation.reason;
|
|
569
|
+
|
|
570
|
+
if (repeatEvaluation.outcome === 'OBSERVED_BREAK') {
|
|
571
|
+
trace.observedExpectation.outcome = 'OBSERVED_BREAK';
|
|
572
|
+
trace.observedExpectation.reason = 'inconsistent_on_repeat';
|
|
573
|
+
trace.observedExpectation.confidenceLevel = 'LOW';
|
|
574
|
+
} else if (trace.observedExpectation.repeated && trace.observedExpectation.outcome === 'VERIFIED') {
|
|
575
|
+
trace.observedExpectation.confidenceLevel = 'MEDIUM';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
repeatTrace = repeatResult.repeatTrace;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
trace.unprovenResult = true;
|
|
583
|
+
trace.resultType = 'UNPROVEN_RESULT';
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
traces.push(trace);
|
|
588
|
+
totalInteractionsExecuted++;
|
|
589
|
+
|
|
590
|
+
if (repeatTrace) {
|
|
591
|
+
traces.push(repeatTrace);
|
|
592
|
+
totalInteractionsExecuted++;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const afterUrl = trace.after?.url || page.url();
|
|
596
|
+
const navigatedSameOrigin = afterUrl && afterUrl !== beforeUrl && !isExternalUrl(afterUrl, baseOrigin);
|
|
597
|
+
if (navigatedSameOrigin && interaction.type === 'link') {
|
|
598
|
+
// Link navigation - add new page to frontier (if not already visited)
|
|
599
|
+
const normalizedAfter = frontier.normalizeUrl(afterUrl);
|
|
600
|
+
const wasAlreadyVisited = frontier.visited.has(normalizedAfter);
|
|
601
|
+
if (!wasAlreadyVisited) {
|
|
602
|
+
const added = frontier.addUrl(afterUrl);
|
|
603
|
+
// If frontier was capped, record coverage gap
|
|
604
|
+
if (!added && frontier.frontierCapped) {
|
|
605
|
+
remainingInteractionsGaps.push({
|
|
606
|
+
interaction: {
|
|
607
|
+
type: 'link',
|
|
608
|
+
selector: interaction.selector,
|
|
609
|
+
label: interaction.label
|
|
610
|
+
},
|
|
611
|
+
reason: 'frontier_capped',
|
|
612
|
+
url: afterUrl
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Stay on the new page and continue executing interactions there
|
|
618
|
+
navigatedToNewPage = true;
|
|
619
|
+
navigatedPageUrl = afterUrl;
|
|
620
|
+
|
|
621
|
+
// Discover links on the new page immediately
|
|
622
|
+
try {
|
|
623
|
+
const newPageLinks = await page.locator('a[href]').all();
|
|
624
|
+
for (const link of newPageLinks) {
|
|
625
|
+
try {
|
|
626
|
+
const href = await link.getAttribute('href');
|
|
627
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
628
|
+
const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
|
|
629
|
+
if (!isExternalUrl(resolvedUrl, baseOrigin)) {
|
|
630
|
+
frontier.addUrl(resolvedUrl);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
} catch (error) {
|
|
634
|
+
// Record invalid URL discovery as silence
|
|
635
|
+
silenceTracker.record({
|
|
636
|
+
scope: 'discovery',
|
|
637
|
+
reason: 'discovery_error',
|
|
638
|
+
description: 'Invalid or unreadable link during discovery',
|
|
639
|
+
context: { pageUrl: page.url() },
|
|
640
|
+
impact: 'incomplete_check'
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch (error) {
|
|
645
|
+
// Record link discovery failure as silence
|
|
646
|
+
silenceTracker.record({
|
|
647
|
+
scope: 'discovery',
|
|
648
|
+
reason: 'discovery_error',
|
|
649
|
+
description: 'Link discovery failed on page',
|
|
650
|
+
context: { pageUrl: page.url() },
|
|
651
|
+
impact: 'incomplete_check'
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Break to restart loop on new page
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Mark remaining interactions as COVERAGE_GAP if we stopped early
|
|
662
|
+
if (remainingInteractionsStartIndex > 0 && remainingInteractionsStartIndex < sortedInteractions.length && !navigatedToNewPage) {
|
|
663
|
+
for (let j = remainingInteractionsStartIndex; j < sortedInteractions.length; j++) {
|
|
664
|
+
const reason = totalInteractionsExecuted >= scanBudget.maxTotalInteractions ? 'budget_exceeded' :
|
|
665
|
+
(totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage ? 'route_budget_exceeded' : 'budget_exceeded');
|
|
666
|
+
remainingInteractionsGaps.push({
|
|
667
|
+
interaction: {
|
|
668
|
+
type: sortedInteractions[j].type,
|
|
669
|
+
selector: sortedInteractions[j].selector,
|
|
670
|
+
label: sortedInteractions[j].label
|
|
671
|
+
},
|
|
672
|
+
reason: reason,
|
|
673
|
+
url: currentUrl
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// If we navigated to a new page, stay on it and continue (next iteration will handle it)
|
|
679
|
+
if (navigatedToNewPage && navigatedPageUrl) {
|
|
680
|
+
// Don't mark as visited yet - we'll do it at the start of next iteration
|
|
681
|
+
// This ensures the page counter is incremented when we process the page
|
|
682
|
+
// Set nextPageUrl to the navigated page so we process it in the next iteration
|
|
683
|
+
nextPageUrl = navigatedPageUrl;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// After executing all interactions on current page, move to next page in frontier
|
|
688
|
+
nextPageUrl = frontier.getNextUrl();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Combine all coverage gaps
|
|
692
|
+
expectationCoverageGaps.push(...accumulateCoverageGaps(remainingInteractionsGaps, frontier, page.url(), scanBudget));
|
|
693
|
+
|
|
694
|
+
// Build coverage object matching writeTraces expected format
|
|
695
|
+
const coverage = buildCoverageObject(
|
|
696
|
+
totalInteractionsDiscovered,
|
|
697
|
+
totalInteractionsExecuted,
|
|
117
698
|
scanBudget,
|
|
118
|
-
startTime,
|
|
119
699
|
frontier,
|
|
120
|
-
manifest,
|
|
121
|
-
expectationResults,
|
|
122
|
-
incrementalMode,
|
|
123
|
-
oldSnapshot,
|
|
124
|
-
snapshotDiff,
|
|
125
|
-
currentUrl: page.url(),
|
|
126
|
-
screenshotsDir,
|
|
127
|
-
timestamp,
|
|
128
|
-
decisionRecorder,
|
|
129
|
-
silenceTracker,
|
|
130
|
-
traces,
|
|
131
700
|
skippedInteractions,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
totalInteractionsExecuted,
|
|
135
|
-
remainingInteractionsGaps,
|
|
136
|
-
allowWrites,
|
|
137
|
-
allowRiskyActions
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// PHASE 21.3: Process traversal results using helper
|
|
141
|
-
const finalTraces = [...traversalResult.traces];
|
|
142
|
-
const finalSkippedInteractions = [...traversalResult.skippedInteractions];
|
|
143
|
-
const finalObservedExpectations = [...traversalResult.observedExpectations];
|
|
144
|
-
remainingInteractionsGaps = [...traversalResult.remainingInteractionsGaps];
|
|
701
|
+
remainingInteractionsGaps
|
|
702
|
+
);
|
|
145
703
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
704
|
+
// Ensure we increment pagesVisited when we navigate via getNextUrl()
|
|
705
|
+
// getNextUrl() marks as visited but doesn't increment counter - we do it here
|
|
706
|
+
// BUT: when alreadyOnPage is true, we've already marked it, so don't double-count
|
|
707
|
+
|
|
708
|
+
// Record warnings
|
|
709
|
+
observeWarnings.push(...generateCoverageWarnings(coverage, skippedInteractions));
|
|
710
|
+
|
|
711
|
+
// Append expectation traces for completeness
|
|
712
|
+
if (expectationResults && expectationResults.results) {
|
|
713
|
+
for (const result of expectationResults.results) {
|
|
714
|
+
if (result.trace) {
|
|
715
|
+
result.trace.expectationDriven = true;
|
|
716
|
+
result.trace.expectationId = result.expectationId;
|
|
717
|
+
result.trace.expectationOutcome = result.outcome;
|
|
718
|
+
traces.push(result.trace);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, observedExpectations, silenceTracker, runId);
|
|
724
|
+
|
|
725
|
+
// Add silence tracking to observation result
|
|
726
|
+
observation.silences = silenceTracker.getDetailedSummary();
|
|
727
|
+
|
|
728
|
+
if (expectationResults) {
|
|
729
|
+
observation.expectationExecution = {
|
|
730
|
+
totalProvenExpectations: expectationResults.totalProvenExpectations,
|
|
731
|
+
executedCount: expectationResults.executedCount,
|
|
732
|
+
coverageGapsCount: expectationCoverageGaps.length,
|
|
733
|
+
results: expectationResults.results.map(r => ({
|
|
734
|
+
expectationId: r.expectationId,
|
|
735
|
+
type: r.type,
|
|
736
|
+
fromPath: r.fromPath,
|
|
737
|
+
outcome: r.outcome,
|
|
738
|
+
reason: r.reason
|
|
739
|
+
}))
|
|
740
|
+
};
|
|
741
|
+
observation.expectationCoverageGaps = expectationCoverageGaps;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// STAGE D2.1: Snapshot finalization (moved to snapshot-ops module)
|
|
745
|
+
const incrementalMetadata = await finalizeSnapshot(
|
|
159
746
|
manifest,
|
|
747
|
+
traces,
|
|
748
|
+
skippedInteractions,
|
|
160
749
|
incrementalMode,
|
|
161
750
|
snapshotDiff,
|
|
162
|
-
|
|
163
|
-
runId
|
|
751
|
+
projectDir,
|
|
752
|
+
runId,
|
|
753
|
+
url
|
|
164
754
|
);
|
|
755
|
+
if (incrementalMetadata) {
|
|
756
|
+
observation.incremental = incrementalMetadata;
|
|
757
|
+
}
|
|
165
758
|
|
|
166
759
|
await closeBrowser(browser);
|
|
167
760
|
|
|
168
|
-
// PHASE
|
|
169
|
-
|
|
761
|
+
// PHASE 6: Export determinism decisions to run directory
|
|
762
|
+
if (runId && projectDir) {
|
|
763
|
+
const runsDir = resolve(projectDir, '.verax', 'runs', runId);
|
|
764
|
+
mkdirSync(runsDir, { recursive: true });
|
|
765
|
+
const decisionsPath = resolve(runsDir, 'decisions.json');
|
|
766
|
+
const decisionsData = JSON.stringify(decisionRecorder.export(), null, 2);
|
|
767
|
+
writeFileSync(decisionsPath, decisionsData, 'utf-8');
|
|
768
|
+
}
|
|
170
769
|
|
|
171
770
|
// Phase 4: Add safety mode statistics
|
|
172
771
|
const safetyBlocks = {
|
|
173
|
-
actionsBlocked:
|
|
772
|
+
actionsBlocked: skippedInteractions.filter(s => s.reason === 'safety_mode').length,
|
|
174
773
|
networkWritesBlocked: blockedNetworkWrites.length,
|
|
175
774
|
crossOriginBlocked: blockedCrossOrigin.length,
|
|
176
|
-
blockedActions:
|
|
775
|
+
blockedActions: skippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
|
|
177
776
|
label: s.interaction.label,
|
|
178
777
|
classification: s.classification,
|
|
179
778
|
url: s.url
|
|
@@ -193,4 +792,56 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
|
|
|
193
792
|
}
|
|
194
793
|
}
|
|
195
794
|
|
|
196
|
-
|
|
795
|
+
async function repeatObservedInteraction(
|
|
796
|
+
page,
|
|
797
|
+
interaction,
|
|
798
|
+
observedExpectation,
|
|
799
|
+
timestamp,
|
|
800
|
+
interactionIndex,
|
|
801
|
+
screenshotsDir,
|
|
802
|
+
baseOrigin,
|
|
803
|
+
startTime,
|
|
804
|
+
scanBudget
|
|
805
|
+
) {
|
|
806
|
+
const selector = observedExpectation.evidence?.selector || interaction.selector;
|
|
807
|
+
if (!selector) return null;
|
|
808
|
+
|
|
809
|
+
const locator = page.locator(selector).first();
|
|
810
|
+
const count = await locator.count();
|
|
811
|
+
if (count === 0) {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const repeatInteraction = {
|
|
816
|
+
...interaction,
|
|
817
|
+
element: locator
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const repeatTrace = await runInteraction(
|
|
821
|
+
page,
|
|
822
|
+
repeatInteraction,
|
|
823
|
+
timestamp,
|
|
824
|
+
interactionIndex,
|
|
825
|
+
screenshotsDir,
|
|
826
|
+
baseOrigin,
|
|
827
|
+
startTime,
|
|
828
|
+
scanBudget,
|
|
829
|
+
null,
|
|
830
|
+
null // No silence tracker for repeat executions (not counted as new silence)
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
if (!repeatTrace) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
repeatTrace.repeatExecution = true;
|
|
838
|
+
repeatTrace.repeatOfObservedExpectationId = observedExpectation.id;
|
|
839
|
+
repeatTrace.resultType = 'OBSERVED_EXPECTATION_REPEAT';
|
|
840
|
+
|
|
841
|
+
const repeatEvaluation = evaluateObservedExpectation(observedExpectation, repeatTrace);
|
|
842
|
+
|
|
843
|
+
return {
|
|
844
|
+
repeatTrace,
|
|
845
|
+
repeatEvaluation
|
|
846
|
+
};
|
|
847
|
+
}
|