@aarwitz/tapp 0.15.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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type Platform = "ios" | "android" | "web";
|
|
2
|
+
export type Criticality = "low" | "medium" | "high" | "critical";
|
|
3
|
+
|
|
4
|
+
export interface ActorDefinition {
|
|
5
|
+
session?: "default" | "isolated" | "shared";
|
|
6
|
+
role?: string;
|
|
7
|
+
credentials?: {
|
|
8
|
+
email?: string;
|
|
9
|
+
password?: string;
|
|
10
|
+
[name: string]: string | undefined;
|
|
11
|
+
};
|
|
12
|
+
vars?: Record<string, string | number | boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TaskContractStep<TActor extends string = string> {
|
|
16
|
+
actor: TActor;
|
|
17
|
+
task: string;
|
|
18
|
+
with?: Record<string, unknown>;
|
|
19
|
+
save?: Record<string, string>;
|
|
20
|
+
reason?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ContractExpectation {
|
|
24
|
+
screen?: string;
|
|
25
|
+
exists?: string;
|
|
26
|
+
absent?: string;
|
|
27
|
+
text?: { of: string; contains: string };
|
|
28
|
+
eventually?: { timeoutMs: number; pollMs?: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ExpectContractStep<TActor extends string = string> {
|
|
32
|
+
actor: TActor;
|
|
33
|
+
expect: ContractExpectation;
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RequestStep {
|
|
38
|
+
request: {
|
|
39
|
+
method?: string;
|
|
40
|
+
path: string;
|
|
41
|
+
status?: number;
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
body?: unknown;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ReleaseContract<TActors extends Record<string, ActorDefinition> = Record<string, ActorDefinition>> {
|
|
49
|
+
kind?: "release-contract";
|
|
50
|
+
version?: 1;
|
|
51
|
+
name: string;
|
|
52
|
+
title: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
businessValue: string;
|
|
55
|
+
criticality: Criticality;
|
|
56
|
+
platforms: Platform[];
|
|
57
|
+
policy?: {
|
|
58
|
+
always?: boolean;
|
|
59
|
+
prRelevant?: boolean;
|
|
60
|
+
nightly?: boolean;
|
|
61
|
+
tags?: string[];
|
|
62
|
+
};
|
|
63
|
+
url?: string;
|
|
64
|
+
app?: string;
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
actors: TActors;
|
|
67
|
+
variables?: Record<string, string | number | boolean>;
|
|
68
|
+
setup?: RequestStep[];
|
|
69
|
+
steps: Array<TaskContractStep<Extract<keyof TActors, string>> | ExpectContractStep<Extract<keyof TActors, string>>>;
|
|
70
|
+
teardown?: RequestStep[];
|
|
71
|
+
coverage?: {
|
|
72
|
+
nodes?: string[];
|
|
73
|
+
edges?: string[];
|
|
74
|
+
capabilities?: string[];
|
|
75
|
+
sourcePaths?: string[];
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export declare function defineContract<const TActors extends Record<string, ActorDefinition>>(
|
|
80
|
+
contract: ReleaseContract<TActors>,
|
|
81
|
+
): ReleaseContract<TActors> & { kind: "release-contract"; version: 1 };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Business-level release contracts compile to the same deterministic Flow or
|
|
2
|
+
// isolated Scenario runtime already used by every platform. TypeScript is an
|
|
3
|
+
// authoring/type-checking surface only; it never participates in replay.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import ts from "typescript";
|
|
9
|
+
import { compileFlowTasksFromRepository } from "./task-runtime.js";
|
|
10
|
+
import { semanticUiKey } from "./ui-map.js";
|
|
11
|
+
|
|
12
|
+
const PLATFORMS = new Set(["ios", "android", "web"]);
|
|
13
|
+
const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
|
|
14
|
+
const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts", "runtapp/contracts", "tapp-mcp/contracts"]);
|
|
15
|
+
const CONTRACT_AUTHORING_IMPORT = /(["'])(?:@aarwitz\/tapp|runtapp|tapp-mcp)\/contracts\1/g;
|
|
16
|
+
const authoringUrl = pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "contract-authoring.js")).href;
|
|
17
|
+
|
|
18
|
+
function expectationAction(expectation) {
|
|
19
|
+
const timeoutMs = Number(expectation.eventually?.timeoutMs || 6000);
|
|
20
|
+
if (expectation.screen) return { action: "assert_screen", target: expectation.screen, timeoutMs };
|
|
21
|
+
if (expectation.exists) return { action: "assert_exists", target: expectation.exists, timeoutMs };
|
|
22
|
+
if (expectation.absent) return { action: "assert_absent", target: expectation.absent, timeoutMs };
|
|
23
|
+
if (expectation.text) return { action: "assert_text", of: expectation.text.of, contains: expectation.text.contains, timeoutMs };
|
|
24
|
+
throw new Error("Contract expectation must define screen, exists, absent, or text");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateRequestPhases(contract, errors) {
|
|
28
|
+
for (const phase of ["setup", "teardown"]) {
|
|
29
|
+
if (contract[phase] !== undefined && !Array.isArray(contract[phase])) errors.push(`${phase} must be an array`);
|
|
30
|
+
for (const [index, step] of (contract[phase] || []).entries()) {
|
|
31
|
+
if (!step?.request || typeof step.request !== "object") errors.push(`${phase}[${index}] must be a request step`);
|
|
32
|
+
else if (!step.request.path) errors.push(`${phase}[${index}].request.path is required`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateReleaseContract(contract) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
if (!contract || typeof contract !== "object" || Array.isArray(contract)) return ["Release contract must be an object"];
|
|
40
|
+
if (contract.kind !== "release-contract") errors.push("kind must be 'release-contract'");
|
|
41
|
+
if (contract.version !== 1) errors.push("version must be 1");
|
|
42
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(String(contract.name || ""))) errors.push("name must be lower camelCase");
|
|
43
|
+
if (!String(contract.title || "").trim()) errors.push("title is required");
|
|
44
|
+
if (!String(contract.businessValue || "").trim()) errors.push("businessValue is required");
|
|
45
|
+
if (!CRITICALITIES.has(contract.criticality)) errors.push("criticality must be low|medium|high|critical");
|
|
46
|
+
if (!Array.isArray(contract.platforms) || contract.platforms.length === 0) errors.push("platforms must be a non-empty array");
|
|
47
|
+
else for (const platform of contract.platforms) if (!PLATFORMS.has(platform)) errors.push(`unsupported platform '${platform}'`);
|
|
48
|
+
|
|
49
|
+
const actorNames = contract.actors && typeof contract.actors === "object" && !Array.isArray(contract.actors)
|
|
50
|
+
? Object.keys(contract.actors) : [];
|
|
51
|
+
if (!actorNames.length) errors.push("actors must define at least one named actor");
|
|
52
|
+
if (actorNames.length > 1) {
|
|
53
|
+
for (const actor of actorNames) if ((contract.actors[actor].session || "isolated") !== "isolated") errors.push(`actor '${actor}' must use an isolated session in a multi-actor contract`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!Array.isArray(contract.steps) || contract.steps.length === 0) errors.push("steps must be a non-empty array");
|
|
57
|
+
let taskCount = 0;
|
|
58
|
+
for (const [index, step] of (contract.steps || []).entries()) {
|
|
59
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) { errors.push(`steps[${index}] must be an object`); continue; }
|
|
60
|
+
if (!actorNames.includes(step.actor)) errors.push(`steps[${index}].actor must name a defined actor`);
|
|
61
|
+
if (typeof step.task === "string") {
|
|
62
|
+
taskCount += 1;
|
|
63
|
+
if (step.expect !== undefined) errors.push(`steps[${index}] cannot define both task and expect`);
|
|
64
|
+
} else if (step.expect && typeof step.expect === "object") {
|
|
65
|
+
const keys = ["screen", "exists", "absent", "text"].filter((key) => step.expect[key] !== undefined);
|
|
66
|
+
if (keys.length !== 1) errors.push(`steps[${index}].expect must define exactly one exact assertion`);
|
|
67
|
+
const eventual = step.expect.eventually;
|
|
68
|
+
if (eventual) {
|
|
69
|
+
const timeout = Number(eventual.timeoutMs);
|
|
70
|
+
const poll = Number(eventual.pollMs || 250);
|
|
71
|
+
if (!(timeout > 0 && timeout <= 120000)) errors.push(`steps[${index}].expect.eventually.timeoutMs must be 1..120000`);
|
|
72
|
+
if (!(poll >= 50 && poll <= timeout)) errors.push(`steps[${index}].expect.eventually.pollMs must be between 50 and timeoutMs`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
errors.push(`steps[${index}] must call a Task or define an exact expectation`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!taskCount) errors.push("a release contract must compose at least one reusable Task");
|
|
79
|
+
validateRequestPhases(contract, errors);
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rewriteAuthoringImport(source, contractPath) {
|
|
84
|
+
if (/\bimport\s*\(/.test(source) || /\brequire\s*\(/.test(source)) {
|
|
85
|
+
throw new Error("Release contracts cannot use dynamic import or require");
|
|
86
|
+
}
|
|
87
|
+
const imports = [...source.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)].map((match) => match[1]);
|
|
88
|
+
const unsupported = imports.filter((specifier) => !CONTRACT_AUTHORING_SPECIFIERS.has(specifier));
|
|
89
|
+
if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (legacy runtapp/contracts and tapp-mcp/contracts are also accepted; found ${unsupported.join(", ")})`);
|
|
90
|
+
const output = ts.transpileModule(source, {
|
|
91
|
+
fileName: contractPath,
|
|
92
|
+
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: true },
|
|
93
|
+
reportDiagnostics: true,
|
|
94
|
+
});
|
|
95
|
+
const diagnostics = (output.diagnostics || []).filter((item) => item.category === ts.DiagnosticCategory.Error);
|
|
96
|
+
if (diagnostics.length) throw new Error(diagnostics.map((item) => ts.flattenDiagnosticMessageText(item.messageText, " ")).join("; "));
|
|
97
|
+
return output.outputText.replace(CONTRACT_AUTHORING_IMPORT, JSON.stringify(authoringUrl));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function loadReleaseContractFile(contractPath) {
|
|
101
|
+
const absolute = path.resolve(contractPath);
|
|
102
|
+
if (!fs.existsSync(absolute)) throw new Error(`Release contract not found: ${absolute}`);
|
|
103
|
+
const extension = path.extname(absolute).toLowerCase();
|
|
104
|
+
let contract;
|
|
105
|
+
if (extension === ".json") {
|
|
106
|
+
contract = JSON.parse(fs.readFileSync(absolute, "utf8"));
|
|
107
|
+
} else if ([".ts", ".mts", ".js", ".mjs"].includes(extension)) {
|
|
108
|
+
const source = fs.readFileSync(absolute, "utf8");
|
|
109
|
+
const code = extension === ".ts" || extension === ".mts" ? rewriteAuthoringImport(source, absolute)
|
|
110
|
+
: source.replace(CONTRACT_AUTHORING_IMPORT, JSON.stringify(authoringUrl));
|
|
111
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tapp-contract-"));
|
|
112
|
+
const modulePath = path.join(tempDir, "contract.mjs");
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(modulePath, code);
|
|
115
|
+
contract = (await import(`${pathToFileURL(modulePath).href}?v=${Date.now()}`)).default;
|
|
116
|
+
} finally {
|
|
117
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error("Release contracts must be .contract.ts, .mts, .mjs, .js, or .json");
|
|
121
|
+
}
|
|
122
|
+
const normalized = { ...contract, kind: contract?.kind || "release-contract", version: contract?.version ?? 1, __path: absolute };
|
|
123
|
+
const errors = validateReleaseContract(normalized);
|
|
124
|
+
if (errors.length) throw new Error(`Invalid Release Contract: ${errors.join("; ")}`);
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function compiledActors(contract) {
|
|
129
|
+
return Object.fromEntries(Object.entries(contract.actors).map(([name, actor]) => {
|
|
130
|
+
const { credentials = {}, ...publicActor } = actor;
|
|
131
|
+
return [name, {
|
|
132
|
+
...publicActor,
|
|
133
|
+
vars: {
|
|
134
|
+
...(actor.vars || {}),
|
|
135
|
+
...(credentials.email ? { EMAIL: credentials.email } : {}),
|
|
136
|
+
...(credentials.password ? { PASSWORD: credentials.password } : {}),
|
|
137
|
+
},
|
|
138
|
+
}];
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function compileReleaseContract(contract, { platform = "", sourcePath = contract.__path || "" } = {}) {
|
|
143
|
+
const errors = validateReleaseContract(contract);
|
|
144
|
+
if (errors.length) throw new Error(`Invalid Release Contract: ${errors.join("; ")}`);
|
|
145
|
+
const selected = String(platform || (contract.platforms.length === 1 ? contract.platforms[0] : "")).toLowerCase();
|
|
146
|
+
if (!selected) throw new Error("A platform is required when a release contract applies to multiple platforms");
|
|
147
|
+
if (!contract.platforms.includes(selected)) throw new Error(`Release contract '${contract.name}' does not apply to ${selected}`);
|
|
148
|
+
const actorNames = Object.keys(contract.actors);
|
|
149
|
+
if (actorNames.length > 1 && selected !== "web") throw new Error("isolated multi-actor release contracts currently run on web; iOS and Android isolation remain unsupported");
|
|
150
|
+
const multiActor = actorNames.length > 1;
|
|
151
|
+
if (!multiActor && selected !== "web" && ((contract.setup || []).length || (contract.teardown || []).length)) {
|
|
152
|
+
throw new Error("single-actor request setup/teardown currently runs on web; use target-native reset for iOS or Android");
|
|
153
|
+
}
|
|
154
|
+
const onlyActor = contract.actors[actorNames[0]];
|
|
155
|
+
const singleActorVars = multiActor ? {} : {
|
|
156
|
+
...(onlyActor.vars || {}),
|
|
157
|
+
...(onlyActor.credentials?.email ? { EMAIL: onlyActor.credentials.email } : {}),
|
|
158
|
+
...(onlyActor.credentials?.password ? { PASSWORD: onlyActor.credentials.password } : {}),
|
|
159
|
+
};
|
|
160
|
+
const sourceSteps = contract.steps.map((step) => {
|
|
161
|
+
const body = step.task
|
|
162
|
+
? { task: step.task, ...(step.with ? { with: step.with } : {}), ...(step.save ? { save: step.save } : {}) }
|
|
163
|
+
: expectationAction(step.expect);
|
|
164
|
+
return multiActor ? { actor: step.actor, do: body } : body;
|
|
165
|
+
});
|
|
166
|
+
const execution = {
|
|
167
|
+
name: contract.title,
|
|
168
|
+
kind: multiActor ? "scenario" : "flow",
|
|
169
|
+
platform: selected,
|
|
170
|
+
...(contract.url ? { url: contract.url } : {}),
|
|
171
|
+
...(contract.app ? { app: contract.app } : {}),
|
|
172
|
+
timeoutMs: Number(contract.timeoutMs) || 6000,
|
|
173
|
+
vars: { ...(contract.variables || {}), ...singleActorVars },
|
|
174
|
+
...(multiActor ? { actors: compiledActors(contract) } : {}),
|
|
175
|
+
setup: contract.setup || [],
|
|
176
|
+
steps: sourceSteps,
|
|
177
|
+
teardown: contract.teardown || [],
|
|
178
|
+
releaseContract: {
|
|
179
|
+
name: contract.name,
|
|
180
|
+
title: contract.title,
|
|
181
|
+
businessValue: contract.businessValue,
|
|
182
|
+
criticality: contract.criticality,
|
|
183
|
+
policy: contract.policy || {},
|
|
184
|
+
platforms: contract.platforms,
|
|
185
|
+
coverage: contract.coverage || {},
|
|
186
|
+
source: sourcePath,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
return compileFlowTasksFromRepository({ flow: execution, sourcePath, platform: selected });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function referencedNodes(contract, map) {
|
|
193
|
+
return (contract.coverage?.nodes || []).map((reference) => map.nodes.find((node) =>
|
|
194
|
+
node.id === reference || semanticUiKey(node.semanticKey) === semanticUiKey(reference) || node.name === reference)).filter(Boolean);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function validateReleaseContractAgainstUiMap(contract, map) {
|
|
198
|
+
const errors = validateReleaseContract(contract);
|
|
199
|
+
const warnings = [];
|
|
200
|
+
if (!map || map.schemaVersion !== 1) return { errors: [...errors, "A UI Map v1 is required"], warnings };
|
|
201
|
+
const nodes = referencedNodes(contract, map);
|
|
202
|
+
if (nodes.length !== (contract.coverage?.nodes || []).length) errors.push("coverage.nodes contains states not present in the UI Map");
|
|
203
|
+
const edges = new Set(map.edges.map((edge) => edge.id));
|
|
204
|
+
for (const edge of contract.coverage?.edges || []) if (!edges.has(edge)) errors.push(`coverage edge '${edge}' is not present in the UI Map`);
|
|
205
|
+
if (!(contract.coverage?.nodes || []).length) warnings.push("release contract does not yet cite UI Map states");
|
|
206
|
+
return { errors, warnings };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function applyReleaseContractCoverage(map, contract) {
|
|
210
|
+
const next = structuredClone(map);
|
|
211
|
+
const nodeIds = new Set(referencedNodes(contract, next).map((node) => node.id));
|
|
212
|
+
const edgeIds = new Set(contract.coverage?.edges || []);
|
|
213
|
+
next.coverage = next.coverage || { tasks: [], contracts: [], uncoveredNodeIds: [], uncoveredEdgeIds: [] };
|
|
214
|
+
next.coverage.contracts = [...new Set([...(next.coverage.contracts || []), contract.name])].sort();
|
|
215
|
+
for (const node of next.nodes) if (nodeIds.has(node.id)) {
|
|
216
|
+
node.coveredBy ||= { tasks: [], contracts: [] };
|
|
217
|
+
node.coveredBy.contracts = [...new Set([...(node.coveredBy.contracts || []), contract.name])].sort();
|
|
218
|
+
}
|
|
219
|
+
for (const edge of next.edges) if (edgeIds.has(edge.id)) {
|
|
220
|
+
edge.coveredBy ||= { tasks: [], contracts: [] };
|
|
221
|
+
edge.coveredBy.contracts = [...new Set([...(edge.coveredBy.contracts || []), contract.name])].sort();
|
|
222
|
+
}
|
|
223
|
+
next.coverage.uncoveredNodeIds = next.nodes.filter((node) => !(node.coveredBy?.tasks || []).length && !(node.coveredBy?.contracts || []).length).map((node) => node.id).sort();
|
|
224
|
+
next.coverage.uncoveredEdgeIds = next.edges.filter((edge) => !(edge.coveredBy?.tasks || []).length && !(edge.coveredBy?.contracts || []).length).map((edge) => edge.id).sort();
|
|
225
|
+
return next;
|
|
226
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Pure report/gate logic shared by the MCP server (index.js) and the CI gate CLI
|
|
2
|
+
// (ci-report.js). Turns a capture's OCQA markers into the same ship/no-ship report the AutoTap
|
|
3
|
+
// app produces, and diffs two runs' findings into the CI regression gate. No shell, no server —
|
|
4
|
+
// keep it dependency-free so the CI path stays importable and testable.
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const repoRoot = path.resolve(__dirname, "../..");
|
|
11
|
+
|
|
12
|
+
export function parseOcqaMarkers(markersFilePath) {
|
|
13
|
+
if (!fs.existsSync(markersFilePath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const raw = fs.readFileSync(markersFilePath, "utf8");
|
|
18
|
+
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
19
|
+
const counts = {
|
|
20
|
+
STATE: 0,
|
|
21
|
+
ACTION: 0,
|
|
22
|
+
TRANSITION: 0,
|
|
23
|
+
ISSUE: 0,
|
|
24
|
+
PROGRESS: 0,
|
|
25
|
+
COMPLETE: 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const states = [];
|
|
29
|
+
const actions = [];
|
|
30
|
+
const transitions = [];
|
|
31
|
+
const issues = [];
|
|
32
|
+
let complete = null;
|
|
33
|
+
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (!line.startsWith("OCQA_")) continue;
|
|
36
|
+
|
|
37
|
+
const sep = line.indexOf(":");
|
|
38
|
+
const key = sep >= 0 ? line.slice(0, sep) : line;
|
|
39
|
+
const payload = sep >= 0 ? line.slice(sep + 1).trim() : "";
|
|
40
|
+
const category = key.replace("OCQA_", "");
|
|
41
|
+
|
|
42
|
+
if (Object.prototype.hasOwnProperty.call(counts, category)) {
|
|
43
|
+
counts[category] += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let parsed = payload;
|
|
47
|
+
if (payload.startsWith("{")) {
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(payload);
|
|
50
|
+
} catch {
|
|
51
|
+
parsed = payload;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (category === "STATE") states.push(parsed);
|
|
56
|
+
if (category === "ACTION") actions.push(parsed);
|
|
57
|
+
if (category === "TRANSITION") transitions.push(parsed);
|
|
58
|
+
if (category === "ISSUE") issues.push(parsed);
|
|
59
|
+
if (category === "COMPLETE") complete = parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
markersFilePath,
|
|
64
|
+
relativeMarkersFilePath: path.relative(repoRoot, markersFilePath),
|
|
65
|
+
totalLines: lines.length,
|
|
66
|
+
counts,
|
|
67
|
+
uniqueScreens: Array.from(
|
|
68
|
+
new Set(
|
|
69
|
+
states
|
|
70
|
+
.map((state) => (state && typeof state === "object" ? state.screen : null))
|
|
71
|
+
.filter((screen) => typeof screen === "string" && screen.trim().length > 0)
|
|
72
|
+
)
|
|
73
|
+
),
|
|
74
|
+
complete,
|
|
75
|
+
recentActions: actions.slice(-5),
|
|
76
|
+
recentTransitions: transitions.slice(-5),
|
|
77
|
+
recentIssues: issues.slice(-5),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Map harness OCQA_ISSUE `type` -> AutoTap FindingCategory. Crashes are always critical.
|
|
82
|
+
export const ISSUE_CATEGORY = {
|
|
83
|
+
crash: "crash",
|
|
84
|
+
app_hang: "app_hang",
|
|
85
|
+
auth_failed: "auth_failure",
|
|
86
|
+
submit_failed: "unresponsive_element",
|
|
87
|
+
error_surface: "network_error_surface",
|
|
88
|
+
unresponsive_element: "unresponsive_element",
|
|
89
|
+
dead_end: "navigation_dead_end",
|
|
90
|
+
navigation_loop: "repeated_loop",
|
|
91
|
+
navigation_trap: "navigation_dead_end",
|
|
92
|
+
blank_screen: "blank_screen",
|
|
93
|
+
limited_surface: "blank_screen",
|
|
94
|
+
performance_timeout: "performance_timeout",
|
|
95
|
+
explore_timeout: "performance_timeout",
|
|
96
|
+
};
|
|
97
|
+
export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
|
|
98
|
+
|
|
99
|
+
export function severityRank(s) {
|
|
100
|
+
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Turn a capture's OCQA markers into the same ship/no-ship report the AutoTap app produces:
|
|
104
|
+
// deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
|
|
105
|
+
export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
|
|
106
|
+
const base = parseOcqaMarkers(markersFilePath);
|
|
107
|
+
if (!base) return null;
|
|
108
|
+
|
|
109
|
+
const raw = fs.readFileSync(markersFilePath, "utf8");
|
|
110
|
+
const rawIssues = [];
|
|
111
|
+
const screens = new Set();
|
|
112
|
+
const inputsByScreen = new Map();
|
|
113
|
+
const screenElementCounts = {}; // screen -> max elements observed (content-collapse detection)
|
|
114
|
+
let anySecure = false;
|
|
115
|
+
let actions = 0;
|
|
116
|
+
|
|
117
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
118
|
+
const t = line.trim();
|
|
119
|
+
if (t.startsWith("OCQA_ISSUE:")) {
|
|
120
|
+
try {
|
|
121
|
+
const o = JSON.parse(t.slice("OCQA_ISSUE:".length));
|
|
122
|
+
let sev = String(o.severity || "medium").toLowerCase();
|
|
123
|
+
if (CRITICAL_ISSUE_TYPES.has(o.type)) sev = "critical";
|
|
124
|
+
// `target` gives a finding its identity beyond type|screen — two dead buttons on the
|
|
125
|
+
// same screen are two findings, and fixing one while breaking another is a regression.
|
|
126
|
+
const target = (typeof o.control === "string" && o.control) || (typeof o.target === "string" && o.target) || null;
|
|
127
|
+
rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, step: o.step ?? null });
|
|
128
|
+
} catch {
|
|
129
|
+
/* ignore malformed */
|
|
130
|
+
}
|
|
131
|
+
} else if (t.startsWith("OCQA_ACTION:")) {
|
|
132
|
+
actions += 1;
|
|
133
|
+
} else if (t.startsWith("OCQA_STATE:{")) {
|
|
134
|
+
try {
|
|
135
|
+
const s = JSON.parse(t.slice("OCQA_STATE:".length));
|
|
136
|
+
if (typeof s.screen === "string" && s.screen.trim()) screens.add(s.screen);
|
|
137
|
+
if (typeof s.screen === "string" && s.screen.trim() && Number.isFinite(s.elements)) {
|
|
138
|
+
screenElementCounts[s.screen] = Math.max(screenElementCounts[s.screen] || 0, s.elements);
|
|
139
|
+
}
|
|
140
|
+
if (typeof s.screen === "string" && Array.isArray(s.inputs) && s.inputs.length) {
|
|
141
|
+
const fields = s.inputs
|
|
142
|
+
.map((f) => ({ label: f.label || f.placeholder || f.key || "", secure: !!f.secure }))
|
|
143
|
+
.filter((f) => f.label);
|
|
144
|
+
if (fields.length && !inputsByScreen.has(s.screen)) inputsByScreen.set(s.screen, fields);
|
|
145
|
+
if (fields.some((f) => f.secure)) anySecure = true;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const inputFieldsEncountered = Array.from(inputsByScreen.entries()).map(([screen, fields]) => ({ screen, fields }));
|
|
154
|
+
|
|
155
|
+
// Dedup by stable signature (type|screen|target) so repeated detections count once —
|
|
156
|
+
// but DIFFERENT controls failing on the same screen each count.
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const findings = [];
|
|
159
|
+
for (const i of rawIssues) {
|
|
160
|
+
const key = `${i.type}|${i.screen}|${i.target ?? ""}`;
|
|
161
|
+
if (seen.has(key)) continue;
|
|
162
|
+
seen.add(key);
|
|
163
|
+
findings.push({ ...i, category: ISSUE_CATEGORY[i.type] || i.type });
|
|
164
|
+
}
|
|
165
|
+
findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
166
|
+
|
|
167
|
+
const screensExplored = screens.size || base.uniqueScreens.length;
|
|
168
|
+
const actionsPerformed =
|
|
169
|
+
actions || (base.complete && typeof base.complete === "object" ? base.complete.actions || 0 : 0);
|
|
170
|
+
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
171
|
+
const high = findings.filter((f) => f.severity === "high").length;
|
|
172
|
+
const med = findings.filter((f) => f.severity === "medium").length;
|
|
173
|
+
const low = findings.filter((f) => f.severity === "low").length;
|
|
174
|
+
|
|
175
|
+
// Coverage floor: a verdict is only trustworthy if the app was actually exercised.
|
|
176
|
+
const inconclusive = screensExplored < 2 || actionsPerformed < 3;
|
|
177
|
+
let confidence = Math.max(0, Math.min(100, 100 - crit * 25 - high * 10 - med * 3));
|
|
178
|
+
if (inconclusive) confidence = Math.min(confidence, 40);
|
|
179
|
+
|
|
180
|
+
let verdict;
|
|
181
|
+
if (crit > 0) verdict = "blocked";
|
|
182
|
+
else if (inconclusive) verdict = "caution";
|
|
183
|
+
else if (confidence < 50) verdict = "blocked";
|
|
184
|
+
else if (high > 0 || confidence < 80) verdict = "caution";
|
|
185
|
+
else verdict = "ready";
|
|
186
|
+
|
|
187
|
+
const headline = inconclusive
|
|
188
|
+
? `Inconclusive — only ${screensExplored} screen(s) / ${actionsPerformed} action(s) explored. The app may have crashed on launch, be stuck behind a sign-in wall, or otherwise prevent exploration. Absence of issues is NOT a pass.`
|
|
189
|
+
: verdict === "ready"
|
|
190
|
+
? "Ship-ready — no release-blocking issues found."
|
|
191
|
+
: verdict === "caution"
|
|
192
|
+
? `Proceed with caution — ${findings.length} issue(s) to review.`
|
|
193
|
+
: `Not ready — ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
|
|
194
|
+
|
|
195
|
+
// The verdict's own honesty label: exactly which defect classes this run checked, which
|
|
196
|
+
// it structurally could NOT check, and which conditions never came up — so "checked" is
|
|
197
|
+
// never claimed for a state the run didn't reach. Platform-aware: a web run doesn't
|
|
198
|
+
// inherit iOS keyboard assertions and vice versa.
|
|
199
|
+
const conditionsNotReached = [];
|
|
200
|
+
let checkedFor;
|
|
201
|
+
let notChecked;
|
|
202
|
+
if (platform === "web") {
|
|
203
|
+
checkedFor = [
|
|
204
|
+
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
|
|
205
|
+
"dead buttons", "error text on pages", "load timeouts",
|
|
206
|
+
];
|
|
207
|
+
notChecked = [
|
|
208
|
+
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
209
|
+
"visual correctness — layout/images/clipping (vision review; needs an API key)",
|
|
210
|
+
"only the first few visible buttons per page are probed (web beta)",
|
|
211
|
+
"content & reachability regressions require a baseline",
|
|
212
|
+
];
|
|
213
|
+
} else if (platform === "android") {
|
|
214
|
+
checkedFor = [
|
|
215
|
+
"crashes / process exits", "dead controls", "error surfaces", "blank screens",
|
|
216
|
+
"navigation reachability", "form interaction",
|
|
217
|
+
];
|
|
218
|
+
notChecked = [
|
|
219
|
+
"app-specific business logic (cover with committed Flows)",
|
|
220
|
+
"visual correctness — layout/images/clipping",
|
|
221
|
+
"push notifications / system integrations",
|
|
222
|
+
"content & reachability regressions require a baseline",
|
|
223
|
+
];
|
|
224
|
+
} else {
|
|
225
|
+
checkedFor = [
|
|
226
|
+
"crashes (launch + in-run)", "hangs / stuck loading",
|
|
227
|
+
"dead controls (incl. navigation)", "error surfaces", "blank screens",
|
|
228
|
+
"navigation traps/loops", "keyboard-covered actions",
|
|
229
|
+
"lost field state (persistent-class fields)",
|
|
230
|
+
];
|
|
231
|
+
notChecked = [
|
|
232
|
+
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
233
|
+
"visual correctness — layout/images/clipping (vision review; needs an API key)",
|
|
234
|
+
"push notifications / system integrations",
|
|
235
|
+
"content & reachability regressions require a baseline" ,
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
// "Failed sign-ins" is only a claim when a sign-in surface was actually encountered.
|
|
239
|
+
if (anySecure) checkedFor.splice(2, 0, "failed sign-ins");
|
|
240
|
+
else conditionsNotReached.push("sign-in (no login form encountered this run)");
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
verdict,
|
|
244
|
+
// `releaseScore` is the honest name: a heuristic quality score from fixed deductions,
|
|
245
|
+
// NOT calibrated statistical confidence. `confidence` is kept as an alias for
|
|
246
|
+
// compatibility (baselines, desktop app, existing consumers).
|
|
247
|
+
confidence,
|
|
248
|
+
releaseScore: confidence,
|
|
249
|
+
headline,
|
|
250
|
+
inconclusive,
|
|
251
|
+
checkedFor,
|
|
252
|
+
notChecked,
|
|
253
|
+
conditionsNotReached,
|
|
254
|
+
platform,
|
|
255
|
+
screensExplored,
|
|
256
|
+
actionsPerformed,
|
|
257
|
+
findingCounts: { critical: crit, high, medium: med, low, total: findings.length },
|
|
258
|
+
findings,
|
|
259
|
+
screens: Array.from(screens),
|
|
260
|
+
screenElementCounts,
|
|
261
|
+
inputFieldsEncountered,
|
|
262
|
+
loginEncountered: anySecure,
|
|
263
|
+
complete: base.complete,
|
|
264
|
+
relativeMarkersFilePath: base.relativeMarkersFilePath,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Content-collapse regression: screens that were rich in the baseline but are near-empty now.
|
|
269
|
+
// The app "works" (renders, navigates, no errors) while its content pipeline is broken — the
|
|
270
|
+
// class NO per-run detector can catch deterministically (a silent empty feed looks like a legit
|
|
271
|
+
// empty state). Cross-run, it's unambiguous. Found via corpus bug-seeding: a broken API host in
|
|
272
|
+
// a real HN client produced SHIP-READY 100/100 until this comparison existed.
|
|
273
|
+
const COLLAPSE_MIN_BASELINE = 10; // only screens that clearly HAD content
|
|
274
|
+
const COLLAPSE_RATIO = 0.4; // current below 40% of baseline = collapsed
|
|
275
|
+
export function computeContentCollapse(currentCounts, baselineCounts) {
|
|
276
|
+
if (!currentCounts || !baselineCounts) return [];
|
|
277
|
+
const findings = [];
|
|
278
|
+
for (const [screen, base] of Object.entries(baselineCounts)) {
|
|
279
|
+
const cur = currentCounts[screen];
|
|
280
|
+
if (cur === undefined || base < COLLAPSE_MIN_BASELINE) continue;
|
|
281
|
+
if (cur <= base * COLLAPSE_RATIO) {
|
|
282
|
+
findings.push({
|
|
283
|
+
type: "content_collapse",
|
|
284
|
+
severity: "high",
|
|
285
|
+
category: "content_collapse",
|
|
286
|
+
title: `Screen lost most of its content (${base} → ${cur} elements)`,
|
|
287
|
+
screen,
|
|
288
|
+
step: null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return findings;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Reachability-loss regression: screens the baseline explored that this run never reached
|
|
296
|
+
// at all. Content-collapse can't see them (nothing to compare against) — but a screen
|
|
297
|
+
// vanishing from the same-budget exploration usually means navigation regressed (a dead
|
|
298
|
+
// back button trapping the explorer, a broken link, a crash short-circuiting a flow).
|
|
299
|
+
// Found via subtle-bug seeding: a dead back button stranded the run on one screen and
|
|
300
|
+
// SHIP-READY passed with 3 of 6 baseline screens missing. Guarded: only fires when the
|
|
301
|
+
// current run had a comparable action budget (≥60% of baseline actions), so a legit
|
|
302
|
+
// short run doesn't spray false losses.
|
|
303
|
+
export function computeReachabilityLoss(current, baseline) {
|
|
304
|
+
if (!current?.screens || !baseline?.screens) return [];
|
|
305
|
+
const baseActions = baseline.actionsPerformed || 0;
|
|
306
|
+
if (baseActions > 0 && (current.actionsPerformed || 0) < baseActions * 0.6) return [];
|
|
307
|
+
const reached = new Set(current.screens);
|
|
308
|
+
return baseline.screens
|
|
309
|
+
.filter((s) => !reached.has(s))
|
|
310
|
+
.map((screen) => ({
|
|
311
|
+
type: "screen_unreachable",
|
|
312
|
+
severity: "high",
|
|
313
|
+
category: "navigation_dead_end",
|
|
314
|
+
title: "Screen explored in the baseline was never reached this run",
|
|
315
|
+
screen,
|
|
316
|
+
step: null,
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Cross-run regression: diff this run's deduped findings against a baseline (the `findings` array a
|
|
321
|
+
// prior tapp_run_qa returned), matched by the same stable signature the dedup uses (type|screen).
|
|
322
|
+
// Mirrors the Swift FindingRegression.compute. Returns null when no baseline is supplied (first run).
|
|
323
|
+
// The `gate` block is the CI signal: a wrapper sets a non-zero exit when gate.failed is true.
|
|
324
|
+
export function computeRegression(current, baseline) {
|
|
325
|
+
if (!Array.isArray(baseline)) return null;
|
|
326
|
+
// Identity is type|screen|target when a target (control id) is known — type|screen alone
|
|
327
|
+
// would classify "Save fixed, Delete Account newly broken on Settings" as one persisting
|
|
328
|
+
// dead_button and let the new defect through the gate. Coarse matching remains as a
|
|
329
|
+
// migration fallback ONLY when one side predates target identity (old baselines), so
|
|
330
|
+
// upgrading never sprays false "new" findings.
|
|
331
|
+
const fine = (f) => `${f.type}|${f.screen ?? null}|${f.target ?? ""}`;
|
|
332
|
+
const coarse = (f) => `${f.type}|${f.screen ?? null}`;
|
|
333
|
+
const baseFine = new Set(baseline.map(fine));
|
|
334
|
+
const baseCoarseAll = new Set(baseline.map(coarse));
|
|
335
|
+
const baseCoarseNoTarget = new Set(baseline.filter((f) => f.target == null).map(coarse));
|
|
336
|
+
const currFine = new Set(current.map(fine));
|
|
337
|
+
const currCoarseAll = new Set(current.map(coarse));
|
|
338
|
+
const currCoarseNoTarget = new Set(current.filter((f) => f.target == null).map(coarse));
|
|
339
|
+
|
|
340
|
+
const currentMatches = (f) =>
|
|
341
|
+
baseFine.has(fine(f)) ||
|
|
342
|
+
(f.target == null && baseCoarseAll.has(coarse(f))) ||
|
|
343
|
+
(f.target != null && baseCoarseNoTarget.has(coarse(f)));
|
|
344
|
+
const baselineMatched = (b) =>
|
|
345
|
+
currFine.has(fine(b)) ||
|
|
346
|
+
(b.target == null && currCoarseAll.has(coarse(b))) ||
|
|
347
|
+
(b.target != null && currCoarseNoTarget.has(coarse(b)));
|
|
348
|
+
|
|
349
|
+
const newFindings = current.filter((f) => !currentMatches(f));
|
|
350
|
+
const persisting = current.filter((f) => currentMatches(f));
|
|
351
|
+
const resolved = baseline.filter((b) => !baselineMatched(b));
|
|
352
|
+
const newCritical = newFindings.filter((f) => f.severity === "critical").length;
|
|
353
|
+
const newHigh = newFindings.filter((f) => f.severity === "high").length;
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
hadBaseline: true,
|
|
357
|
+
counts: { new: newFindings.length, persisting: persisting.length, resolved: resolved.length },
|
|
358
|
+
newFindings,
|
|
359
|
+
resolved,
|
|
360
|
+
// CI gate: fail the build when this run introduced new high/critical findings vs. the baseline.
|
|
361
|
+
gate: { newCritical, newHigh, failed: newCritical + newHigh > 0 },
|
|
362
|
+
};
|
|
363
|
+
}
|