@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,1597 @@
|
|
|
1
|
+
// Deterministic repository import for `tapp init`. This constructs inspectable
|
|
2
|
+
// facts and grounded release-plan proposals without launching a target or
|
|
3
|
+
// sending source/customer data to a model.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { credentialBindingsFromValue, readProjectConfig } from "./project-config.js";
|
|
8
|
+
import { applyReleaseContractCoverage, compileReleaseContract, loadReleaseContractFile, validateReleaseContractAgainstUiMap } from "./release-contract.js";
|
|
9
|
+
import { applyTaskCoverage, loadTaskFile, validateTaskAgainstUiMap } from "./task-runtime.js";
|
|
10
|
+
import { semanticUiKey } from "./ui-map.js";
|
|
11
|
+
|
|
12
|
+
const SKIP = new Set([".git", ".build", ".gradle", ".next", ".swiftpm", "Pods", "Carthage", "DerivedData", "build", "dist", "node_modules", "vendor"]);
|
|
13
|
+
|
|
14
|
+
function posix(value) { return String(value || "").replaceAll("\\", "/"); }
|
|
15
|
+
function relative(root, value) { return posix(path.relative(root, value)) || "."; }
|
|
16
|
+
function stableId(prefix, value) { return `${prefix}_${crypto.createHash("sha256").update(value).digest("hex").slice(0, 16)}`; }
|
|
17
|
+
function humanize(value) {
|
|
18
|
+
return String(value || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim().replace(/^./, (letter) => letter.toUpperCase());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function walk(root, maxDepth = 4) {
|
|
22
|
+
const files = [];
|
|
23
|
+
const directories = [];
|
|
24
|
+
const visit = (dir, depth) => {
|
|
25
|
+
let entries;
|
|
26
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (SKIP.has(entry.name) || (entry.name.startsWith(".") && entry.name !== ".autotap")) continue;
|
|
29
|
+
const absolute = path.join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
directories.push(absolute);
|
|
32
|
+
if (/\.(xcodeproj|xcworkspace)$/.test(entry.name)) continue;
|
|
33
|
+
if (depth < maxDepth) visit(absolute, depth + 1);
|
|
34
|
+
} else if (entry.isFile()) files.push(absolute);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
visit(root, 0);
|
|
38
|
+
return { files, directories };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sourceEvidence(paths, detail = "") {
|
|
42
|
+
return { basis: "source-observed", paths: [...new Set(paths)].sort(), ...(detail ? { detail } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function reviewedEvidence(paths, detail = "") {
|
|
46
|
+
return { basis: "reviewed-artifact", paths: [...new Set(paths)].sort(), ...(detail ? { detail } : {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readJson(file) {
|
|
50
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function detectIosTargets(root, inventory) {
|
|
54
|
+
const containers = inventory.directories.filter((item) => /\.(xcworkspace|xcodeproj)$/.test(item) && !item.includes(`${path.sep}Pods${path.sep}`));
|
|
55
|
+
const workspaces = containers.filter((item) => item.endsWith(".xcworkspace") && !item.includes(".xcodeproj/"));
|
|
56
|
+
const projects = containers.filter((item) => item.endsWith(".xcodeproj"));
|
|
57
|
+
const selected = workspaces.length ? workspaces : projects;
|
|
58
|
+
return selected.map((container) => {
|
|
59
|
+
const source = relative(root, container);
|
|
60
|
+
const sharedSchemesDir = path.join(container, "xcshareddata", "xcschemes");
|
|
61
|
+
let schemes = [];
|
|
62
|
+
try { schemes = fs.readdirSync(sharedSchemesDir).filter((name) => name.endsWith(".xcscheme")).map((name) => name.replace(/\.xcscheme$/, "")); } catch {}
|
|
63
|
+
const fallback = path.basename(container).replace(/\.(xcworkspace|xcodeproj)$/, "");
|
|
64
|
+
return {
|
|
65
|
+
id: stableId("target", `ios|${source}`), platform: "ios", kind: "ios-simulator", name: fallback,
|
|
66
|
+
sourcePath: source, status: schemes.length ? "configured" : "needs-confirmation",
|
|
67
|
+
build: { tool: "xcodebuild", container: source, schemeCandidates: schemes, proposedScheme: schemes.find((item) => item === fallback) || schemes[0] || fallback, configuration: "Debug" },
|
|
68
|
+
runtime: { surface: "iOS Simulator", signingRequired: false },
|
|
69
|
+
evidence: sourceEvidence([source, ...schemes.map((name) => posix(path.join(source, "xcshareddata/xcschemes", `${name}.xcscheme`)))], schemes.length ? "shared scheme observed" : "scheme is a source-derived proposal and must be validated by a build"),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function repositoryRelativePath(root, value) {
|
|
75
|
+
if (!String(value || "").trim()) return "";
|
|
76
|
+
let absolute = path.resolve(root, String(value));
|
|
77
|
+
try { absolute = fs.realpathSync(absolute); } catch { return ""; }
|
|
78
|
+
const candidate = path.relative(root, absolute);
|
|
79
|
+
if (!candidate || candidate === "." || path.isAbsolute(candidate) || candidate === ".." || candidate.startsWith(`..${path.sep}`)) return "";
|
|
80
|
+
return posix(candidate);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyRuntimeTargetValidation(root, targets, validation) {
|
|
84
|
+
if (!validation || validation.platform !== "ios" || validation.resolution?.kind !== "xcode-build-installed") return targets;
|
|
85
|
+
const build = validation.resolution.build || {};
|
|
86
|
+
const container = repositoryRelativePath(root, build.container);
|
|
87
|
+
const scheme = String(build.scheme || "").trim();
|
|
88
|
+
const configuration = String(build.configuration || "").trim() || "Debug";
|
|
89
|
+
const bundleId = String(validation.target || validation.resolution.bundleId || "").trim();
|
|
90
|
+
if (!container || !scheme || !bundleId) return targets;
|
|
91
|
+
const captureId = String(validation.evidence?.captureId || "").trim();
|
|
92
|
+
return targets.map((target) => {
|
|
93
|
+
if (target.platform !== "ios" || posix(target.sourcePath) !== container) return target;
|
|
94
|
+
return {
|
|
95
|
+
...target,
|
|
96
|
+
status: "configured",
|
|
97
|
+
build: {
|
|
98
|
+
...target.build,
|
|
99
|
+
schemeCandidates: [...new Set([...(target.build?.schemeCandidates || []), scheme])].sort(),
|
|
100
|
+
proposedScheme: scheme,
|
|
101
|
+
configuration,
|
|
102
|
+
},
|
|
103
|
+
runtime: { ...target.runtime, bundleId },
|
|
104
|
+
evidence: {
|
|
105
|
+
...target.evidence,
|
|
106
|
+
detail: `The scheme was source-derived, then confirmed by a successful Tapp build as '${scheme}'.`,
|
|
107
|
+
},
|
|
108
|
+
runtimeValidation: {
|
|
109
|
+
status: "validated",
|
|
110
|
+
basis: "runtime-observed",
|
|
111
|
+
operation: "xcode-build-install-explore",
|
|
112
|
+
target: bundleId,
|
|
113
|
+
build: { container, scheme, configuration },
|
|
114
|
+
evidence: {
|
|
115
|
+
...(captureId ? { capture: portableEvidenceReference(`tapp-capture:${captureId}`) } : {}),
|
|
116
|
+
verdict: String(validation.evidence?.verdict || "unknown"),
|
|
117
|
+
inconclusive: validation.evidence?.inconclusive === true,
|
|
118
|
+
...(validation.evidence?.observedAt ? { observedAt: String(validation.evidence.observedAt) } : {}),
|
|
119
|
+
},
|
|
120
|
+
detail: "Tapp built this repository target with the recorded scheme, installed it, launched it, and produced UI Map evidence.",
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function persistedTargetValidations(root, outDir) {
|
|
127
|
+
const artifactDir = path.resolve(root, String(outDir || ".autotap"));
|
|
128
|
+
const relativeArtifactDir = path.relative(root, artifactDir);
|
|
129
|
+
if (path.isAbsolute(relativeArtifactDir) || relativeArtifactDir === ".." || relativeArtifactDir.startsWith(`..${path.sep}`)) return [];
|
|
130
|
+
const prior = readJson(path.join(artifactDir, "application-model.json"));
|
|
131
|
+
if (prior?.schemaVersion !== 1 || prior.kind !== "tapp-application-model" || !Array.isArray(prior.targets)) return [];
|
|
132
|
+
return prior.targets.flatMap((target) => {
|
|
133
|
+
const validation = target?.runtimeValidation;
|
|
134
|
+
if (target?.platform !== "ios" || validation?.status !== "validated" || validation?.basis !== "runtime-observed" || validation?.operation !== "xcode-build-install-explore") return [];
|
|
135
|
+
const capture = String(validation.evidence?.capture || "");
|
|
136
|
+
return [{
|
|
137
|
+
platform: "ios",
|
|
138
|
+
target: String(validation.target || target.runtime?.bundleId || ""),
|
|
139
|
+
resolution: {
|
|
140
|
+
kind: "xcode-build-installed",
|
|
141
|
+
bundleId: String(validation.target || target.runtime?.bundleId || ""),
|
|
142
|
+
build: {
|
|
143
|
+
container: validation.build?.container,
|
|
144
|
+
scheme: validation.build?.scheme,
|
|
145
|
+
configuration: validation.build?.configuration,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
evidence: {
|
|
149
|
+
captureId: capture.startsWith("tapp-capture:") ? capture.slice("tapp-capture:".length) : "",
|
|
150
|
+
verdict: validation.evidence?.verdict,
|
|
151
|
+
inconclusive: validation.evidence?.inconclusive === true,
|
|
152
|
+
observedAt: validation.evidence?.observedAt,
|
|
153
|
+
},
|
|
154
|
+
}];
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function androidApplicationId(source) {
|
|
159
|
+
const match = source.match(/\bapplicationId\s*(?:=\s*)?["']([^"']+)["']/);
|
|
160
|
+
return match?.[1] || "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function appliesAndroidApplicationPlugin(source) {
|
|
164
|
+
if (/apply\s*(?:plugin\s*:\s*|plugin\s*=\s*)["']com\.android\.application["']/.test(source)) return true;
|
|
165
|
+
const blocks = [...source.matchAll(/plugins\s*\{([\s\S]*?)\}/g)].map((match) => match[1]);
|
|
166
|
+
return blocks.some((block) => /com\.android\.application/.test(block) && !/\bapply\s+false\b/.test(block));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function detectAndroidTargets(root, inventory) {
|
|
170
|
+
const gradleFiles = inventory.files.filter((item) => /(?:^|\/)(?:build\.gradle(?:\.kts)?)$/.test(posix(item)));
|
|
171
|
+
return gradleFiles.flatMap((file) => {
|
|
172
|
+
const source = fs.readFileSync(file, "utf8");
|
|
173
|
+
if (!appliesAndroidApplicationPlugin(source)) return [];
|
|
174
|
+
const moduleDir = path.dirname(file);
|
|
175
|
+
let gradleRoot = moduleDir;
|
|
176
|
+
while (gradleRoot !== root && !fs.existsSync(path.join(gradleRoot, "gradlew")) && !fs.existsSync(path.join(gradleRoot, "settings.gradle")) && !fs.existsSync(path.join(gradleRoot, "settings.gradle.kts"))) gradleRoot = path.dirname(gradleRoot);
|
|
177
|
+
const modulePath = relative(gradleRoot, moduleDir);
|
|
178
|
+
const appId = androidApplicationId(source);
|
|
179
|
+
return [{
|
|
180
|
+
id: stableId("target", `android|${relative(root, moduleDir)}`), platform: "android", kind: "android-application", name: path.basename(moduleDir),
|
|
181
|
+
sourcePath: relative(root, moduleDir), status: appId ? "configured" : "needs-confirmation",
|
|
182
|
+
build: { tool: fs.existsSync(path.join(gradleRoot, "gradlew")) ? "gradle-wrapper" : "gradle", projectDir: relative(root, gradleRoot), task: `${modulePath === "." ? "" : `:${posix(modulePath).replaceAll("/", ":")}`}:assembleDebug`.replace(/^::/, ":") },
|
|
183
|
+
runtime: { surface: "Android emulator/device", applicationId: appId || null },
|
|
184
|
+
evidence: sourceEvidence([relative(root, file)], appId ? "application plugin and application id observed" : "application plugin observed; application id requires confirmation"),
|
|
185
|
+
}];
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const WEB_HINTS = new Set(["@angular/core", "@remix-run/react", "astro", "next", "nuxt", "react", "react-dom", "svelte", "vite", "vue"]);
|
|
190
|
+
const WEB_LOCKFILES = [
|
|
191
|
+
["package-lock.json", "npm ci"],
|
|
192
|
+
["npm-shrinkwrap.json", "npm ci"],
|
|
193
|
+
["pnpm-lock.yaml", "corepack pnpm install --frozen-lockfile"],
|
|
194
|
+
["yarn.lock", "corepack yarn install --immutable"],
|
|
195
|
+
["bun.lock", "bun install --frozen-lockfile"],
|
|
196
|
+
["bun.lockb", "bun install --frozen-lockfile"],
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
function webDependencyPlan(root, dir, pkg) {
|
|
200
|
+
const dependencyCount = ["dependencies", "devDependencies", "optionalDependencies"]
|
|
201
|
+
.reduce((total, key) => total + Object.keys(pkg[key] || {}).length, 0);
|
|
202
|
+
if (!dependencyCount) return { install: null, dependencyStatus: "not-required", evidencePaths: [] };
|
|
203
|
+
let cursor = dir;
|
|
204
|
+
while (true) {
|
|
205
|
+
for (const [name, install] of WEB_LOCKFILES) {
|
|
206
|
+
const candidate = path.join(cursor, name);
|
|
207
|
+
if (fs.existsSync(candidate)) return {
|
|
208
|
+
install, dependencyStatus: "locked", lockfile: relative(root, candidate),
|
|
209
|
+
installProjectDir: relative(root, cursor), evidencePaths: [relative(root, candidate)],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (cursor === root) break;
|
|
213
|
+
const parent = path.dirname(cursor);
|
|
214
|
+
if (parent === cursor || !isInsideRoot(root, parent)) break;
|
|
215
|
+
cursor = parent;
|
|
216
|
+
}
|
|
217
|
+
return { install: null, dependencyStatus: "missing-lockfile", evidencePaths: [] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isInsideRoot(root, candidate) {
|
|
221
|
+
const value = path.relative(root, candidate);
|
|
222
|
+
return value === "" || (!value.startsWith(`..${path.sep}`) && value !== "..");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function detectWebTargets(root, inventory, ownedUrl = "") {
|
|
226
|
+
const packageFiles = inventory.files.filter((item) => path.basename(item) === "package.json");
|
|
227
|
+
const targets = packageFiles.flatMap((file) => {
|
|
228
|
+
const pkg = readJson(file);
|
|
229
|
+
if (!pkg) return [];
|
|
230
|
+
const dir = path.dirname(file);
|
|
231
|
+
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
232
|
+
const scripts = pkg.scripts || {};
|
|
233
|
+
const indexEntry = ["index.html", "public/index.html", "src/index.html"].find((candidate) => fs.existsSync(path.join(dir, candidate)));
|
|
234
|
+
const hasIndex = !!indexEntry;
|
|
235
|
+
const webDependency = Object.keys(dependencies).some((name) => WEB_HINTS.has(name));
|
|
236
|
+
const startEntry = ["dev", "start", "serve", "preview"].find((name) => typeof scripts[name] === "string");
|
|
237
|
+
// A Node service having `start: node server.js` is not evidence that it owns
|
|
238
|
+
// a browser UI. Require an actual browser entrypoint or a recognized web
|
|
239
|
+
// framework; start scripts only explain how to launch an already-grounded
|
|
240
|
+
// web target.
|
|
241
|
+
if (!hasIndex && !webDependency) return [];
|
|
242
|
+
const sourcePath = relative(root, dir);
|
|
243
|
+
const dependencyPlan = webDependencyPlan(root, dir, pkg);
|
|
244
|
+
const managed = dependencyPlan.dependencyStatus !== "missing-lockfile" && (!!startEntry || hasIndex);
|
|
245
|
+
return [{
|
|
246
|
+
id: stableId("target", `web|${sourcePath}`), platform: "web", kind: "browser-application", name: pkg.name || path.basename(dir),
|
|
247
|
+
sourcePath, status: (ownedUrl || managed) ? "configured" : "needs-confirmation",
|
|
248
|
+
build: { tool: "package-script", projectDir: sourcePath, ...dependencyPlan, ...(scripts.build ? { build: "npm run build" } : {}), start: startEntry ? `npm run ${startEntry}` : null },
|
|
249
|
+
runtime: { surface: "Chromium", ownedUrl: ownedUrl || null, management: ownedUrl ? "customer-managed" : managed ? "tapp-managed" : "unresolved" },
|
|
250
|
+
evidence: sourceEvidence([relative(root, file), ...(indexEntry ? [relative(root, path.join(dir, indexEntry))] : []), ...dependencyPlan.evidencePaths], `${startEntry ? `start script '${startEntry}' observed` : "static entrypoint observed"}; dependencies ${dependencyPlan.dependencyStatus}; runtime ${ownedUrl ? "customer-provided URL" : managed ? "Tapp-managed build/start" : "unresolved"}`),
|
|
251
|
+
}];
|
|
252
|
+
});
|
|
253
|
+
if (!targets.length && fs.existsSync(path.join(root, "index.html"))) {
|
|
254
|
+
targets.push({
|
|
255
|
+
id: stableId("target", "web|."), platform: "web", kind: "static-browser-application", name: path.basename(root),
|
|
256
|
+
sourcePath: ".", status: "configured",
|
|
257
|
+
build: { tool: "static-files", projectDir: ".", install: null, start: null },
|
|
258
|
+
runtime: { surface: "Chromium", ownedUrl: ownedUrl || null, management: ownedUrl ? "customer-managed" : "tapp-managed" },
|
|
259
|
+
evidence: sourceEvidence(["index.html"], `static browser entrypoint observed; runtime ${ownedUrl ? "customer-provided URL" : "Tapp-managed static server"}`),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return targets;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function loadUiMapAt(root, relativePath) {
|
|
266
|
+
const mapPath = path.join(root, relativePath);
|
|
267
|
+
const map = readJson(mapPath);
|
|
268
|
+
const artifactPath = posix(relativePath);
|
|
269
|
+
if (!map || map.schemaVersion !== 1) return { map: null, summary: { path: artifactPath, status: "missing", nodeCount: 0, edgeCount: 0, controlCount: 0, uncoveredNodeIds: [], uncoveredEdgeIds: [], platforms: [] } };
|
|
270
|
+
const nodeCount = map.nodes?.length || 0;
|
|
271
|
+
const lastRun = map.provenance?.lastRun || null;
|
|
272
|
+
const inconclusive = nodeCount < 1 || lastRun?.inconclusive === true;
|
|
273
|
+
return {
|
|
274
|
+
map,
|
|
275
|
+
summary: {
|
|
276
|
+
path: artifactPath, status: inconclusive ? "inconclusive" : "observed", nodeCount, edgeCount: map.edges?.length || 0,
|
|
277
|
+
controlCount: (map.nodes || []).reduce((total, node) => total + (node.controls?.length || 0), 0),
|
|
278
|
+
uncoveredNodeIds: map.coverage?.uncoveredNodeIds || [], uncoveredEdgeIds: map.coverage?.uncoveredEdgeIds || [],
|
|
279
|
+
platforms: map.app?.platforms || map.application?.platforms || [],
|
|
280
|
+
...(lastRun ? { lastRun } : {}),
|
|
281
|
+
evidence: reviewedEvidence([artifactPath], nodeCount < 1 ? "artifact exists but contains no observed UI states" : lastRun?.inconclusive ? "runtime states were observed, but the latest exploration was explicitly inconclusive" : "grounded in prior runtime observations"),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function targetArtifactScope(target) {
|
|
287
|
+
const source = posix(target.sourcePath || ".");
|
|
288
|
+
if (target.platform === "ios" && /\.(?:xcodeproj|xcworkspace)$/.test(source)) return posix(path.dirname(source)) || ".";
|
|
289
|
+
return source;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function uiMapTargetsTarget(map, target, targets) {
|
|
293
|
+
const platforms = map?.app?.platforms || map?.application?.platforms || [];
|
|
294
|
+
if (platforms.length && !platforms.includes(target.platform)) return false;
|
|
295
|
+
const hint = String(map?.app?.target || map?.application?.target || "").trim();
|
|
296
|
+
const identities = new Set([target.id, target.name, target.sourcePath, target.runtime?.applicationId, target.runtime?.bundleId, target.runtime?.ownedUrl].filter(Boolean).map(String));
|
|
297
|
+
if (hint && identities.has(hint)) return true;
|
|
298
|
+
return platforms.length === 1 && targets.filter((candidate) => candidate.platform === target.platform).length === 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function loadTargetUiMaps(root, targets) {
|
|
302
|
+
const rootMap = loadUiMapAt(root, path.join(".autotap", "ui-map.json"));
|
|
303
|
+
const records = targets.map((target) => {
|
|
304
|
+
const scope = targetArtifactScope(target);
|
|
305
|
+
const expectedPath = posix(path.join(scope === "." ? "" : scope, ".autotap", "ui-map.json"));
|
|
306
|
+
let loaded = expectedPath === rootMap.summary.path ? rootMap : loadUiMapAt(root, expectedPath);
|
|
307
|
+
if (!loaded.map && rootMap.map && (targets.length === 1 || uiMapTargetsTarget(rootMap.map, target, targets))) loaded = rootMap;
|
|
308
|
+
return {
|
|
309
|
+
targetId: target.id,
|
|
310
|
+
targetName: target.name,
|
|
311
|
+
platform: target.platform,
|
|
312
|
+
expectedPath,
|
|
313
|
+
map: loaded.map,
|
|
314
|
+
summary: { ...loaded.summary, targetId: target.id, targetName: target.name, platform: target.platform, expectedPath },
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
const unique = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
for (const record of records) {
|
|
320
|
+
if (!record.map || seen.has(record.summary.path)) continue;
|
|
321
|
+
seen.add(record.summary.path);
|
|
322
|
+
unique.push(record);
|
|
323
|
+
}
|
|
324
|
+
if (!targets.length && rootMap.map) unique.push({ map: rootMap.map, summary: rootMap.summary });
|
|
325
|
+
const allObserved = records.length > 0 && records.every((record) => record.summary.status === "observed");
|
|
326
|
+
const someObserved = records.some((record) => record.summary.status === "observed");
|
|
327
|
+
const someInconclusive = records.some((record) => record.summary.status === "inconclusive");
|
|
328
|
+
const prefixCoverage = unique.length > 1;
|
|
329
|
+
const coverageValues = (field) => unique.flatMap((record) => (record.summary[field] || []).map((id) => prefixCoverage ? `${record.summary.targetId}:${id}` : id));
|
|
330
|
+
const summary = {
|
|
331
|
+
path: unique.length === 1 ? unique[0].summary.path : ".autotap/ui-map.json",
|
|
332
|
+
paths: unique.map((record) => record.summary.path).sort(),
|
|
333
|
+
status: allObserved ? "observed" : someObserved ? "partial" : someInconclusive ? "inconclusive" : "missing",
|
|
334
|
+
nodeCount: unique.reduce((total, record) => total + record.summary.nodeCount, 0),
|
|
335
|
+
edgeCount: unique.reduce((total, record) => total + record.summary.edgeCount, 0),
|
|
336
|
+
controlCount: unique.reduce((total, record) => total + record.summary.controlCount, 0),
|
|
337
|
+
uncoveredNodeIds: coverageValues("uncoveredNodeIds"),
|
|
338
|
+
uncoveredEdgeIds: coverageValues("uncoveredEdgeIds"),
|
|
339
|
+
platforms: [...new Set(unique.flatMap((record) => record.summary.platforms || []))].sort(),
|
|
340
|
+
observedTargetIds: records.filter((record) => record.summary.status === "observed").map((record) => record.targetId),
|
|
341
|
+
missingTargetIds: records.filter((record) => record.summary.status !== "observed").map((record) => record.targetId),
|
|
342
|
+
evidence: reviewedEvidence(unique.map((record) => record.summary.path), allObserved ? "every detected target has a grounded UI Map" : someObserved ? "some detected targets have grounded UI Maps; missing or inconclusive targets remain explicit" : "no detected target has a conclusive grounded UI Map"),
|
|
343
|
+
...(unique.length === 1 && unique[0].summary.lastRun ? { lastRun: unique[0].summary.lastRun } : {}),
|
|
344
|
+
};
|
|
345
|
+
const planningMap = rootMap.map || (targets.length === 1 ? records[0]?.map || null : null);
|
|
346
|
+
return { map: planningMap, maps: records, summary };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function artifactScope(root, file) {
|
|
350
|
+
const parts = relative(root, file).split("/");
|
|
351
|
+
const index = parts.indexOf(".autotap");
|
|
352
|
+
return index > 0 ? parts.slice(0, index).join("/") : ".";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function artifactFiles(root, inventory, kind, pattern) {
|
|
356
|
+
return inventory.files.filter((file) => {
|
|
357
|
+
const parts = relative(root, file).split("/");
|
|
358
|
+
const index = parts.indexOf(".autotap");
|
|
359
|
+
return index >= 0 && parts[index + 1] === kind && pattern.test(path.basename(file));
|
|
360
|
+
}).sort();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function taskArtifacts(root, inventory) {
|
|
364
|
+
const tasks = [];
|
|
365
|
+
const errors = [];
|
|
366
|
+
for (const file of artifactFiles(root, inventory, "tasks", /\.ya?ml$|\.json$/i)) {
|
|
367
|
+
try { tasks.push({ ...loadTaskFile(file), __scope: artifactScope(root, file) }); }
|
|
368
|
+
catch (error) { errors.push({ path: relative(root, file), error: error.message || String(error) }); }
|
|
369
|
+
}
|
|
370
|
+
return { tasks, errors };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function contractArtifacts(root, inventory) {
|
|
374
|
+
const files = artifactFiles(root, inventory, "contracts", /\.contract\.(?:ts|mts|mjs|js|json)$/i);
|
|
375
|
+
const contracts = [];
|
|
376
|
+
const errors = [];
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
try { contracts.push({ ...await loadReleaseContractFile(file), __scope: artifactScope(root, file) }); }
|
|
379
|
+
catch (error) { errors.push({ path: relative(root, file), error: error.message || String(error) }); }
|
|
380
|
+
}
|
|
381
|
+
return { contracts, errors };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function capabilityCriticality(name) {
|
|
385
|
+
const key = semanticUiKey(name);
|
|
386
|
+
if (/checkout|payment|purchase|revenue|order/.test(key)) return "critical";
|
|
387
|
+
if (/auth|sign-in|account|message|publish|create|save/.test(key)) return "high";
|
|
388
|
+
return "medium";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function entityFromTask(name) {
|
|
392
|
+
const match = String(name || "").match(/^(?:add|archive|create|delete|edit|like|open|publish|remove|save|send|update|view)([A-Z].*)$/);
|
|
393
|
+
if (!match) return "";
|
|
394
|
+
const noun = match[1].replace(/To[A-Z].*$/, "").replace(/From[A-Z].*$/, "");
|
|
395
|
+
if (!noun || /^(home|settings|profile|feed|conversation)$/i.test(noun)) return "";
|
|
396
|
+
return humanize(noun).replace(/\bFirst\b/i, "").trim();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function taskNamesFromContract(contract) {
|
|
400
|
+
return [...new Set((contract.steps || []).filter((step) => typeof step.task === "string").map((step) => step.task))];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function taskPlatforms(task, applicationPlatforms) {
|
|
404
|
+
if (Array.isArray(task.steps) || task.implementations?.shared || task.implementations?.default) return applicationPlatforms;
|
|
405
|
+
return Object.keys(task.implementations || {}).filter((platform) => ["ios", "android", "web"].includes(platform)).sort();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function safeTaskInputs(task) {
|
|
409
|
+
return Object.fromEntries(Object.entries(task.inputs || {}).map(([name, value]) => {
|
|
410
|
+
const definition = value && typeof value === "object" && !Array.isArray(value) ? value : { default: value };
|
|
411
|
+
return [name, {
|
|
412
|
+
required: definition.required !== false,
|
|
413
|
+
secret: definition.secret === true,
|
|
414
|
+
...(!definition.secret && Object.hasOwn(definition, "default") ? { default: definition.default } : {}),
|
|
415
|
+
}];
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function applicationName(root, targets) {
|
|
420
|
+
const pkg = readJson(path.join(root, "package.json"));
|
|
421
|
+
return pkg?.name || (targets.length === 1 ? targets[0].name : path.basename(root));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".autotap" } = {}) {
|
|
425
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
426
|
+
const inventory = walk(root);
|
|
427
|
+
let targets = [
|
|
428
|
+
...detectIosTargets(root, inventory),
|
|
429
|
+
...detectAndroidTargets(root, inventory),
|
|
430
|
+
...detectWebTargets(root, inventory, ownedUrl),
|
|
431
|
+
];
|
|
432
|
+
if (platform) targets = targets.filter((target) => target.platform === platform);
|
|
433
|
+
for (const priorValidation of persistedTargetValidations(root, outDir)) targets = applyRuntimeTargetValidation(root, targets, priorValidation);
|
|
434
|
+
targets = applyRuntimeTargetValidation(root, targets, targetValidation);
|
|
435
|
+
const { map, maps: uiMaps, summary: uiMap } = loadTargetUiMaps(root, targets);
|
|
436
|
+
const { tasks, errors: taskErrors } = taskArtifacts(root, inventory);
|
|
437
|
+
const { contracts, errors: contractErrors } = await contractArtifacts(root, inventory);
|
|
438
|
+
const contractPathByName = new Map(contracts.map((contract) => [contract.name, relative(root, contract.__path)]));
|
|
439
|
+
const projectConfiguration = readProjectConfig(root);
|
|
440
|
+
|
|
441
|
+
const actorsByName = new Map();
|
|
442
|
+
for (const [name, actor] of Object.entries(projectConfiguration.config?.actors || {})) {
|
|
443
|
+
actorsByName.set(name, {
|
|
444
|
+
name,
|
|
445
|
+
roles: actor.role ? [actor.role] : [],
|
|
446
|
+
contracts: [],
|
|
447
|
+
credentialRequirements: Object.keys(actor.credentials || {}),
|
|
448
|
+
credentialBindings: Object.fromEntries(Object.entries(actor.credentials || {}).map(([key, binding]) => [key, binding.env])),
|
|
449
|
+
credentialsConfigured: Object.keys(actor.credentials || {}).length > 0,
|
|
450
|
+
session: actor.session || "default",
|
|
451
|
+
provisioning: actor.provisioning || "existing",
|
|
452
|
+
configured: true,
|
|
453
|
+
bindingConflicts: [],
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
for (const contract of contracts) for (const [name, actor] of Object.entries(contract.actors || {})) {
|
|
457
|
+
const prior = actorsByName.get(name) || { name, roles: [], contracts: [], credentialRequirements: [], credentialBindings: {}, configured: false, bindingConflicts: [] };
|
|
458
|
+
if (actor.role && !prior.roles.includes(actor.role)) prior.roles.push(actor.role);
|
|
459
|
+
if (!prior.contracts.includes(contract.name)) prior.contracts.push(contract.name);
|
|
460
|
+
for (const key of Object.keys(actor.credentials || {})) if (!prior.credentialRequirements.includes(key)) prior.credentialRequirements.push(key);
|
|
461
|
+
for (const [key, env] of Object.entries(credentialBindingsFromValue(actor.credentials))) {
|
|
462
|
+
if (prior.credentialBindings[key] && prior.credentialBindings[key] !== env) prior.bindingConflicts.push({ credential: key, configuredEnv: prior.credentialBindings[key], contractEnv: env, contract: contract.name });
|
|
463
|
+
else prior.credentialBindings[key] = env;
|
|
464
|
+
}
|
|
465
|
+
if (actor.session || !prior.configured) prior.session = actor.session || (Object.keys(contract.actors).length > 1 ? "isolated" : "default");
|
|
466
|
+
prior.provisioning ||= "unknown";
|
|
467
|
+
prior.credentialsConfigured ||= Object.keys(actor.credentials || {}).length > 0;
|
|
468
|
+
actorsByName.set(name, prior);
|
|
469
|
+
}
|
|
470
|
+
const actors = [...actorsByName.values()].map((actor) => {
|
|
471
|
+
const configuredPath = actor.configured ? [projectConfiguration.relativePath] : [];
|
|
472
|
+
const paths = [...configuredPath, ...actor.contracts.map((name) => contractPathByName.get(name)).filter(Boolean)];
|
|
473
|
+
return {
|
|
474
|
+
...actor,
|
|
475
|
+
roles: actor.roles.sort(),
|
|
476
|
+
contracts: actor.contracts.sort(),
|
|
477
|
+
credentialRequirements: actor.credentialRequirements.sort(),
|
|
478
|
+
credentialBindings: Object.fromEntries(Object.entries(actor.credentialBindings || {}).sort(([a], [b]) => a.localeCompare(b))),
|
|
479
|
+
bindingConflicts: actor.bindingConflicts || [],
|
|
480
|
+
evidence: reviewedEvidence(paths, actor.configured ? "actor, session, provisioning, and environment-variable names are explicit human configuration; credential values are never included" : "actor is derived from a reviewed contract; credential values intentionally omitted"),
|
|
481
|
+
};
|
|
482
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
483
|
+
|
|
484
|
+
const capabilities = new Map();
|
|
485
|
+
const addCapability = (name, basis, sourcePath, task = "", contract = "") => {
|
|
486
|
+
const key = semanticUiKey(name);
|
|
487
|
+
if (!key || key === "unknown") return;
|
|
488
|
+
const prior = capabilities.get(key) || { id: `capability_${key}`, name: humanize(name), status: basis, tasks: [], contracts: [], sourcePaths: [] };
|
|
489
|
+
if (task && !prior.tasks.includes(task)) prior.tasks.push(task);
|
|
490
|
+
if (contract && !prior.contracts.includes(contract)) prior.contracts.push(contract);
|
|
491
|
+
if (sourcePath && !prior.sourcePaths.includes(sourcePath)) prior.sourcePaths.push(sourcePath);
|
|
492
|
+
if (basis === "declared") prior.status = "declared";
|
|
493
|
+
capabilities.set(key, prior);
|
|
494
|
+
};
|
|
495
|
+
for (const task of tasks) addCapability(task.name, "task-derived", relative(root, task.__path), task.name);
|
|
496
|
+
for (const contract of contracts) {
|
|
497
|
+
for (const name of contract.coverage?.capabilities || []) addCapability(name, "declared", relative(root, contract.__path), "", contract.name);
|
|
498
|
+
for (const task of taskNamesFromContract(contract)) addCapability(task, "task-derived", relative(root, contract.__path), task, contract.name);
|
|
499
|
+
}
|
|
500
|
+
const capabilityList = [...capabilities.values()].map((item) => ({ ...item, tasks: item.tasks.sort(), contracts: item.contracts.sort(), sourcePaths: item.sourcePaths.sort(), evidence: item.status === "declared" ? reviewedEvidence(item.sourcePaths) : sourceEvidence(item.sourcePaths, "derived from a reusable Task name; requires review") })).sort((a, b) => a.id.localeCompare(b.id));
|
|
501
|
+
|
|
502
|
+
const entitiesByName = new Map();
|
|
503
|
+
const addEntity = (name, status, task = "", contract = "", sourcePath = "") => {
|
|
504
|
+
const key = semanticUiKey(name);
|
|
505
|
+
if (!key || key === "unknown") return;
|
|
506
|
+
const prior = entitiesByName.get(key) || { id: `entity_${key}`, name: humanize(name), status, tasks: [], contracts: [], sourcePaths: [] };
|
|
507
|
+
if (task && !prior.tasks.includes(task)) prior.tasks.push(task);
|
|
508
|
+
if (contract && !prior.contracts.includes(contract)) prior.contracts.push(contract);
|
|
509
|
+
if (sourcePath && !prior.sourcePaths.includes(sourcePath)) prior.sourcePaths.push(sourcePath);
|
|
510
|
+
if (status === "declared") prior.status = "declared";
|
|
511
|
+
entitiesByName.set(key, prior);
|
|
512
|
+
};
|
|
513
|
+
for (const task of tasks) {
|
|
514
|
+
const entity = entityFromTask(task.name);
|
|
515
|
+
if (entity) addEntity(entity, "task-derived", task.name, "", relative(root, task.__path));
|
|
516
|
+
}
|
|
517
|
+
for (const contract of contracts) for (const entity of contract.coverage?.entities || []) addEntity(entity, "declared", "", contract.name, relative(root, contract.__path));
|
|
518
|
+
const entities = [...entitiesByName.values()].map((entity) => ({ ...entity, tasks: entity.tasks.sort(), contracts: entity.contracts.sort(), sourcePaths: entity.sourcePaths.sort(), evidence: entity.status === "declared" ? reviewedEvidence(entity.sourcePaths) : sourceEvidence(entity.sourcePaths, "noun derived from a reusable Task; requires review") })).sort((a, b) => a.id.localeCompare(b.id));
|
|
519
|
+
|
|
520
|
+
const contractActors = (contract) => Object.entries(contract.actors || {}).map(([name, actor]) => ({
|
|
521
|
+
name,
|
|
522
|
+
session: actor.session || (Object.keys(contract.actors || {}).length > 1 ? "isolated" : "default"),
|
|
523
|
+
credentialRequirements: Object.keys(actor.credentials || {}).sort(),
|
|
524
|
+
credentialBindings: Object.fromEntries(Object.entries(credentialBindingsFromValue(actor.credentials)).sort(([a], [b]) => a.localeCompare(b))),
|
|
525
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
526
|
+
|
|
527
|
+
const journeys = contracts.map((contract) => ({
|
|
528
|
+
id: stableId("journey", `${contract.__scope}|${contract.name}`), name: contract.title, status: "authored-unvalidated", contract: contract.name, scope: contract.__scope,
|
|
529
|
+
criticality: contract.criticality, businessValue: contract.businessValue, actors: Object.keys(contract.actors), tasks: taskNamesFromContract(contract),
|
|
530
|
+
platforms: contract.platforms, evidence: reviewedEvidence([relative(root, contract.__path)], "committed contract exists; current-revision replay evidence is still required"),
|
|
531
|
+
}));
|
|
532
|
+
const revenuePaths = journeys.filter((journey) => /checkout|payment|purchase|revenue|order|subscription|pricing/i.test(`${journey.name} ${journey.businessValue}`)).map((journey) => ({ journeyId: journey.id, contract: journey.contract, status: journey.status, evidence: journey.evidence }));
|
|
533
|
+
const systemInvariants = contracts.filter((contract) => Object.keys(contract.actors || {}).length > 1).map((contract) => ({
|
|
534
|
+
id: stableId("invariant", `${contract.__scope}|${contract.name}`), name: contract.title, contract: contract.name, scope: contract.__scope, actors: Object.keys(contract.actors), status: "authored-unvalidated",
|
|
535
|
+
evidence: reviewedEvidence([relative(root, contract.__path)], "cross-actor behavior is authored but requires current replay evidence"),
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
const requirements = [];
|
|
539
|
+
if (!targets.length) requirements.push({ id: "target", severity: "blocking", status: "missing", message: "No iOS application project, Android application module, or browser application target was detected.", remediation: "Pass the representative target explicitly or add its unavoidable build/runtime configuration." });
|
|
540
|
+
for (const target of targets) {
|
|
541
|
+
if (target.platform === "ios" && target.status !== "configured") requirements.push({ id: `${target.id}:scheme`, severity: "blocking", status: "needs-confirmation", message: `Confirm a shared build scheme for ${target.name}.`, remediation: `Run tapp build ${target.sourcePath} or provide the scheme during init/build.` });
|
|
542
|
+
if (target.platform === "android" && !target.runtime.applicationId) requirements.push({ id: `${target.id}:application-id`, severity: "blocking", status: "missing", message: `Android application id was not statically detected for ${target.name}.`, remediation: "Provide --app-id or expose applicationId in the application module." });
|
|
543
|
+
if (target.platform === "web" && !target.runtime.ownedUrl && target.runtime.management !== "tapp-managed") requirements.push({ id: `${target.id}:owned-url`, severity: "blocking", status: "missing", message: `No safe managed runtime or owned URL is available for ${target.name}.`, remediation: "Add a deterministic start/static target or start the app and rerun tapp init with --url http://127.0.0.1:<port>." });
|
|
544
|
+
if (target.platform === "web" && target.build.dependencyStatus === "missing-lockfile") requirements.push({ id: `${target.id}:dependency-lock`, severity: "blocking", status: "missing", message: `${target.name} declares browser dependencies without an observed dependency lockfile.`, remediation: "Commit the package-manager lockfile so Tapp can install dependencies reproducibly, then rerun tapp init." });
|
|
545
|
+
}
|
|
546
|
+
const incompleteMaps = uiMaps.filter((record) => record.summary.status !== "observed");
|
|
547
|
+
for (const record of incompleteMaps.length ? incompleteMaps : (!targets.length && uiMap.status !== "observed" ? [{ summary: uiMap }] : [])) {
|
|
548
|
+
const target = record.targetId ? targets.find((candidate) => candidate.id === record.targetId) : null;
|
|
549
|
+
const summary = record.summary;
|
|
550
|
+
requirements.push({
|
|
551
|
+
id: targets.length <= 1 ? "ui-map" : `${target.id}:ui-map`, severity: "blocking", status: summary.status === "inconclusive" ? "inconclusive" : "missing",
|
|
552
|
+
message: target ? (summary.status === "inconclusive" ? `The UI Map for ${target.name} is inconclusive.` : `No grounded UI Map exists for ${target.name}.`) : "No repository UI Map has been grounded in a real run.",
|
|
553
|
+
remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run tapp init --explore so real exploration evidence is merged into .autotap/ui-map.json.",
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (!contracts.length) requirements.push({ id: "contracts", severity: "warning", status: "missing", message: "No reviewed release contracts exist yet.", remediation: "Review the proposed release plan, then generate and validate a compact set of contracts." });
|
|
557
|
+
if (!actors.length) requirements.push({ id: "actors", severity: "warning", status: "unknown", message: "No user roles or actors are represented in reviewed artifacts.", remediation: "Provide test actors/roles when the product has authentication or cross-user behavior." });
|
|
558
|
+
for (const error of projectConfiguration.errors) requirements.push({ id: stableId("project-config-error", error), severity: "blocking", status: "invalid", message: error, remediation: `Fix ${projectConfiguration.relativePath}; actor configuration must contain environment-variable bindings, never credential values.` });
|
|
559
|
+
for (const actor of actors) {
|
|
560
|
+
const missingBindings = actor.credentialRequirements.filter((credential) => !actor.credentialBindings[credential]);
|
|
561
|
+
if (missingBindings.length) requirements.push({ id: `actor:${actor.name}:credential-bindings`, severity: "blocking", status: "missing", message: `Actor '${actor.name}' has unbound credential requirements: ${missingBindings.join(", ")}.`, remediation: `Run tapp actor set ${actor.name} --credential <name>=<ENV_NAME> for each credential, then replace any literal contract credentials with $ENV_NAME placeholders.` });
|
|
562
|
+
if (actor.bindingConflicts.length) requirements.push({ id: `actor:${actor.name}:credential-conflicts`, severity: "blocking", status: "conflict", message: `Actor '${actor.name}' has conflicting credential environment bindings.`, remediation: `Align ${projectConfiguration.relativePath} and reviewed contracts; Tapp will not guess which secret binding is correct.` });
|
|
563
|
+
}
|
|
564
|
+
for (const error of taskErrors) requirements.push({ id: stableId("task-error", error.path), severity: "blocking", status: "invalid", message: error.error, remediation: `Fix ${error.path} before generation.` });
|
|
565
|
+
for (const error of contractErrors) requirements.push({ id: stableId("contract-error", error.path), severity: "blocking", status: "invalid", message: error.error, remediation: `Fix ${error.path} before trusting the release plan.` });
|
|
566
|
+
|
|
567
|
+
const model = {
|
|
568
|
+
schemaVersion: 1, kind: "tapp-application-model",
|
|
569
|
+
application: { name: applicationName(root, targets), repositoryRoot: ".", platforms: [...new Set(targets.map((target) => target.platform))].sort(), targetIds: targets.map((target) => target.id) },
|
|
570
|
+
targets,
|
|
571
|
+
actors,
|
|
572
|
+
entities,
|
|
573
|
+
capabilities: capabilityList,
|
|
574
|
+
criticalJourneys: journeys,
|
|
575
|
+
revenuePaths,
|
|
576
|
+
systemInvariants,
|
|
577
|
+
configuration: {
|
|
578
|
+
path: projectConfiguration.relativePath,
|
|
579
|
+
status: projectConfiguration.errors.length ? "invalid" : projectConfiguration.exists ? "configured" : "missing",
|
|
580
|
+
actorCount: Object.keys(projectConfiguration.config?.actors || {}).length,
|
|
581
|
+
lifecycle: {
|
|
582
|
+
setupSteps: projectConfiguration.config?.lifecycle?.setup?.length || 0,
|
|
583
|
+
teardownSteps: projectConfiguration.config?.lifecycle?.teardown?.length || 0,
|
|
584
|
+
},
|
|
585
|
+
evidence: projectConfiguration.exists ? reviewedEvidence([projectConfiguration.relativePath], "explicit human configuration; only environment-variable names are included in the application model") : sourceEvidence([], "optional project configuration has not been created"),
|
|
586
|
+
},
|
|
587
|
+
environments: targets.map((target) => ({ targetId: target.id, platform: target.platform, status: target.status, runtime: target.runtime })),
|
|
588
|
+
stateBoundaries: actors.length > 1 ? [{ kind: "actor-session", isolation: "required", actors: actors.map((actor) => actor.name) }] : [],
|
|
589
|
+
uiMap,
|
|
590
|
+
uiMaps: uiMaps.map((record) => record.summary),
|
|
591
|
+
artifacts: { tasks: tasks.map((task) => ({ id: stableId("task", relative(root, task.__path)), name: task.name, scope: task.__scope, path: relative(root, task.__path), version: task.version, platforms: taskPlatforms(task, [...new Set(targets.map((target) => target.platform))].sort()), inputs: safeTaskInputs(task) })), contracts: contracts.map((contract) => ({ id: stableId("contract", relative(root, contract.__path)), name: contract.name, scope: contract.__scope, path: relative(root, contract.__path), criticality: contract.criticality, platforms: contract.platforms, actors: contractActors(contract) })) },
|
|
592
|
+
requirements,
|
|
593
|
+
provenance: {
|
|
594
|
+
generatedBy: "tapp-init", generatedAt: new Date().toISOString(), remoteAiUsed: false,
|
|
595
|
+
distinctions: ["runtime-observed", "source-observed", "reviewed-artifact", "source-derived-proposal", "human-decision"],
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
return { root, model, map, maps: uiMaps.map((record) => ({ targetId: record.targetId, path: record.summary.path, map: record.map })), tasks, contracts, projectConfiguration };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function candidateFromTask(task, capability, platforms, actor) {
|
|
602
|
+
return {
|
|
603
|
+
id: stableId("proposal", `task|${task.path}`), kind: "release-contract", name: `${task.name}Works`, title: `${humanize(task.name)} remains available`, scope: task.scope,
|
|
604
|
+
origin: "deterministic-source-proposal", decision: "pending", criticality: capabilityCriticality(task.name),
|
|
605
|
+
businessValue: `Protect the reviewed ${humanize(task.name).toLowerCase()} capability through its reusable Task.`, actors: [actor], tasks: [task.name], platforms: task.platforms?.length ? task.platforms : platforms,
|
|
606
|
+
taskInputs: { [task.name]: task.inputs || {} },
|
|
607
|
+
risk: "The Task exists but is not composed by a reviewed release contract.", groundedBy: [{ type: "task", path: task.path }, { type: "capability", id: capability?.id || `capability_${semanticUiKey(task.name)}` }],
|
|
608
|
+
requiredValidation: "Compile and replay against the real target before approval or commit.",
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function releasePlanCandidateFromUiMapNode(node, platforms, actor = "customer", { scope = ".", targetId = "", mapPath = "" } = {}) {
|
|
613
|
+
const displayName = node.stateLabel || node.name;
|
|
614
|
+
return {
|
|
615
|
+
id: stableId("proposal", `node|${targetId}|${node.id}`), kind: "release-contract", name: `${semanticUiKey(displayName).replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Reachable`, title: `${displayName} remains reachable`, scope,
|
|
616
|
+
origin: "deterministic-ui-map-proposal", decision: "pending", criticality: capabilityCriticality(node.name),
|
|
617
|
+
businessValue: `Protect access to the observed ${displayName} product surface.`, actors: [actor], tasks: [], platforms: node.platforms?.length ? node.platforms : platforms,
|
|
618
|
+
risk: "Observed UI behavior is not covered by a reviewed Task or release contract.", groundedBy: [{ type: "ui-map-node", id: node.id, observationCount: node.observation?.count || 0, ...(targetId ? { targetId } : {}), ...(mapPath ? { mapPath } : {}) }],
|
|
619
|
+
requiredValidation: "Author reusable Tasks for observed transitions, then compile and replay the contract against the real target.",
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function taskScreenCondition(task, phase) {
|
|
624
|
+
const condition = (task[phase] || []).find((item) => item && typeof item === "object" && typeof item.screen === "string");
|
|
625
|
+
return condition?.screen || "";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function crossActorVisibleOutput(task) {
|
|
629
|
+
const entity = entityFromTask(task.name);
|
|
630
|
+
if (!/^(announcement|comment|content|listing|post|update)$/i.test(entity)) return null;
|
|
631
|
+
for (const [output, definition] of Object.entries(task.outputs || {})) {
|
|
632
|
+
const input = definition?.fromInput;
|
|
633
|
+
if (!input || task.inputs?.[input]?.secret === true) continue;
|
|
634
|
+
const visible = (task.postconditions || []).some((condition) => condition?.exists === `{{${input}}}`);
|
|
635
|
+
if (visible) return { entity, input, output };
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function configuredActorPair(model) {
|
|
641
|
+
const actors = (model.actors || []).filter((actor) => actor.configured && actor.session === "isolated" && actor.credentialBindings?.email && actor.credentialBindings?.password);
|
|
642
|
+
for (let left = 0; left < actors.length; left += 1) for (let right = left + 1; right < actors.length; right += 1) {
|
|
643
|
+
const sharedRole = actors[left].roles.find((role) => actors[right].roles.includes(role));
|
|
644
|
+
if (sharedRole) return { actors: [actors[left], actors[right]], role: sharedRole };
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function crossActorProposals({ model, tasks, contracts, projectConfiguration }) {
|
|
650
|
+
if (!model.application.platforms.includes("web") || projectConfiguration?.errors?.length) return [];
|
|
651
|
+
const lifecycle = projectConfiguration?.config?.lifecycle || {};
|
|
652
|
+
if (!(lifecycle.setup?.length && lifecycle.teardown?.length)) return [];
|
|
653
|
+
const pair = configuredActorPair(model);
|
|
654
|
+
if (!pair) return [];
|
|
655
|
+
const existingMultiActorTasks = new Set(contracts.filter((contract) => Object.keys(contract.actors || {}).length > 1).flatMap(taskNamesFromContract));
|
|
656
|
+
const artifacts = new Map(model.artifacts.tasks.map((task) => [`${task.scope}|${task.name}`, task]));
|
|
657
|
+
const proposals = [];
|
|
658
|
+
for (const producer of tasks) {
|
|
659
|
+
const output = crossActorVisibleOutput(producer);
|
|
660
|
+
if (!output || existingMultiActorTasks.has(producer.name)) continue;
|
|
661
|
+
const producerPlatforms = taskPlatforms(producer, model.application.platforms);
|
|
662
|
+
if (!producerPlatforms.includes("web")) continue;
|
|
663
|
+
const producerEntry = taskScreenCondition(producer, "preconditions");
|
|
664
|
+
if (!producerEntry) continue;
|
|
665
|
+
const authentication = tasks.find((candidate) => {
|
|
666
|
+
if (candidate.__scope !== producer.__scope || !/^(authenticate|logIn|signIn)/.test(candidate.name)) return false;
|
|
667
|
+
if (!taskPlatforms(candidate, model.application.platforms).includes("web")) return false;
|
|
668
|
+
if (candidate.inputs?.email?.secret !== true || candidate.inputs?.password?.secret !== true) return false;
|
|
669
|
+
return semanticUiKey(taskScreenCondition(candidate, "postconditions")) === semanticUiKey(producerEntry);
|
|
670
|
+
});
|
|
671
|
+
if (!authentication) continue;
|
|
672
|
+
const producerArtifact = artifacts.get(`${producer.__scope}|${producer.name}`);
|
|
673
|
+
const authArtifact = artifacts.get(`${authentication.__scope}|${authentication.name}`);
|
|
674
|
+
if (!producerArtifact || !authArtifact) continue;
|
|
675
|
+
const [creator, observer] = pair.actors.map((actor) => actor.name);
|
|
676
|
+
const entityKey = semanticUiKey(output.entity);
|
|
677
|
+
const entityCamel = entityKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
678
|
+
const suffix = crypto.createHash("sha256").update(`${producer.__scope}|${producer.name}|${output.output}`).digest("hex").slice(0, 6);
|
|
679
|
+
const value = `Tapp cross-actor ${output.entity.toLowerCase()} ${suffix}`;
|
|
680
|
+
const sharedVariable = `SHARED_${entityKey.replaceAll("-", "_").toUpperCase()}`;
|
|
681
|
+
proposals.push({
|
|
682
|
+
id: stableId("proposal", `cross-actor|${producer.__scope}|${producer.name}|${creator}|${observer}`),
|
|
683
|
+
kind: "release-contract",
|
|
684
|
+
name: `${entityCamel}PropagatesAcrossActors`,
|
|
685
|
+
title: `${output.entity} created by one ${pair.role} becomes visible to another`,
|
|
686
|
+
scope: producer.__scope,
|
|
687
|
+
origin: "deterministic-cross-actor-proposal",
|
|
688
|
+
decision: "pending",
|
|
689
|
+
criticality: "high",
|
|
690
|
+
businessValue: `Protect cross-account ${output.entity.toLowerCase()} propagation between isolated ${pair.role} sessions.`,
|
|
691
|
+
actors: [creator, observer],
|
|
692
|
+
tasks: [authentication.name, producer.name],
|
|
693
|
+
platforms: ["web"],
|
|
694
|
+
policy: { always: true, prRelevant: true, nightly: true, tags: ["multi-actor", entityKey, "propagation"] },
|
|
695
|
+
taskInputs: { [authentication.name]: safeTaskInputs(authentication), [producer.name]: safeTaskInputs(producer) },
|
|
696
|
+
constraints: { inputs: { [producer.name]: { [output.input]: value } } },
|
|
697
|
+
lifecycleSource: "project-config",
|
|
698
|
+
journeySteps: [
|
|
699
|
+
{ actor: creator, task: authentication.name, with: { email: "$EMAIL", password: "$PASSWORD" }, reason: `${humanize(creator)} needs an isolated authenticated session.` },
|
|
700
|
+
{ actor: observer, task: authentication.name, with: { email: "$EMAIL", password: "$PASSWORD" }, reason: `${humanize(observer)} must not share ${humanize(creator)}'s session.` },
|
|
701
|
+
{ actor: creator, task: producer.name, with: { [output.input]: value }, save: { [output.output]: sharedVariable } },
|
|
702
|
+
{ actor: observer, expect: { exists: `$${sharedVariable}`, eventually: { timeoutMs: 10000, pollMs: 250 } }, reason: `The observed Task output must propagate across isolated accounts within a bounded interval.` },
|
|
703
|
+
],
|
|
704
|
+
risk: `The producer Task proves local creation, but no reviewed contract proves another isolated ${pair.role} can observe its output.`,
|
|
705
|
+
groundedBy: [
|
|
706
|
+
{ type: "actor-config", path: projectConfiguration.relativePath, actors: [creator, observer], role: pair.role },
|
|
707
|
+
{ type: "lifecycle", path: projectConfiguration.relativePath, setupSteps: lifecycle.setup.length, teardownSteps: lifecycle.teardown.length },
|
|
708
|
+
{ type: "task", path: authArtifact.path, task: authentication.name },
|
|
709
|
+
{ type: "task-output", path: producerArtifact.path, task: producer.name, input: output.input, output: output.output, postcondition: `exists {{${output.input}}}` },
|
|
710
|
+
{ type: "capability", id: `capability_${semanticUiKey(producer.name)}`, name: producer.name },
|
|
711
|
+
],
|
|
712
|
+
requiredValidation: "Compile to an isolated deterministic Scenario and replay against the real shared backend before approval or commit.",
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return proposals;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function observedScreenNode(map, screen) {
|
|
719
|
+
const key = semanticUiKey(screen);
|
|
720
|
+
return (map?.nodes || []).find((node) => node.status === "observed" && (node.semanticKey === key || semanticUiKey(node.name) === key));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function durableCheckoutOutput(task) {
|
|
724
|
+
if (!/^(?:completeCheckout|checkout|placeOrder|purchase|submitOrder)$/i.test(String(task.name || ""))) return null;
|
|
725
|
+
for (const [output, definition] of Object.entries(task.outputs || {})) {
|
|
726
|
+
const input = definition?.fromInput;
|
|
727
|
+
const inputDefinition = task.inputs?.[input];
|
|
728
|
+
if (!input || !inputDefinition || inputDefinition.secret === true || !Object.hasOwn(inputDefinition, "default")) continue;
|
|
729
|
+
const value = inputDefinition.default;
|
|
730
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
731
|
+
const visible = (task.postconditions || []).some((condition) => condition?.exists === `{{${input}}}`);
|
|
732
|
+
if (visible) return { input, output, value };
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function durableBusinessProposals({ model, map, tasks, contracts, projectConfiguration }) {
|
|
738
|
+
if (!model.application.platforms.includes("web") || projectConfiguration?.errors?.length) return [];
|
|
739
|
+
const lifecycle = projectConfiguration?.config?.lifecycle || {};
|
|
740
|
+
if (!(lifecycle.setup?.length && lifecycle.teardown?.length)) return [];
|
|
741
|
+
const actor = (model.actors || []).find((candidate) => candidate.configured && candidate.session === "default") || model.actors?.[0];
|
|
742
|
+
if (!actor) return [];
|
|
743
|
+
const contractedTasks = new Set(contracts.flatMap(taskNamesFromContract));
|
|
744
|
+
const artifacts = new Map(model.artifacts.tasks.map((task) => [`${task.scope}|${task.name}`, task]));
|
|
745
|
+
const proposals = [];
|
|
746
|
+
for (const producer of tasks) {
|
|
747
|
+
const output = durableCheckoutOutput(producer);
|
|
748
|
+
if (!output || contractedTasks.has(producer.name) || !taskPlatforms(producer, model.application.platforms).includes("web")) continue;
|
|
749
|
+
const entryScreen = taskScreenCondition(producer, "preconditions");
|
|
750
|
+
const confirmationScreen = taskScreenCondition(producer, "postconditions");
|
|
751
|
+
const verifier = tasks.find((candidate) => {
|
|
752
|
+
if (candidate.__scope !== producer.__scope || contractedTasks.has(candidate.name)) return false;
|
|
753
|
+
if (!/^(?:open|view)(?:OrderHistory|Orders|Purchases)$/i.test(candidate.name)) return false;
|
|
754
|
+
if (!taskPlatforms(candidate, model.application.platforms).includes("web")) return false;
|
|
755
|
+
return Object.values(safeTaskInputs(candidate)).every((definition) => definition.required === false || Object.hasOwn(definition, "default"));
|
|
756
|
+
});
|
|
757
|
+
if (!entryScreen || !confirmationScreen || !verifier) continue;
|
|
758
|
+
const historyScreen = taskScreenCondition(verifier, "postconditions");
|
|
759
|
+
const nodes = [observedScreenNode(map, entryScreen), observedScreenNode(map, confirmationScreen), observedScreenNode(map, historyScreen)];
|
|
760
|
+
if (!historyScreen || nodes.some((node) => !node)) continue;
|
|
761
|
+
const producerArtifact = artifacts.get(`${producer.__scope}|${producer.name}`);
|
|
762
|
+
const verifierArtifact = artifacts.get(`${verifier.__scope}|${verifier.name}`);
|
|
763
|
+
if (!producerArtifact || !verifierArtifact) continue;
|
|
764
|
+
const sharedVariable = "ORDERED_ITEM";
|
|
765
|
+
proposals.push({
|
|
766
|
+
id: stableId("proposal", `durable-checkout|${producer.__scope}|${producer.name}|${verifier.name}|${actor.name}`),
|
|
767
|
+
kind: "release-contract",
|
|
768
|
+
name: "checkoutCreatesDurableOrder",
|
|
769
|
+
title: "Checkout creates an order that remains in order history",
|
|
770
|
+
scope: producer.__scope,
|
|
771
|
+
origin: "deterministic-business-effect-proposal",
|
|
772
|
+
decision: "pending",
|
|
773
|
+
criticality: "critical",
|
|
774
|
+
businessValue: "Protect the revenue path and prove its resulting order survives navigation into customer order history.",
|
|
775
|
+
actors: [actor.name],
|
|
776
|
+
tasks: [producer.name, verifier.name],
|
|
777
|
+
platforms: ["web"],
|
|
778
|
+
policy: { always: true, prRelevant: true, nightly: true, tags: ["revenue", "checkout", "order", "persistence"] },
|
|
779
|
+
taskInputs: { [producer.name]: safeTaskInputs(producer), [verifier.name]: safeTaskInputs(verifier) },
|
|
780
|
+
constraints: { inputs: { [producer.name]: { [output.input]: output.value } } },
|
|
781
|
+
lifecycleSource: "project-config",
|
|
782
|
+
journeySteps: [
|
|
783
|
+
{ actor: actor.name, task: producer.name, with: { [output.input]: output.value }, save: { [output.output]: sharedVariable }, reason: "Complete the reviewed representative revenue path from controlled state." },
|
|
784
|
+
{ actor: actor.name, task: verifier.name, reason: "Navigate away from confirmation and reopen the durable system record." },
|
|
785
|
+
{ actor: actor.name, expect: { exists: `$${sharedVariable}`, eventually: { timeoutMs: 10000, pollMs: 250 } }, reason: "The purchased item must remain observable in order history within a bounded interval." },
|
|
786
|
+
],
|
|
787
|
+
risk: "A confirmation screen can pass even when checkout fails to create a durable order record.",
|
|
788
|
+
groundedBy: [
|
|
789
|
+
{ type: "lifecycle", path: projectConfiguration.relativePath, setupSteps: lifecycle.setup.length, teardownSteps: lifecycle.teardown.length },
|
|
790
|
+
{ type: "task-output", path: producerArtifact.path, task: producer.name, input: output.input, output: output.output, postcondition: `exists {{${output.input}}}` },
|
|
791
|
+
{ type: "task", path: verifierArtifact.path, task: verifier.name, postcondition: `screen ${historyScreen}` },
|
|
792
|
+
...nodes.map((node) => ({ type: "ui-map-node", id: node.id, observationCount: node.observation?.count || 0 })),
|
|
793
|
+
{ type: "capability", id: "capability_checkout", name: "checkout" },
|
|
794
|
+
{ type: "capability", id: "capability_order-creation", name: "order creation" },
|
|
795
|
+
{ type: "capability", id: "capability_order-persistence", name: "order persistence" },
|
|
796
|
+
],
|
|
797
|
+
requiredValidation: "Replay from deterministic reset through order history on the real backend before approval or commit.",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return proposals;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function isBusinessUiMapNode(node) {
|
|
804
|
+
const key = semanticUiKey(`${node.name} ${(node.roles || []).join(" ")}`);
|
|
805
|
+
if (/error|blank|loading|not-found|debug|changelog|feature|system-status/.test(key)) return false;
|
|
806
|
+
if (/account|admin|cart|checkout|dashboard|feed|home|inbox|login|message|onboard|order|payment|plan|price|pricing|product|profile|register|search|settings|sign-in|subscribe/.test(key)) return true;
|
|
807
|
+
const controls = node.controls || [];
|
|
808
|
+
const fields = controls.filter((control) => /field|input|select|checkbox|radio/i.test(control.kind)).length;
|
|
809
|
+
const actions = controls.filter((control) => /button/i.test(control.kind)).length;
|
|
810
|
+
return fields > 0 && actions > 0;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function proposeReleasePlan({ model, map, maps = [], tasks, contracts, projectConfiguration, maxContracts = 15 } = {}) {
|
|
814
|
+
const defaultActor = model.actors?.find((actor) => actor.session === "default")?.name || model.actors?.[0]?.name || "customer";
|
|
815
|
+
const items = contracts.map((contract) => {
|
|
816
|
+
const artifact = model.artifacts.contracts.find((item) => item.name === contract.name && item.scope === contract.__scope);
|
|
817
|
+
return {
|
|
818
|
+
id: artifact?.id || stableId("contract", `${contract.__scope}|${contract.name}`), kind: "release-contract", name: contract.name, title: contract.title, scope: contract.__scope,
|
|
819
|
+
origin: "committed", decision: "accepted", criticality: contract.criticality, businessValue: contract.businessValue,
|
|
820
|
+
actors: Object.keys(contract.actors), tasks: taskNamesFromContract(contract), platforms: contract.platforms,
|
|
821
|
+
risk: "Business guarantee regresses or becomes inconclusive.", groundedBy: [{ type: "contract", path: artifact?.path }],
|
|
822
|
+
requiredValidation: "Replay on the current target revision; committed status alone is not execution proof.",
|
|
823
|
+
};
|
|
824
|
+
});
|
|
825
|
+
for (const proposal of durableBusinessProposals({ model, map, tasks, contracts, projectConfiguration })) {
|
|
826
|
+
if (items.length >= maxContracts) break;
|
|
827
|
+
items.push(proposal);
|
|
828
|
+
}
|
|
829
|
+
for (const proposal of crossActorProposals({ model, tasks, contracts, projectConfiguration })) {
|
|
830
|
+
if (items.length >= maxContracts) break;
|
|
831
|
+
items.push(proposal);
|
|
832
|
+
}
|
|
833
|
+
const contractedTasks = new Set(items.flatMap((item) => item.tasks.map((task) => `${item.scope}|${task}`)));
|
|
834
|
+
const capabilityByTask = new Map(model.capabilities.flatMap((capability) => capability.tasks.map((task) => [task, capability])));
|
|
835
|
+
for (const task of model.artifacts.tasks) {
|
|
836
|
+
if (items.length >= maxContracts || contractedTasks.has(`${task.scope}|${task.name}`)) continue;
|
|
837
|
+
items.push(candidateFromTask(task, capabilityByTask.get(task.name), model.application.platforms, defaultActor));
|
|
838
|
+
}
|
|
839
|
+
const scopedMaps = maps.some((entry) => entry.map)
|
|
840
|
+
? maps.filter((entry) => entry.map).map((entry) => ({ ...entry, target: model.targets.find((target) => target.id === entry.targetId) }))
|
|
841
|
+
: map ? [{ map, path: model.uiMap.path, target: null }] : [];
|
|
842
|
+
for (const entry of scopedMaps) for (const node of entry.map.nodes || []) {
|
|
843
|
+
if (items.length >= maxContracts) break;
|
|
844
|
+
if ((node.coveredBy?.tasks || []).length || (node.coveredBy?.contracts || []).length) continue;
|
|
845
|
+
if (!isBusinessUiMapNode(node)) continue;
|
|
846
|
+
if (items.some((item) => item.groundedBy.some((ground) => ground.type === "ui-map-node" && ground.id === node.id && (!entry.targetId || ground.targetId === entry.targetId)))) continue;
|
|
847
|
+
const scope = entry.target ? targetArtifactScope(entry.target) : ".";
|
|
848
|
+
items.push(releasePlanCandidateFromUiMapNode(node, entry.target ? [entry.target.platform] : model.application.platforms, defaultActor, { scope, targetId: model.targets.length > 1 ? entry.targetId || "" : "", mapPath: entry.path || model.uiMap.path }));
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
schemaVersion: 1, kind: "tapp-release-plan", application: model.application,
|
|
852
|
+
status: items.some((item) => item.decision === "pending") ? "awaiting-review" : "reviewed",
|
|
853
|
+
policy: { targetCount: "approximately 5–15 when grounded evidence supports it", maximum: maxContracts, compactOverExhaustive: true, deterministicReplayRequired: true, aiAssertionsDefault: false },
|
|
854
|
+
items,
|
|
855
|
+
coverageGaps: { unknownRequirements: model.requirements.filter((item) => ["missing", "unknown", "needs-confirmation"].includes(item.status)).map((item) => item.id), uncoveredUiMapNodes: model.uiMap.uncoveredNodeIds, uncoveredUiMapEdges: model.uiMap.uncoveredEdgeIds },
|
|
856
|
+
review: { instructions: "Approve, reject, defer, reprioritize, or constrain pending items before generation. Existing committed contracts remain accepted but still require current replay evidence.", reviewedAt: null },
|
|
857
|
+
provenance: { generatedBy: "tapp-init", generatedAt: new Date().toISOString(), remoteAiUsed: false, groundedOnly: true },
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function invalidatedGeneration(generation) {
|
|
862
|
+
if (!generation || generation.status === "blocked" || !generation.path) return generation;
|
|
863
|
+
return {
|
|
864
|
+
...generation,
|
|
865
|
+
status: "requires-revalidation",
|
|
866
|
+
trusted: false,
|
|
867
|
+
replayRequired: true,
|
|
868
|
+
validationStale: true,
|
|
869
|
+
...(generation.realValidation ? { previousValidation: generation.realValidation, realValidation: {} } : {}),
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function mergePlanDecisions(next, prior, { invalidateValidation = false } = {}) {
|
|
874
|
+
if (!prior || prior.schemaVersion !== 1 || prior.kind !== "tapp-release-plan") return next;
|
|
875
|
+
const priorItems = new Map((prior.items || []).map((item) => [item.id, item]));
|
|
876
|
+
const priorByNameScope = new Map();
|
|
877
|
+
for (const item of prior.items || []) {
|
|
878
|
+
const key = `${item.scope || "."}|${item.name}`;
|
|
879
|
+
const list = priorByNameScope.get(key) || [];
|
|
880
|
+
list.push(item);
|
|
881
|
+
priorByNameScope.set(key, list);
|
|
882
|
+
}
|
|
883
|
+
const consumedPriorIds = new Set();
|
|
884
|
+
const carried = next.items.map((item) => {
|
|
885
|
+
const exact = priorItems.get(item.id);
|
|
886
|
+
const lineage = item.origin === "committed"
|
|
887
|
+
? (priorByNameScope.get(`${item.scope || "."}|${item.name}`) || []).find((candidate) => candidate.origin === "promoted-validated" || candidate.generation?.status === "promoted")
|
|
888
|
+
: null;
|
|
889
|
+
const previous = exact || lineage;
|
|
890
|
+
if (!previous) return item;
|
|
891
|
+
consumedPriorIds.add(previous.id);
|
|
892
|
+
if (exact && lineage && exact.id !== lineage.id) consumedPriorIds.add(lineage.id);
|
|
893
|
+
const human = {};
|
|
894
|
+
for (const key of ["decision", "criticality", "reviewNotes", "constraints", "reviewedBy", "reviewedAt"]) if (previous[key] !== undefined) human[key] = previous[key];
|
|
895
|
+
const generationSource = exact?.generation || lineage?.generation || previous.generation;
|
|
896
|
+
const taskSource = exact?.generation?.tasks ? exact : lineage?.generation?.tasks ? lineage : previous;
|
|
897
|
+
if (generationSource !== undefined) human.generation = invalidateValidation ? invalidatedGeneration(generationSource) : generationSource;
|
|
898
|
+
if (taskSource.generation?.tasks && taskSource.tasks !== undefined) human.tasks = taskSource.tasks;
|
|
899
|
+
if (taskSource.generation?.tasks && taskSource.taskInputs !== undefined) human.taskInputs = taskSource.taskInputs;
|
|
900
|
+
// A reviewed proposal becomes a committed contract without becoming a different
|
|
901
|
+
// customer decision. Preserve its original plan identity so browser links, CLI
|
|
902
|
+
// item selectors, and review history remain stable across promotion refreshes.
|
|
903
|
+
return { ...item, id: previous.id, ...human };
|
|
904
|
+
});
|
|
905
|
+
const currentIds = new Set(carried.map((item) => item.id));
|
|
906
|
+
for (const previous of prior.items || []) if (!currentIds.has(previous.id) && !consumedPriorIds.has(previous.id) && previous.decision && previous.decision !== "pending") carried.push({ ...previous, stale: true, status: "not-derived-on-refresh" });
|
|
907
|
+
let generation = prior.generation;
|
|
908
|
+
if (invalidateValidation && generation) generation = {
|
|
909
|
+
...generation,
|
|
910
|
+
generatedTasks: (generation.generatedTasks || []).map(invalidatedGeneration),
|
|
911
|
+
generated: (generation.generated || []).map(invalidatedGeneration),
|
|
912
|
+
invalidatedAt: new Date().toISOString(),
|
|
913
|
+
invariant: "A new runtime exploration invalidated prior draft trust; preserved evidence is historical and every affected platform must replay.",
|
|
914
|
+
};
|
|
915
|
+
return {
|
|
916
|
+
...next,
|
|
917
|
+
...(generation ? { generation } : {}),
|
|
918
|
+
items: carried,
|
|
919
|
+
status: carried.some((item) => item.decision === "pending") ? "awaiting-review" : "reviewed",
|
|
920
|
+
review: { ...next.review, ...(prior.review || {}) },
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function invalidateGeneratedTaskFiles(root, plan) {
|
|
925
|
+
for (const record of plan.generation?.generatedTasks || []) {
|
|
926
|
+
if (!record.path) continue;
|
|
927
|
+
const absolute = path.resolve(root, record.path);
|
|
928
|
+
const proposalRoot = path.join(root, ".autotap", "proposals", "tasks");
|
|
929
|
+
if (!isInsideRoot(proposalRoot, absolute) || !fs.existsSync(absolute)) continue;
|
|
930
|
+
const task = readJson(absolute);
|
|
931
|
+
if (!task || task.generation?.origin !== "deterministic-ui-map") continue;
|
|
932
|
+
task.generation = invalidatedGeneration({ ...task.generation, path: record.path });
|
|
933
|
+
delete task.generation.path;
|
|
934
|
+
const temporary = `${absolute}.tmp-${process.pid}-${Date.now()}`;
|
|
935
|
+
fs.writeFileSync(temporary, JSON.stringify(task, null, 2) + "\n");
|
|
936
|
+
fs.renameSync(temporary, absolute);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export function writeInitArtifacts({ root, model, plan, outDir = ".autotap", refresh = false, invalidateValidation = false } = {}) {
|
|
941
|
+
const directory = path.resolve(root, outDir);
|
|
942
|
+
const modelPath = path.join(directory, "application-model.json");
|
|
943
|
+
const planPath = path.join(directory, "release-plan.json");
|
|
944
|
+
if (!refresh && (fs.existsSync(modelPath) || fs.existsSync(planPath))) throw new Error(`Init artifacts already exist under ${relative(root, directory)}; inspect them or rerun with --refresh to preserve reviewed decisions while updating evidence`);
|
|
945
|
+
const priorPlan = refresh && fs.existsSync(planPath) ? readJson(planPath) : null;
|
|
946
|
+
const mergedPlan = mergePlanDecisions(plan, priorPlan, { invalidateValidation });
|
|
947
|
+
if (invalidateValidation) invalidateGeneratedTaskFiles(root, mergedPlan);
|
|
948
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
949
|
+
fs.writeFileSync(modelPath, JSON.stringify(model, null, 2) + "\n");
|
|
950
|
+
fs.writeFileSync(planPath, JSON.stringify(mergedPlan, null, 2) + "\n");
|
|
951
|
+
return { modelPath, planPath, plan: mergedPlan };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export function reviewReleasePlan(plan, { approve = [], reject = [], defer = [] } = {}) {
|
|
955
|
+
const choices = new Map();
|
|
956
|
+
for (const [decision, values] of Object.entries({ approved: approve, rejected: reject, deferred: defer })) {
|
|
957
|
+
for (const value of values || []) {
|
|
958
|
+
if (choices.has(value)) throw new Error(`Plan item '${value}' received more than one decision`);
|
|
959
|
+
choices.set(value, decision);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const matched = new Set();
|
|
963
|
+
const items = (plan.items || []).map((item) => {
|
|
964
|
+
const decision = choices.get(item.id) || choices.get(item.name);
|
|
965
|
+
if (!decision) return item;
|
|
966
|
+
matched.add(choices.has(item.id) ? item.id : item.name);
|
|
967
|
+
return { ...item, decision, reviewedAt: new Date().toISOString() };
|
|
968
|
+
});
|
|
969
|
+
const missing = [...choices.keys()].filter((key) => !matched.has(key));
|
|
970
|
+
if (missing.length) throw new Error(`Unknown plan item(s): ${missing.join(", ")}`);
|
|
971
|
+
return { ...plan, items, status: items.some((item) => item.decision === "pending") ? "awaiting-review" : "reviewed", review: { ...(plan.review || {}), reviewedAt: new Date().toISOString() } };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function kebab(value) {
|
|
975
|
+
return String(value || "contract").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function pascalSemantic(value) {
|
|
979
|
+
return semanticUiKey(value).split("-").filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join("") || "Screen";
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function edgeSupportsPlatform(edge, nodes, platform) {
|
|
983
|
+
if (Array.isArray(edge.platforms) && edge.platforms.length) return edge.platforms.includes(platform);
|
|
984
|
+
const from = nodes.get(edge.from);
|
|
985
|
+
const to = nodes.get(edge.to);
|
|
986
|
+
return (!from?.platforms?.length || from.platforms.includes(platform)) && (!to?.platforms?.length || to.platforms.includes(platform));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function shortestObservedPath(map, platform, targetId, { startNodeId } = {}) {
|
|
990
|
+
const nodes = new Map((map.nodes || []).map((node) => [node.id, node]));
|
|
991
|
+
const entryId = startNodeId || map.app?.navigationRoots?.[platform] || map.app?.entryNodes?.[platform];
|
|
992
|
+
if (!entryId || !nodes.has(entryId)) throw new Error(`UI Map has no observed ${platform} navigation root; rerun tapp init --refresh --explore before generating Tasks`);
|
|
993
|
+
if (!nodes.has(targetId)) throw new Error(`UI Map proposal target '${targetId}' is no longer present`);
|
|
994
|
+
if (entryId === targetId) return [];
|
|
995
|
+
const outgoing = new Map();
|
|
996
|
+
for (const edge of (map.edges || []).filter((item) => item.status === "observed" && edgeSupportsPlatform(item, nodes, platform))) {
|
|
997
|
+
const list = outgoing.get(edge.from) || [];
|
|
998
|
+
list.push(edge);
|
|
999
|
+
outgoing.set(edge.from, list);
|
|
1000
|
+
}
|
|
1001
|
+
for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id));
|
|
1002
|
+
const queue = [{ nodeId: entryId, path: [] }];
|
|
1003
|
+
const seen = new Set([entryId]);
|
|
1004
|
+
while (queue.length) {
|
|
1005
|
+
const current = queue.shift();
|
|
1006
|
+
for (const edge of outgoing.get(current.nodeId) || []) {
|
|
1007
|
+
const nextPath = [...current.path, edge];
|
|
1008
|
+
if (edge.to === targetId) return nextPath;
|
|
1009
|
+
if (!seen.has(edge.to)) {
|
|
1010
|
+
seen.add(edge.to);
|
|
1011
|
+
queue.push({ nodeId: edge.to, path: nextPath });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
throw new Error(`No observed ${platform} path connects the entry state to the approved UI Map proposal`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function reviewedEntryToNavigationRootTasks(map, platform, existingTasks) {
|
|
1019
|
+
const entryId = map.app?.entryNodes?.[platform];
|
|
1020
|
+
const rootId = map.app?.navigationRoots?.[platform] || entryId;
|
|
1021
|
+
if (!entryId || !rootId || entryId === rootId) return [];
|
|
1022
|
+
const path = shortestObservedPath(map, platform, rootId, { startNodeId: entryId });
|
|
1023
|
+
const sequence = [];
|
|
1024
|
+
for (const edge of path) {
|
|
1025
|
+
const reviewed = (edge.coveredBy?.tasks || []).find((name) => existingTasks.has(name));
|
|
1026
|
+
if (!reviewed) {
|
|
1027
|
+
throw new Error(`Observed ${platform} launch entry requires a reviewed Task before deterministic replay can reach the navigation root`);
|
|
1028
|
+
}
|
|
1029
|
+
if (sequence.at(-1) !== reviewed) sequence.push(reviewed);
|
|
1030
|
+
}
|
|
1031
|
+
return sequence;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function generatedActionSteps(edge, nodes) {
|
|
1035
|
+
const from = nodes.get(edge.from);
|
|
1036
|
+
const to = nodes.get(edge.to);
|
|
1037
|
+
const action = String(edge.action?.type || "tap").toLowerCase();
|
|
1038
|
+
const target = String(edge.action?.target || "").trim();
|
|
1039
|
+
let step;
|
|
1040
|
+
if (["tap", "open", "login"].includes(action) && target) step = { tap: target };
|
|
1041
|
+
else if (action === "back") step = { back: true };
|
|
1042
|
+
else throw new Error(`Observed edge '${edge.id}' uses unsupported generated action '${action || "unknown"}'`);
|
|
1043
|
+
const inputs = {};
|
|
1044
|
+
const preparation = [];
|
|
1045
|
+
for (const action of edge.preparation || []) {
|
|
1046
|
+
if (action.type !== "type" || !action.target) throw new Error(`Observed edge '${edge.id}' has unsupported preparation evidence`);
|
|
1047
|
+
let value = "Tapp test";
|
|
1048
|
+
if (action.valueSource === "test-email") {
|
|
1049
|
+
inputs.email = { required: true, secret: true };
|
|
1050
|
+
value = "{{email}}";
|
|
1051
|
+
} else if (action.valueSource === "test-password") {
|
|
1052
|
+
inputs.password = { required: true, secret: true };
|
|
1053
|
+
value = "{{password}}";
|
|
1054
|
+
} else if (action.valueSource !== "generated-text") throw new Error(`Observed edge '${edge.id}' has an unknown preparation value source`);
|
|
1055
|
+
preparation.push({ wait_for: action.target }, { type: { field: action.target, value } });
|
|
1056
|
+
}
|
|
1057
|
+
return { inputs, steps: [
|
|
1058
|
+
{ action: "assert_screen", target: from.name },
|
|
1059
|
+
...preparation,
|
|
1060
|
+
...(["tap", "open", "login"].includes(action) ? [{ wait_for: target }] : []),
|
|
1061
|
+
step,
|
|
1062
|
+
{ wait_for: to.name },
|
|
1063
|
+
] };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function generatedTaskName(node, entryOnly, occupied, identity) {
|
|
1067
|
+
const base = `${entryOnly ? "confirm" : "open"}${pascalSemantic(node.name)}${entryOnly ? "Available" : ""}`;
|
|
1068
|
+
if (!occupied.has(base) || occupied.get(base) === identity) return base;
|
|
1069
|
+
return `${base}${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 6)}`;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function prepareMapBackedItem(item, map, existingTasks, taskDrafts, { targetId = "", mapPath = ".autotap/ui-map.json" } = {}) {
|
|
1073
|
+
const ground = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node");
|
|
1074
|
+
if (!ground) throw new Error("Approved UI-only proposal is not grounded by a UI Map node");
|
|
1075
|
+
const nodes = new Map((map.nodes || []).map((node) => [node.id, node]));
|
|
1076
|
+
const target = nodes.get(ground.id);
|
|
1077
|
+
if (!target) throw new Error(`Approved UI Map state '${ground.id}' is no longer present`);
|
|
1078
|
+
const occupied = new Map([...existingTasks.keys()].map((name) => [name, `reviewed:${name}`]));
|
|
1079
|
+
for (const [name, draft] of taskDrafts) occupied.set(name, draft.identity);
|
|
1080
|
+
const platformSequences = new Map();
|
|
1081
|
+
const sourcePaths = [...new Set((item.groundedBy || []).filter((ground) => ground.type === "pr-exploration" && ground.provenance === "runtime-observed").flatMap((ground) => ground.changedFiles || []))].sort();
|
|
1082
|
+
|
|
1083
|
+
for (const platform of item.platforms || []) {
|
|
1084
|
+
const path = shortestObservedPath(map, platform, target.id);
|
|
1085
|
+
// Autonomous exploration normalizes to navigationRoots, but committed
|
|
1086
|
+
// contract replay starts at the real launch entry. Reuse reviewed Tasks
|
|
1087
|
+
// for that prefix so a generated draft remains valid on a fresh CI run.
|
|
1088
|
+
const sequence = reviewedEntryToNavigationRootTasks(map, platform, existingTasks);
|
|
1089
|
+
if (!path.length) {
|
|
1090
|
+
const identity = `${targetId}|entry|${target.semanticKey}`;
|
|
1091
|
+
const name = generatedTaskName(target, true, occupied, identity);
|
|
1092
|
+
let draft = taskDrafts.get(name);
|
|
1093
|
+
if (!draft) {
|
|
1094
|
+
draft = { identity, name, scope: item.scope || ".", targetId, mapPath, description: `Confirm the observed ${target.name} entry state is available.`, inputs: {}, implementations: {}, nodes: new Set(), edges: new Set(), sourcePaths: new Set(), planItemIds: new Set() };
|
|
1095
|
+
taskDrafts.set(name, draft);
|
|
1096
|
+
occupied.set(name, identity);
|
|
1097
|
+
}
|
|
1098
|
+
draft.implementations[platform] = [{ action: "assert_screen", target: target.name }];
|
|
1099
|
+
draft.nodes.add(target.id);
|
|
1100
|
+
draft.planItemIds.add(item.id);
|
|
1101
|
+
sequence.push(name);
|
|
1102
|
+
} else {
|
|
1103
|
+
for (const edge of path) {
|
|
1104
|
+
const reviewed = (edge.coveredBy?.tasks || []).find((name) => existingTasks.has(name));
|
|
1105
|
+
// One reviewed semantic Task may intentionally own several adjacent map
|
|
1106
|
+
// edges (for example Dashboard → Settings → Update Profile). Compose it
|
|
1107
|
+
// once; repeating it for every covered edge would immediately violate
|
|
1108
|
+
// its precondition after the first successful invocation.
|
|
1109
|
+
if (reviewed) {
|
|
1110
|
+
if (sequence.at(-1) !== reviewed) sequence.push(reviewed);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const destination = nodes.get(edge.to);
|
|
1114
|
+
const identity = `${targetId}|edge|${edge.from}|${edge.to}|${semanticUiKey(edge.action?.target)}`;
|
|
1115
|
+
const name = generatedTaskName(destination, false, occupied, identity);
|
|
1116
|
+
let draft = taskDrafts.get(name);
|
|
1117
|
+
if (!draft) {
|
|
1118
|
+
draft = { identity, name, scope: item.scope || ".", targetId, mapPath, description: `Navigate from ${nodes.get(edge.from).name} to the observed ${destination.name} state.`, inputs: {}, implementations: {}, nodes: new Set(), edges: new Set(), sourcePaths: new Set(), planItemIds: new Set() };
|
|
1119
|
+
taskDrafts.set(name, draft);
|
|
1120
|
+
occupied.set(name, identity);
|
|
1121
|
+
} else if (draft.identity !== identity) {
|
|
1122
|
+
throw new Error(`Generated Task name '${name}' maps to incompatible UI transitions`);
|
|
1123
|
+
}
|
|
1124
|
+
const generated = generatedActionSteps(edge, nodes);
|
|
1125
|
+
const steps = generated.steps;
|
|
1126
|
+
const prior = draft.implementations[platform];
|
|
1127
|
+
if (prior && JSON.stringify(prior) !== JSON.stringify(steps)) throw new Error(`Observed ${platform} paths require conflicting '${name}' implementations`);
|
|
1128
|
+
draft.implementations[platform] = steps;
|
|
1129
|
+
for (const [input, definition] of Object.entries(generated.inputs)) {
|
|
1130
|
+
if (draft.inputs[input] && JSON.stringify(draft.inputs[input]) !== JSON.stringify(definition)) throw new Error(`Observed paths require conflicting '${name}.${input}' input definitions`);
|
|
1131
|
+
draft.inputs[input] = definition;
|
|
1132
|
+
}
|
|
1133
|
+
draft.nodes.add(edge.from);
|
|
1134
|
+
draft.nodes.add(edge.to);
|
|
1135
|
+
draft.edges.add(edge.id);
|
|
1136
|
+
draft.planItemIds.add(item.id);
|
|
1137
|
+
sequence.push(name);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
platformSequences.set(platform, sequence);
|
|
1141
|
+
}
|
|
1142
|
+
const sequences = [...platformSequences.values()];
|
|
1143
|
+
if (!sequences.length) throw new Error("Approved proposal has no applicable platform path");
|
|
1144
|
+
if (sequences.some((sequence) => JSON.stringify(sequence) !== JSON.stringify(sequences[0]))) {
|
|
1145
|
+
throw new Error("Observed platform paths require different semantic Task composition; review platform-specific Tasks before generating this contract");
|
|
1146
|
+
}
|
|
1147
|
+
const edgeIds = [...new Set((item.platforms || []).flatMap((platform) => shortestObservedPath(map, platform, target.id).map((edge) => edge.id)))].sort();
|
|
1148
|
+
const nodeIds = [...new Set([target.id, ...edgeIds.flatMap((id) => {
|
|
1149
|
+
const edge = map.edges.find((candidate) => candidate.id === id);
|
|
1150
|
+
return edge ? [edge.from, edge.to] : [];
|
|
1151
|
+
})])].sort();
|
|
1152
|
+
for (const name of sequences.flat()) {
|
|
1153
|
+
const draft = taskDrafts.get(name);
|
|
1154
|
+
if (!draft) continue;
|
|
1155
|
+
draft.sourcePaths ||= new Set();
|
|
1156
|
+
for (const sourcePath of sourcePaths) draft.sourcePaths.add(sourcePath);
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
...item,
|
|
1160
|
+
tasks: sequences[0],
|
|
1161
|
+
taskInputs: Object.fromEntries(sequences[0].map((name) => [name, existingTasks.has(name) ? safeTaskInputs(existingTasks.get(name)) : safeTaskInputs(taskDrafts.get(name) || {})])),
|
|
1162
|
+
generatedCoverage: { nodes: nodeIds, edges: edgeIds, sourcePaths },
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function writeGeneratedTaskDrafts(root, taskDrafts) {
|
|
1167
|
+
const generated = [];
|
|
1168
|
+
for (const draft of [...taskDrafts.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1169
|
+
const scopeRoot = path.resolve(root, draft.scope === "." || !draft.scope ? "" : draft.scope);
|
|
1170
|
+
if (!isInsideRoot(root, scopeRoot)) throw new Error(`Generated Task scope escapes repository: ${draft.scope}`);
|
|
1171
|
+
const output = path.join(scopeRoot, ".autotap", "proposals", "tasks", `${kebab(draft.name)}.task.json`);
|
|
1172
|
+
const definition = {
|
|
1173
|
+
kind: "task", version: 1, name: draft.name, description: draft.description,
|
|
1174
|
+
...(Object.keys(draft.inputs || {}).length ? { inputs: draft.inputs } : {}),
|
|
1175
|
+
implementations: Object.fromEntries(Object.entries(draft.implementations).sort(([a], [b]) => a.localeCompare(b)).map(([platform, steps]) => [platform, { steps }])),
|
|
1176
|
+
coverage: { nodes: [...draft.nodes].sort(), edges: [...draft.edges].sort(), sourcePaths: [...(draft.sourcePaths || [])].sort() },
|
|
1177
|
+
generation: { status: "draft-grounded-unvalidated", trusted: false, origin: "deterministic-ui-map", planItemIds: [...draft.planItemIds].sort(), mapPath: draft.mapPath, targetId: draft.targetId },
|
|
1178
|
+
};
|
|
1179
|
+
const source = JSON.stringify(definition, null, 2) + "\n";
|
|
1180
|
+
const created = !fs.existsSync(output);
|
|
1181
|
+
if (!created) {
|
|
1182
|
+
const existing = readJson(output);
|
|
1183
|
+
const comparable = existing ? { ...existing, generation: definition.generation } : null;
|
|
1184
|
+
if (!comparable || JSON.stringify(comparable, null, 2) + "\n" !== source) throw new Error(`Draft Task already exists with different content and was not overwritten: ${relative(root, output)}`);
|
|
1185
|
+
}
|
|
1186
|
+
if (created) {
|
|
1187
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
1188
|
+
fs.writeFileSync(output, source);
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
const task = loadTaskFile(output);
|
|
1192
|
+
const mapPath = path.resolve(root, draft.mapPath || ".autotap/ui-map.json");
|
|
1193
|
+
if (!isInsideRoot(root, mapPath)) throw new Error(`Generated Task UI Map escapes repository: ${draft.mapPath}`);
|
|
1194
|
+
const map = readJson(mapPath);
|
|
1195
|
+
if (!map || map.schemaVersion !== 1) throw new Error(`Generated Task UI Map is missing or invalid: ${draft.mapPath}`);
|
|
1196
|
+
const grounding = Object.keys(draft.implementations).map((platform) => ({ platform, ...validateTaskAgainstUiMap(task, map, platform) }));
|
|
1197
|
+
const errors = grounding.flatMap((entry) => entry.errors.map((error) => `${entry.platform}: ${error}`));
|
|
1198
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
1199
|
+
generated.push({ name: draft.name, scope: draft.scope, mapPath: draft.mapPath, targetId: draft.targetId, path: relative(root, output), status: task.generation?.status || "draft-grounded-unvalidated", trusted: task.generation?.trusted === true, platforms: Object.keys(draft.implementations).sort(), grounding, ...(task.generation?.realValidation ? { realValidation: task.generation.realValidation } : {}) });
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
if (created) fs.rmSync(output, { force: true });
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return generated;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function draftContractSource(item, projectConfig = {}) {
|
|
1209
|
+
const configuredActors = projectConfig.actors || {};
|
|
1210
|
+
const actors = item.actors?.length ? item.actors : ["customer"];
|
|
1211
|
+
const actor = actors[0];
|
|
1212
|
+
const secretInputs = new Set(Object.values(item.taskInputs || {}).flatMap((inputs) => Object.entries(inputs).filter(([, definition]) => definition.secret).map(([name]) => name)));
|
|
1213
|
+
const actorObject = Object.fromEntries(actors.map((name) => {
|
|
1214
|
+
const configured = configuredActors[name] || {};
|
|
1215
|
+
const bindings = Object.fromEntries(Object.entries(configured.credentials || {}).map(([key, binding]) => [key, binding.env]));
|
|
1216
|
+
if (name === actor) {
|
|
1217
|
+
if (secretInputs.has("email") && !bindings.email) bindings.email = "TEST_EMAIL";
|
|
1218
|
+
if (secretInputs.has("password") && !bindings.password) bindings.password = "TEST_PASSWORD";
|
|
1219
|
+
}
|
|
1220
|
+
const standardCredentials = Object.fromEntries(Object.entries(bindings).filter(([key]) => ["email", "password"].includes(key)).map(([key, env]) => [key, `$${env}`]));
|
|
1221
|
+
const customVars = Object.fromEntries(Object.entries(bindings).filter(([key]) => !["email", "password"].includes(key)).map(([key, env]) => [key.replace(/[^A-Za-z0-9]/g, "_").toUpperCase(), `$${env}`]));
|
|
1222
|
+
return [name, {
|
|
1223
|
+
role: configured.role || (name === "customer" ? "customer" : name),
|
|
1224
|
+
session: configured.session || (actors.length > 1 ? "isolated" : "default"),
|
|
1225
|
+
...(Object.keys(standardCredentials).length ? { credentials: standardCredentials } : {}),
|
|
1226
|
+
...(Object.keys(customVars).length ? { vars: customVars } : {}),
|
|
1227
|
+
}];
|
|
1228
|
+
}));
|
|
1229
|
+
const defaultSteps = (item.tasks || []).map((task) => {
|
|
1230
|
+
const definitions = item.taskInputs?.[task] || {};
|
|
1231
|
+
const supplied = item.constraints?.inputs?.[task] || {};
|
|
1232
|
+
const withInputs = {};
|
|
1233
|
+
for (const [name, definition] of Object.entries(definitions)) {
|
|
1234
|
+
if (Object.hasOwn(supplied, name)) withInputs[name] = supplied[name];
|
|
1235
|
+
else if (definition.secret) withInputs[name] = `$${name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
|
|
1236
|
+
else if (Object.hasOwn(definition, "default")) withInputs[name] = definition.default;
|
|
1237
|
+
}
|
|
1238
|
+
return { actor, task, ...(Object.keys(withInputs).length ? { with: withInputs } : {}) };
|
|
1239
|
+
});
|
|
1240
|
+
const steps = item.journeySteps?.length ? item.journeySteps.map((step) => {
|
|
1241
|
+
if (!step.task) return structuredClone(step);
|
|
1242
|
+
const constrained = item.constraints?.inputs?.[step.task] || {};
|
|
1243
|
+
const definitions = item.taskInputs?.[step.task] || {};
|
|
1244
|
+
const safeConstraints = Object.fromEntries(Object.entries(constrained).filter(([name]) => definitions[name]?.secret !== true));
|
|
1245
|
+
return { ...structuredClone(step), ...(Object.keys(safeConstraints).length ? { with: { ...(step.with || {}), ...safeConstraints } } : {}) };
|
|
1246
|
+
}) : defaultSteps;
|
|
1247
|
+
const lifecycle = item.lifecycleSource === "project-config" ? projectConfig.lifecycle || {} : {};
|
|
1248
|
+
const contract = {
|
|
1249
|
+
name: item.name,
|
|
1250
|
+
title: item.title,
|
|
1251
|
+
description: `Draft generated from approved release-plan item ${item.id}.`,
|
|
1252
|
+
businessValue: item.businessValue,
|
|
1253
|
+
criticality: item.criticality,
|
|
1254
|
+
platforms: item.platforms,
|
|
1255
|
+
policy: { prRelevant: true, ...(item.policy || {}) },
|
|
1256
|
+
actors: actorObject,
|
|
1257
|
+
steps,
|
|
1258
|
+
...(lifecycle.setup?.length ? { setup: lifecycle.setup } : {}),
|
|
1259
|
+
...(lifecycle.teardown?.length ? { teardown: lifecycle.teardown } : {}),
|
|
1260
|
+
coverage: {
|
|
1261
|
+
capabilities: item.groundedBy.filter((ground) => ground.type === "capability").map((ground) => ground.name || ground.id.replace(/^capability_/, "")),
|
|
1262
|
+
nodes: item.generatedCoverage?.nodes || item.groundedBy.filter((ground) => ground.type === "ui-map-node").map((ground) => ground.id),
|
|
1263
|
+
edges: item.generatedCoverage?.edges || [],
|
|
1264
|
+
sourcePaths: item.generatedCoverage?.sourcePaths || [],
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
return `import { defineContract } from "@aarwitz/tapp/contracts";\n\nexport default defineContract(${JSON.stringify(contract, null, 2)});\n`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
export async function generateApprovedContractProposals(plan, { projectDir } = {}) {
|
|
1271
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
1272
|
+
const generated = [];
|
|
1273
|
+
const blocked = [];
|
|
1274
|
+
const generationById = new Map();
|
|
1275
|
+
const preparedById = new Map();
|
|
1276
|
+
const taskDrafts = new Map();
|
|
1277
|
+
const existingTaskResult = taskArtifacts(root, walk(root));
|
|
1278
|
+
if (existingTaskResult.errors.length) throw new Error(existingTaskResult.errors.map((item) => `${item.path}: ${item.error}`).join("; "));
|
|
1279
|
+
const projectConfiguration = readProjectConfig(root);
|
|
1280
|
+
if (projectConfiguration.errors.length) throw new Error(`Invalid ${projectConfiguration.relativePath}: ${projectConfiguration.errors.join("; ")}`);
|
|
1281
|
+
const configuredActors = projectConfiguration.config?.actors || {};
|
|
1282
|
+
const block = (item, reason) => {
|
|
1283
|
+
blocked.push({ id: item.id, name: item.name, reason });
|
|
1284
|
+
generationById.set(item.id, { status: "blocked", trusted: false, reason });
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
for (const item of plan.items || []) {
|
|
1288
|
+
if (item.decision !== "approved" || item.origin === "committed") continue;
|
|
1289
|
+
if (!Array.isArray(item.platforms) || !item.platforms.length) {
|
|
1290
|
+
block(item, "Approved proposal has no applicable detected platform.");
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const unresolvedInputs = Object.entries(item.taskInputs || {}).flatMap(([task, inputs]) => Object.entries(inputs).flatMap(([name, definition]) => {
|
|
1294
|
+
if (item.constraints?.inputs?.[task] && Object.hasOwn(item.constraints.inputs[task], name)) return [];
|
|
1295
|
+
if (Object.hasOwn(definition, "default")) return [];
|
|
1296
|
+
const actorName = item.actors?.[0] || "customer";
|
|
1297
|
+
if (definition.secret && (["email", "password"].includes(name) || configuredActors[actorName]?.credentials?.[name]?.env)) return [];
|
|
1298
|
+
return definition.required === false ? [] : [`${task}.${name}`];
|
|
1299
|
+
}));
|
|
1300
|
+
if (unresolvedInputs.length) {
|
|
1301
|
+
block(item, `Approved proposal still needs explicit safe input bindings: ${unresolvedInputs.join(", ")}`);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
let prepared = item;
|
|
1305
|
+
if (item.origin === "deterministic-ui-map-proposal") {
|
|
1306
|
+
const grounding = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node");
|
|
1307
|
+
const mapPath = grounding?.mapPath || ".autotap/ui-map.json";
|
|
1308
|
+
const absoluteMapPath = path.resolve(root, mapPath);
|
|
1309
|
+
const itemMap = isInsideRoot(root, absoluteMapPath) ? readJson(absoluteMapPath) : null;
|
|
1310
|
+
if (!itemMap || itemMap.schemaVersion !== 1) {
|
|
1311
|
+
block(item, "Approved UI-only proposal requires a valid repository UI Map before reusable Tasks can be generated.");
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
const scopedTasks = new Map(existingTaskResult.tasks.filter((task) => task.__scope === "." || task.__scope === (item.scope || ".")).map((task) => [task.name, task]));
|
|
1315
|
+
try { prepared = prepareMapBackedItem(item, itemMap, scopedTasks, taskDrafts, { targetId: grounding?.targetId || "", mapPath: relative(root, absoluteMapPath) }); }
|
|
1316
|
+
catch (error) { block(item, error.message || String(error)); continue; }
|
|
1317
|
+
} else if (!(item.tasks || []).length) {
|
|
1318
|
+
block(item, "Approved proposal has no reusable Task composition.");
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
preparedById.set(item.id, prepared);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
let generatedTasks = [];
|
|
1325
|
+
if (taskDrafts.size) generatedTasks = writeGeneratedTaskDrafts(root, taskDrafts);
|
|
1326
|
+
|
|
1327
|
+
for (const item of plan.items || []) {
|
|
1328
|
+
const prepared = preparedById.get(item.id);
|
|
1329
|
+
if (!prepared) continue;
|
|
1330
|
+
const scopeRoot = path.resolve(root, item.scope === "." || !item.scope ? "" : item.scope);
|
|
1331
|
+
if (scopeRoot !== root && !scopeRoot.startsWith(root + path.sep)) throw new Error(`Plan scope escapes repository: ${item.scope}`);
|
|
1332
|
+
const output = path.join(scopeRoot, ".autotap", "proposals", "contracts", `${kebab(item.name)}.contract.ts`);
|
|
1333
|
+
const source = draftContractSource(prepared, projectConfiguration.config || {});
|
|
1334
|
+
if (fs.existsSync(output) && fs.readFileSync(output, "utf8") !== source) throw new Error(`Draft contract already exists with different content and was not overwritten: ${relative(root, output)}`);
|
|
1335
|
+
const created = !fs.existsSync(output);
|
|
1336
|
+
if (created) {
|
|
1337
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
1338
|
+
fs.writeFileSync(output, source);
|
|
1339
|
+
}
|
|
1340
|
+
try {
|
|
1341
|
+
const contract = await loadReleaseContractFile(output);
|
|
1342
|
+
const compiled = prepared.platforms.map((platform) => {
|
|
1343
|
+
const execution = compileReleaseContract(contract, { platform, sourcePath: output });
|
|
1344
|
+
return { platform, kind: execution.kind, deterministicSteps: execution.steps.length };
|
|
1345
|
+
});
|
|
1346
|
+
const taskPaths = generatedTasks.filter((task) => task.scope === (item.scope || ".") && prepared.tasks.includes(task.name)).map((task) => task.path);
|
|
1347
|
+
const mapPath = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node")?.mapPath;
|
|
1348
|
+
const resultPath = relative(root, output);
|
|
1349
|
+
const prior = item.generation?.path === resultPath && item.generation.trusted === true ? item.generation : null;
|
|
1350
|
+
const result = {
|
|
1351
|
+
id: item.id, name: item.name, path: resultPath, tasks: prepared.tasks, taskPaths, ...(mapPath ? { mapPath } : {}),
|
|
1352
|
+
status: prior?.status || "draft-compiled-unvalidated", trusted: prior?.trusted === true, staticValidation: compiled,
|
|
1353
|
+
replayRequired: prior ? prior.replayRequired === true : true,
|
|
1354
|
+
...(prior?.realValidation ? { realValidation: prior.realValidation } : {}),
|
|
1355
|
+
...(prior?.validationStale !== undefined ? { validationStale: prior.validationStale } : {}),
|
|
1356
|
+
};
|
|
1357
|
+
generated.push(result);
|
|
1358
|
+
generationById.set(item.id, result);
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (created) fs.rmSync(output, { force: true });
|
|
1361
|
+
const reason = error.message || String(error);
|
|
1362
|
+
blocked.push({ id: item.id, name: item.name, reason });
|
|
1363
|
+
generationById.set(item.id, { status: "blocked", trusted: false, reason });
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const nextPlan = {
|
|
1367
|
+
...plan,
|
|
1368
|
+
items: (plan.items || []).map((item) => generationById.has(item.id) ? { ...item, ...(preparedById.has(item.id) ? { tasks: preparedById.get(item.id).tasks, taskInputs: preparedById.get(item.id).taskInputs || item.taskInputs } : {}), generation: generationById.get(item.id) } : item),
|
|
1369
|
+
generation: { generatedAt: new Date().toISOString(), generatedTasks, generated, blocked, invariant: "Draft Task grounding and contract compilation are not real-surface validation; generated artifacts remain untrusted until deterministic replay passes." },
|
|
1370
|
+
};
|
|
1371
|
+
return { plan: nextPlan, generatedTasks, generated, blocked };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
export function portableEvidenceReference(value) {
|
|
1375
|
+
const evidence = String(value || "").trim();
|
|
1376
|
+
if (!evidence) return null;
|
|
1377
|
+
const capture = evidence.match(/[\\/]captures[\\/]([^\\/]+)(?:[\\/].*)?$/);
|
|
1378
|
+
if (capture) return `tapp-capture:${capture[1]}`;
|
|
1379
|
+
if (path.isAbsolute(evidence)) return `local-evidence:${crypto.createHash("sha256").update(evidence).digest("hex").slice(0, 16)}`;
|
|
1380
|
+
return evidence;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
export function resolvePlanValidationFlag(key, value, { cwd = process.cwd() } = {}) {
|
|
1384
|
+
return key === "apk" ? path.resolve(cwd, String(value)) : String(value);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
export function recordContractProposalValidation(plan, { id = "", name = "", platform, passed, evidence = "", detail = "" } = {}) {
|
|
1388
|
+
if (!["ios", "android", "web"].includes(platform)) throw new Error("platform must be ios|android|web");
|
|
1389
|
+
const matches = (plan.items || []).filter((item) => (id && item.id === id) || (!id && name && item.name === name));
|
|
1390
|
+
if (matches.length !== 1) throw new Error(matches.length ? `Plan item name '${name}' is ambiguous; use its stable id` : `Generated plan item not found: ${id || name}`);
|
|
1391
|
+
const target = matches[0];
|
|
1392
|
+
if (!target.generation?.path) throw new Error(`Plan item '${target.name}' has no generated draft`);
|
|
1393
|
+
if (!(target.platforms || []).includes(platform)) throw new Error(`Plan item '${target.name}' does not apply to ${platform}`);
|
|
1394
|
+
const validations = { ...(target.generation.realValidation || {}), [platform]: { status: passed ? "passed" : "failed", passed: !!passed, evidence: portableEvidenceReference(evidence), detail: detail || "" } };
|
|
1395
|
+
const trusted = (target.platforms || []).every((candidate) => validations[candidate]?.passed === true);
|
|
1396
|
+
const generation = {
|
|
1397
|
+
...target.generation,
|
|
1398
|
+
status: trusted ? (target.generation.promotedAt ? "promoted" : "validated-draft") : passed ? "partially-validated-draft" : "replay-failed",
|
|
1399
|
+
trusted,
|
|
1400
|
+
realValidation: validations,
|
|
1401
|
+
replayRequired: !trusted,
|
|
1402
|
+
validationStale: false,
|
|
1403
|
+
};
|
|
1404
|
+
const items = plan.items.map((item) => item.id === target.id ? { ...item, generation } : item);
|
|
1405
|
+
const generated = (plan.generation?.generated || []).map((item) => {
|
|
1406
|
+
const sameGeneration = item.id === target.id || item.id === target.generation.id || (item.name === target.generation.name && item.path === target.generation.path);
|
|
1407
|
+
return sameGeneration ? { ...item, ...generation } : item;
|
|
1408
|
+
});
|
|
1409
|
+
return { ...plan, items, generation: { ...(plan.generation || {}), generated } };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
export function recordGeneratedTaskProposalValidation({ projectDir, item, platform, evidence = "", detail = "" } = {}) {
|
|
1413
|
+
if (!["ios", "android", "web"].includes(platform)) throw new Error("platform must be ios|android|web");
|
|
1414
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
1415
|
+
const proposalMarker = `${path.sep}.autotap${path.sep}proposals${path.sep}tasks${path.sep}`;
|
|
1416
|
+
const reviewedMarker = `${path.sep}.autotap${path.sep}tasks${path.sep}`;
|
|
1417
|
+
const updated = [];
|
|
1418
|
+
for (const taskPath of item?.generation?.taskPaths || []) {
|
|
1419
|
+
const absolute = path.resolve(root, taskPath);
|
|
1420
|
+
const proposed = isInsideRoot(root, absolute) && absolute.includes(proposalMarker);
|
|
1421
|
+
const reviewed = isInsideRoot(root, absolute) && absolute.includes(reviewedMarker) && !absolute.includes(proposalMarker);
|
|
1422
|
+
if ((!proposed && !reviewed) || !fs.existsSync(absolute)) throw new Error(`Generated Task is missing or outside Tapp Task directories: ${taskPath}`);
|
|
1423
|
+
const task = readJson(absolute);
|
|
1424
|
+
if (!task || task.kind !== "task" || task.generation?.origin !== "deterministic-ui-map") throw new Error(`Generated Task draft has invalid provenance: ${taskPath}`);
|
|
1425
|
+
if (!task.implementations?.[platform]) throw new Error(`Generated Task '${task.name}' has no ${platform} implementation`);
|
|
1426
|
+
if (reviewed) {
|
|
1427
|
+
if (task.generation?.trusted !== true) throw new Error(`Promoted Task '${task.name}' is not trusted`);
|
|
1428
|
+
updated.push({ name: task.name, path: relative(root, absolute), status: task.generation.status, trusted: true, realValidation: task.generation.realValidation || {}, platform });
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
const validations = {
|
|
1432
|
+
...(task.generation.realValidation || {}),
|
|
1433
|
+
[platform]: { status: "passed", passed: true, evidence: portableEvidenceReference(evidence), detail, contract: item.name, validatedAt: new Date().toISOString() },
|
|
1434
|
+
};
|
|
1435
|
+
const platforms = Object.keys(task.implementations || {}).filter((candidate) => ["ios", "android", "web"].includes(candidate));
|
|
1436
|
+
const trusted = platforms.length > 0 && platforms.every((candidate) => validations[candidate]?.passed === true);
|
|
1437
|
+
task.generation = { ...task.generation, status: trusted ? "validated-draft" : "partially-validated-draft", trusted, realValidation: validations };
|
|
1438
|
+
const temporary = `${absolute}.tmp-${process.pid}-${Date.now()}`;
|
|
1439
|
+
fs.writeFileSync(temporary, JSON.stringify(task, null, 2) + "\n");
|
|
1440
|
+
fs.renameSync(temporary, absolute);
|
|
1441
|
+
updated.push({ name: task.name, path: relative(root, absolute), status: task.generation.status, trusted, realValidation: task.generation.realValidation, platform });
|
|
1442
|
+
}
|
|
1443
|
+
return updated;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
export function mergeGeneratedTaskProposalValidation(plan, updates = []) {
|
|
1447
|
+
const byPath = new Map(updates.map((item) => [item.path, item]));
|
|
1448
|
+
return {
|
|
1449
|
+
...plan,
|
|
1450
|
+
generation: {
|
|
1451
|
+
...(plan.generation || {}),
|
|
1452
|
+
generatedTasks: (plan.generation?.generatedTasks || []).map((task) => {
|
|
1453
|
+
const update = byPath.get(task.path);
|
|
1454
|
+
return update ? { ...task, status: update.status, trusted: update.trusted, realValidation: update.realValidation } : task;
|
|
1455
|
+
}),
|
|
1456
|
+
},
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function promotedDestination(root, source, kind) {
|
|
1461
|
+
const marker = `${path.sep}.autotap${path.sep}proposals${path.sep}${kind}${path.sep}`;
|
|
1462
|
+
const index = source.indexOf(marker);
|
|
1463
|
+
if (index < 0) throw new Error(`Proposal ${kind.slice(0, -1)} is outside .autotap/proposals/${kind}: ${relative(root, source)}`);
|
|
1464
|
+
const destination = `${source.slice(0, index)}${path.sep}.autotap${path.sep}${kind}${path.sep}${source.slice(index + marker.length)}`;
|
|
1465
|
+
if (!isInsideRoot(root, destination)) throw new Error(`Promotion destination escapes repository: ${destination}`);
|
|
1466
|
+
return destination;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
export async function promoteValidatedProposals(plan, { projectDir, ids = [] } = {}) {
|
|
1470
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
1471
|
+
const requested = new Set(ids || []);
|
|
1472
|
+
const candidates = (plan.items || []).filter((item) => item.generation?.path && (requested.size ? requested.has(item.id) || requested.has(item.name) : item.generation.trusted === true));
|
|
1473
|
+
if (requested.size) {
|
|
1474
|
+
const matched = new Set(candidates.flatMap((item) => [item.id, item.name]).filter((value) => requested.has(value)));
|
|
1475
|
+
const missing = [...requested].filter((value) => !matched.has(value));
|
|
1476
|
+
if (missing.length) throw new Error(`Generated plan item(s) not found: ${missing.join(", ")}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (!candidates.length) throw new Error("No validated generated contracts are available to promote");
|
|
1479
|
+
for (const item of candidates) if (item.generation.trusted !== true || item.generation.status !== "validated-draft") throw new Error(`Plan item '${item.name}' must pass deterministic replay on every declared platform before promotion`);
|
|
1480
|
+
|
|
1481
|
+
const mapCache = new Map();
|
|
1482
|
+
const mapForItem = (item) => {
|
|
1483
|
+
const relativeMapPath = item.generation?.mapPath || (item.groundedBy || []).find((entry) => entry.type === "ui-map-node")?.mapPath || ".autotap/ui-map.json";
|
|
1484
|
+
const absolute = path.resolve(root, relativeMapPath);
|
|
1485
|
+
if (!isInsideRoot(root, absolute)) throw new Error(`UI Map for '${item.name}' escapes the repository: ${relativeMapPath}`);
|
|
1486
|
+
if (!mapCache.has(absolute)) {
|
|
1487
|
+
const map = readJson(absolute);
|
|
1488
|
+
if (!map || map.schemaVersion !== 1) throw new Error(`A valid UI Map is required for '${item.name}': ${relative(root, absolute)}`);
|
|
1489
|
+
mapCache.set(absolute, map);
|
|
1490
|
+
}
|
|
1491
|
+
return { path: absolute, map: mapCache.get(absolute) };
|
|
1492
|
+
};
|
|
1493
|
+
const moves = new Map();
|
|
1494
|
+
const taskRecords = new Map();
|
|
1495
|
+
const taskMapPaths = new Map();
|
|
1496
|
+
const contractRecords = [];
|
|
1497
|
+
for (const item of candidates) {
|
|
1498
|
+
const itemMap = mapForItem(item);
|
|
1499
|
+
for (const taskPath of item.generation.taskPaths || []) {
|
|
1500
|
+
const source = path.resolve(root, taskPath);
|
|
1501
|
+
if (!fs.existsSync(source)) throw new Error(`Generated Task is missing: ${taskPath}`);
|
|
1502
|
+
if (!String(source).includes(`${path.sep}.autotap${path.sep}proposals${path.sep}tasks${path.sep}`)) continue;
|
|
1503
|
+
const destination = promotedDestination(root, source, "tasks");
|
|
1504
|
+
moves.set(source, destination);
|
|
1505
|
+
let task = taskRecords.get(source);
|
|
1506
|
+
if (!task) {
|
|
1507
|
+
task = loadTaskFile(source);
|
|
1508
|
+
if (task.generation?.trusted !== true || task.generation?.status !== "validated-draft") throw new Error(`Generated Task '${task.name}' must be fully validated before promotion`);
|
|
1509
|
+
taskRecords.set(source, task);
|
|
1510
|
+
}
|
|
1511
|
+
for (const platform of Object.keys(task.implementations || {})) {
|
|
1512
|
+
const grounding = validateTaskAgainstUiMap(task, itemMap.map, platform);
|
|
1513
|
+
if (grounding.errors.length) throw new Error(`Generated Task '${task.name}' is no longer grounded in ${relative(root, itemMap.path)}: ${grounding.errors.join("; ")}`);
|
|
1514
|
+
}
|
|
1515
|
+
const mapPaths = taskMapPaths.get(source) || new Set();
|
|
1516
|
+
mapPaths.add(itemMap.path);
|
|
1517
|
+
taskMapPaths.set(source, mapPaths);
|
|
1518
|
+
}
|
|
1519
|
+
const source = path.resolve(root, item.generation.path);
|
|
1520
|
+
if (!fs.existsSync(source)) throw new Error(`Generated contract is missing: ${item.generation.path}`);
|
|
1521
|
+
const destination = promotedDestination(root, source, "contracts");
|
|
1522
|
+
moves.set(source, destination);
|
|
1523
|
+
const contract = await loadReleaseContractFile(source);
|
|
1524
|
+
const grounding = validateReleaseContractAgainstUiMap(contract, itemMap.map);
|
|
1525
|
+
if (grounding.errors.length) throw new Error(`Generated contract '${contract.name}' is no longer grounded: ${grounding.errors.join("; ")}`);
|
|
1526
|
+
contractRecords.push({ item, source, destination, contract, mapPath: itemMap.path });
|
|
1527
|
+
}
|
|
1528
|
+
for (const destination of moves.values()) if (fs.existsSync(destination)) throw new Error(`Promotion never overwrites an existing reviewed artifact: ${relative(root, destination)}`);
|
|
1529
|
+
|
|
1530
|
+
const completed = [];
|
|
1531
|
+
const originalTaskSources = new Map([...taskRecords.keys()].map((source) => [source, fs.readFileSync(source, "utf8")]));
|
|
1532
|
+
try {
|
|
1533
|
+
for (const [source, destination] of [...moves].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1534
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
1535
|
+
fs.renameSync(source, destination);
|
|
1536
|
+
completed.push([source, destination]);
|
|
1537
|
+
const task = taskRecords.get(source);
|
|
1538
|
+
if (task) {
|
|
1539
|
+
const promoted = { ...task, generation: { ...task.generation, status: "promoted", trusted: true, promotedAt: new Date().toISOString(), promotedFrom: relative(root, source) } };
|
|
1540
|
+
delete promoted.__path;
|
|
1541
|
+
fs.writeFileSync(destination, JSON.stringify(promoted, null, 2) + "\n");
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
for (const [source, destination] of completed.reverse()) {
|
|
1546
|
+
try {
|
|
1547
|
+
fs.renameSync(destination, source);
|
|
1548
|
+
if (originalTaskSources.has(source)) fs.writeFileSync(source, originalTaskSources.get(source));
|
|
1549
|
+
} catch { /* preserve the original error */ }
|
|
1550
|
+
}
|
|
1551
|
+
throw error;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const pathMap = new Map([...moves].map(([source, destination]) => [relative(root, source), relative(root, destination)]));
|
|
1555
|
+
const coveredMaps = new Map([...mapCache].map(([mapPath, map]) => [mapPath, map]));
|
|
1556
|
+
for (const [source, task] of taskRecords) for (const mapPath of taskMapPaths.get(source) || []) coveredMaps.set(mapPath, applyTaskCoverage(coveredMaps.get(mapPath), task));
|
|
1557
|
+
for (const record of contractRecords) coveredMaps.set(record.mapPath, applyReleaseContractCoverage(coveredMaps.get(record.mapPath), record.contract));
|
|
1558
|
+
const { writeUiMap } = await import("./ui-map.js");
|
|
1559
|
+
for (const [mapPath, coveredMap] of coveredMaps) writeUiMap(mapPath, coveredMap);
|
|
1560
|
+
const promotedIds = new Set(candidates.map((item) => item.id));
|
|
1561
|
+
const promotedAt = new Date().toISOString();
|
|
1562
|
+
const rewriteGeneration = (generation) => generation ? {
|
|
1563
|
+
...generation,
|
|
1564
|
+
...(pathMap.has(generation.path) ? { path: pathMap.get(generation.path) } : {}),
|
|
1565
|
+
...(generation.taskPaths ? { taskPaths: generation.taskPaths.map((value) => pathMap.get(value) || value) } : {}),
|
|
1566
|
+
} : generation;
|
|
1567
|
+
const items = (plan.items || []).map((item) => {
|
|
1568
|
+
const generation = rewriteGeneration(item.generation);
|
|
1569
|
+
if (!promotedIds.has(item.id)) return generation === item.generation ? item : { ...item, generation };
|
|
1570
|
+
return { ...item, origin: "promoted-validated", decision: "accepted", generation: { ...generation, status: "promoted", trusted: true, replayRequired: false, promotedAt } };
|
|
1571
|
+
});
|
|
1572
|
+
const generatedTasks = (plan.generation?.generatedTasks || []).map((task) => {
|
|
1573
|
+
const nextPath = pathMap.get(task.path);
|
|
1574
|
+
return nextPath ? { ...task, path: nextPath, status: "promoted", trusted: true, promotedAt } : task;
|
|
1575
|
+
});
|
|
1576
|
+
const generated = (plan.generation?.generated || []).map((contract) => {
|
|
1577
|
+
const nextPath = pathMap.get(contract.path);
|
|
1578
|
+
const taskPaths = (contract.taskPaths || []).map((value) => pathMap.get(value) || value);
|
|
1579
|
+
const tasksChanged = JSON.stringify(taskPaths) !== JSON.stringify(contract.taskPaths || []);
|
|
1580
|
+
return nextPath
|
|
1581
|
+
? { ...contract, path: nextPath, taskPaths, status: "promoted", trusted: true, promotedAt }
|
|
1582
|
+
: tasksChanged ? { ...contract, taskPaths } : contract;
|
|
1583
|
+
});
|
|
1584
|
+
const nextPlan = { ...plan, items, generation: { ...(plan.generation || {}), generatedTasks, generated, promotedAt } };
|
|
1585
|
+
return {
|
|
1586
|
+
plan: nextPlan,
|
|
1587
|
+
promotedTasks: [...taskRecords].map(([source, task]) => ({ name: task.name, from: relative(root, source), path: pathMap.get(relative(root, source)) })).sort((a, b) => a.name.localeCompare(b.name)),
|
|
1588
|
+
promotedContracts: contractRecords.map((record) => ({ name: record.contract.name, from: relative(root, record.source), path: relative(root, record.destination) })).sort((a, b) => a.name.localeCompare(b.name)),
|
|
1589
|
+
mapPath: [...coveredMaps.keys()][0],
|
|
1590
|
+
mapPaths: [...coveredMaps.keys()].sort(),
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
export async function buildInitArtifacts(options = {}) {
|
|
1595
|
+
const inspected = await inspectApplicationRepository(options);
|
|
1596
|
+
return { ...inspected, plan: proposeReleasePlan({ ...inspected, maxContracts: options.maxContracts }) };
|
|
1597
|
+
}
|