@aarwitz/tapp 0.15.1 → 0.16.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 +4 -4
- package/README.md +13 -10
- package/bin/tapp.js +14 -14
- package/browser/app.js +2 -2
- package/browser/index.html +1 -1
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/PRODUCT-ENGINE.md +7 -6
- package/docs/application-model.md +19 -19
- package/docs/scenarios.md +7 -7
- package/mcp-server/src/application-model.js +32 -26
- package/mcp-server/src/ci-report.js +5 -4
- package/mcp-server/src/ci-setup.js +16 -7
- package/mcp-server/src/index.js +28 -27
- package/mcp-server/src/maintenance-proposal.js +4 -4
- package/mcp-server/src/pr-selection.js +9 -8
- package/mcp-server/src/product-operations.js +19 -17
- package/mcp-server/src/project-config.js +8 -5
- package/mcp-server/src/project-paths.js +32 -0
- package/mcp-server/src/task-runtime.js +14 -10
- package/package.json +1 -1
- package/scripts/ci-gate.sh +12 -7
- package/scripts/flow_lib.py +1 -1
|
@@ -8,6 +8,7 @@ import { credentialBindingsFromValue, readProjectConfig } from "./project-config
|
|
|
8
8
|
import { applyReleaseContractCoverage, compileReleaseContract, loadReleaseContractFile, validateReleaseContractAgainstUiMap } from "./release-contract.js";
|
|
9
9
|
import { applyTaskCoverage, loadTaskFile, validateTaskAgainstUiMap } from "./task-runtime.js";
|
|
10
10
|
import { semanticUiKey } from "./ui-map.js";
|
|
11
|
+
import { isProjectArtifactDirectory, projectArtifactDirectory } from "./project-paths.js";
|
|
11
12
|
|
|
12
13
|
const SKIP = new Set([".git", ".build", ".gradle", ".next", ".swiftpm", "Pods", "Carthage", "DerivedData", "build", "dist", "node_modules", "vendor"]);
|
|
13
14
|
|
|
@@ -25,7 +26,7 @@ function walk(root, maxDepth = 4) {
|
|
|
25
26
|
let entries;
|
|
26
27
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
27
28
|
for (const entry of entries) {
|
|
28
|
-
if (SKIP.has(entry.name) || (entry.name.startsWith(".") && entry.name
|
|
29
|
+
if (SKIP.has(entry.name) || (entry.name.startsWith(".") && !isProjectArtifactDirectory(entry.name))) continue;
|
|
29
30
|
const absolute = path.join(dir, entry.name);
|
|
30
31
|
if (entry.isDirectory()) {
|
|
31
32
|
directories.push(absolute);
|
|
@@ -124,7 +125,8 @@ function applyRuntimeTargetValidation(root, targets, validation) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
function persistedTargetValidations(root, outDir) {
|
|
127
|
-
const
|
|
128
|
+
const requested = String(outDir || ".tapp");
|
|
129
|
+
const artifactDir = path.resolve(root, projectArtifactDirectory(root, requested));
|
|
128
130
|
const relativeArtifactDir = path.relative(root, artifactDir);
|
|
129
131
|
if (path.isAbsolute(relativeArtifactDir) || relativeArtifactDir === ".." || relativeArtifactDir.startsWith(`..${path.sep}`)) return [];
|
|
130
132
|
const prior = readJson(path.join(artifactDir, "application-model.json"));
|
|
@@ -299,10 +301,11 @@ function uiMapTargetsTarget(map, target, targets) {
|
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
function loadTargetUiMaps(root, targets) {
|
|
302
|
-
const rootMap = loadUiMapAt(root, path.join(
|
|
304
|
+
const rootMap = loadUiMapAt(root, path.join(projectArtifactDirectory(root), "ui-map.json"));
|
|
303
305
|
const records = targets.map((target) => {
|
|
304
306
|
const scope = targetArtifactScope(target);
|
|
305
|
-
const
|
|
307
|
+
const scopeRoot = path.join(root, scope === "." ? "" : scope);
|
|
308
|
+
const expectedPath = posix(path.join(scope === "." ? "" : scope, projectArtifactDirectory(scopeRoot), "ui-map.json"));
|
|
306
309
|
let loaded = expectedPath === rootMap.summary.path ? rootMap : loadUiMapAt(root, expectedPath);
|
|
307
310
|
if (!loaded.map && rootMap.map && (targets.length === 1 || uiMapTargetsTarget(rootMap.map, target, targets))) loaded = rootMap;
|
|
308
311
|
return {
|
|
@@ -328,7 +331,7 @@ function loadTargetUiMaps(root, targets) {
|
|
|
328
331
|
const prefixCoverage = unique.length > 1;
|
|
329
332
|
const coverageValues = (field) => unique.flatMap((record) => (record.summary[field] || []).map((id) => prefixCoverage ? `${record.summary.targetId}:${id}` : id));
|
|
330
333
|
const summary = {
|
|
331
|
-
path: unique.length === 1 ? unique[0].summary.path : ".
|
|
334
|
+
path: unique.length === 1 ? unique[0].summary.path : ".tapp/ui-map.json",
|
|
332
335
|
paths: unique.map((record) => record.summary.path).sort(),
|
|
333
336
|
status: allObserved ? "observed" : someObserved ? "partial" : someInconclusive ? "inconclusive" : "missing",
|
|
334
337
|
nodeCount: unique.reduce((total, record) => total + record.summary.nodeCount, 0),
|
|
@@ -348,14 +351,14 @@ function loadTargetUiMaps(root, targets) {
|
|
|
348
351
|
|
|
349
352
|
function artifactScope(root, file) {
|
|
350
353
|
const parts = relative(root, file).split("/");
|
|
351
|
-
const index = parts.
|
|
354
|
+
const index = parts.findIndex(isProjectArtifactDirectory);
|
|
352
355
|
return index > 0 ? parts.slice(0, index).join("/") : ".";
|
|
353
356
|
}
|
|
354
357
|
|
|
355
358
|
function artifactFiles(root, inventory, kind, pattern) {
|
|
356
359
|
return inventory.files.filter((file) => {
|
|
357
360
|
const parts = relative(root, file).split("/");
|
|
358
|
-
const index = parts.
|
|
361
|
+
const index = parts.findIndex(isProjectArtifactDirectory);
|
|
359
362
|
return index >= 0 && parts[index + 1] === kind && pattern.test(path.basename(file));
|
|
360
363
|
}).sort();
|
|
361
364
|
}
|
|
@@ -421,7 +424,7 @@ function applicationName(root, targets) {
|
|
|
421
424
|
return pkg?.name || (targets.length === 1 ? targets[0].name : path.basename(root));
|
|
422
425
|
}
|
|
423
426
|
|
|
424
|
-
export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".
|
|
427
|
+
export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".tapp" } = {}) {
|
|
425
428
|
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
426
429
|
const inventory = walk(root);
|
|
427
430
|
let targets = [
|
|
@@ -550,7 +553,7 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
|
|
|
550
553
|
requirements.push({
|
|
551
554
|
id: targets.length <= 1 ? "ui-map" : `${target.id}:ui-map`, severity: "blocking", status: summary.status === "inconclusive" ? "inconclusive" : "missing",
|
|
552
555
|
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 .
|
|
556
|
+
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 .tapp/ui-map.json.",
|
|
554
557
|
});
|
|
555
558
|
}
|
|
556
559
|
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." });
|
|
@@ -925,7 +928,7 @@ function invalidateGeneratedTaskFiles(root, plan) {
|
|
|
925
928
|
for (const record of plan.generation?.generatedTasks || []) {
|
|
926
929
|
if (!record.path) continue;
|
|
927
930
|
const absolute = path.resolve(root, record.path);
|
|
928
|
-
const proposalRoot = path.join(root,
|
|
931
|
+
const proposalRoot = path.join(root, projectArtifactDirectory(root), "proposals", "tasks");
|
|
929
932
|
if (!isInsideRoot(proposalRoot, absolute) || !fs.existsSync(absolute)) continue;
|
|
930
933
|
const task = readJson(absolute);
|
|
931
934
|
if (!task || task.generation?.origin !== "deterministic-ui-map") continue;
|
|
@@ -937,8 +940,8 @@ function invalidateGeneratedTaskFiles(root, plan) {
|
|
|
937
940
|
}
|
|
938
941
|
}
|
|
939
942
|
|
|
940
|
-
export function writeInitArtifacts({ root, model, plan, outDir = ".
|
|
941
|
-
const directory = path.resolve(root, outDir);
|
|
943
|
+
export function writeInitArtifacts({ root, model, plan, outDir = ".tapp", refresh = false, invalidateValidation = false } = {}) {
|
|
944
|
+
const directory = path.resolve(root, projectArtifactDirectory(root, outDir));
|
|
942
945
|
const modelPath = path.join(directory, "application-model.json");
|
|
943
946
|
const planPath = path.join(directory, "release-plan.json");
|
|
944
947
|
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`);
|
|
@@ -1069,7 +1072,7 @@ function generatedTaskName(node, entryOnly, occupied, identity) {
|
|
|
1069
1072
|
return `${base}${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 6)}`;
|
|
1070
1073
|
}
|
|
1071
1074
|
|
|
1072
|
-
function prepareMapBackedItem(item, map, existingTasks, taskDrafts, { targetId = "", mapPath = ".
|
|
1075
|
+
function prepareMapBackedItem(item, map, existingTasks, taskDrafts, { targetId = "", mapPath = ".tapp/ui-map.json" } = {}) {
|
|
1073
1076
|
const ground = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node");
|
|
1074
1077
|
if (!ground) throw new Error("Approved UI-only proposal is not grounded by a UI Map node");
|
|
1075
1078
|
const nodes = new Map((map.nodes || []).map((node) => [node.id, node]));
|
|
@@ -1168,7 +1171,7 @@ function writeGeneratedTaskDrafts(root, taskDrafts) {
|
|
|
1168
1171
|
for (const draft of [...taskDrafts.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1169
1172
|
const scopeRoot = path.resolve(root, draft.scope === "." || !draft.scope ? "" : draft.scope);
|
|
1170
1173
|
if (!isInsideRoot(root, scopeRoot)) throw new Error(`Generated Task scope escapes repository: ${draft.scope}`);
|
|
1171
|
-
const output = path.join(scopeRoot,
|
|
1174
|
+
const output = path.join(scopeRoot, projectArtifactDirectory(scopeRoot), "proposals", "tasks", `${kebab(draft.name)}.task.json`);
|
|
1172
1175
|
const definition = {
|
|
1173
1176
|
kind: "task", version: 1, name: draft.name, description: draft.description,
|
|
1174
1177
|
...(Object.keys(draft.inputs || {}).length ? { inputs: draft.inputs } : {}),
|
|
@@ -1189,7 +1192,7 @@ function writeGeneratedTaskDrafts(root, taskDrafts) {
|
|
|
1189
1192
|
}
|
|
1190
1193
|
try {
|
|
1191
1194
|
const task = loadTaskFile(output);
|
|
1192
|
-
const mapPath = path.resolve(root, draft.mapPath || ".
|
|
1195
|
+
const mapPath = path.resolve(root, draft.mapPath || ".tapp/ui-map.json");
|
|
1193
1196
|
if (!isInsideRoot(root, mapPath)) throw new Error(`Generated Task UI Map escapes repository: ${draft.mapPath}`);
|
|
1194
1197
|
const map = readJson(mapPath);
|
|
1195
1198
|
if (!map || map.schemaVersion !== 1) throw new Error(`Generated Task UI Map is missing or invalid: ${draft.mapPath}`);
|
|
@@ -1304,7 +1307,7 @@ export async function generateApprovedContractProposals(plan, { projectDir } = {
|
|
|
1304
1307
|
let prepared = item;
|
|
1305
1308
|
if (item.origin === "deterministic-ui-map-proposal") {
|
|
1306
1309
|
const grounding = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node");
|
|
1307
|
-
const mapPath = grounding?.mapPath || ".
|
|
1310
|
+
const mapPath = grounding?.mapPath || ".tapp/ui-map.json";
|
|
1308
1311
|
const absoluteMapPath = path.resolve(root, mapPath);
|
|
1309
1312
|
const itemMap = isInsideRoot(root, absoluteMapPath) ? readJson(absoluteMapPath) : null;
|
|
1310
1313
|
if (!itemMap || itemMap.schemaVersion !== 1) {
|
|
@@ -1329,7 +1332,7 @@ export async function generateApprovedContractProposals(plan, { projectDir } = {
|
|
|
1329
1332
|
if (!prepared) continue;
|
|
1330
1333
|
const scopeRoot = path.resolve(root, item.scope === "." || !item.scope ? "" : item.scope);
|
|
1331
1334
|
if (scopeRoot !== root && !scopeRoot.startsWith(root + path.sep)) throw new Error(`Plan scope escapes repository: ${item.scope}`);
|
|
1332
|
-
const output = path.join(scopeRoot,
|
|
1335
|
+
const output = path.join(scopeRoot, projectArtifactDirectory(scopeRoot), "proposals", "contracts", `${kebab(item.name)}.contract.ts`);
|
|
1333
1336
|
const source = draftContractSource(prepared, projectConfiguration.config || {});
|
|
1334
1337
|
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
1338
|
const created = !fs.existsSync(output);
|
|
@@ -1412,13 +1415,13 @@ export function recordContractProposalValidation(plan, { id = "", name = "", pla
|
|
|
1412
1415
|
export function recordGeneratedTaskProposalValidation({ projectDir, item, platform, evidence = "", detail = "" } = {}) {
|
|
1413
1416
|
if (!["ios", "android", "web"].includes(platform)) throw new Error("platform must be ios|android|web");
|
|
1414
1417
|
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
1415
|
-
const
|
|
1416
|
-
const
|
|
1418
|
+
const proposalMarkers = [".tapp", ".autotap"].map((directory) => `${path.sep}${directory}${path.sep}proposals${path.sep}tasks${path.sep}`);
|
|
1419
|
+
const reviewedMarkers = [".tapp", ".autotap"].map((directory) => `${path.sep}${directory}${path.sep}tasks${path.sep}`);
|
|
1417
1420
|
const updated = [];
|
|
1418
1421
|
for (const taskPath of item?.generation?.taskPaths || []) {
|
|
1419
1422
|
const absolute = path.resolve(root, taskPath);
|
|
1420
|
-
const proposed = isInsideRoot(root, absolute) && absolute.includes(
|
|
1421
|
-
const reviewed = isInsideRoot(root, absolute) &&
|
|
1423
|
+
const proposed = isInsideRoot(root, absolute) && proposalMarkers.some((marker) => absolute.includes(marker));
|
|
1424
|
+
const reviewed = isInsideRoot(root, absolute) && reviewedMarkers.some((marker) => absolute.includes(marker)) && !proposed;
|
|
1422
1425
|
if ((!proposed && !reviewed) || !fs.existsSync(absolute)) throw new Error(`Generated Task is missing or outside Tapp Task directories: ${taskPath}`);
|
|
1423
1426
|
const task = readJson(absolute);
|
|
1424
1427
|
if (!task || task.kind !== "task" || task.generation?.origin !== "deterministic-ui-map") throw new Error(`Generated Task draft has invalid provenance: ${taskPath}`);
|
|
@@ -1458,10 +1461,13 @@ export function mergeGeneratedTaskProposalValidation(plan, updates = []) {
|
|
|
1458
1461
|
}
|
|
1459
1462
|
|
|
1460
1463
|
function promotedDestination(root, source, kind) {
|
|
1461
|
-
const marker =
|
|
1464
|
+
const marker = [".tapp", ".autotap"]
|
|
1465
|
+
.map((directory) => `${path.sep}${directory}${path.sep}proposals${path.sep}${kind}${path.sep}`)
|
|
1466
|
+
.find((candidate) => source.includes(candidate));
|
|
1467
|
+
if (!marker) throw new Error(`Proposal ${kind.slice(0, -1)} is outside .tapp/proposals/${kind}: ${relative(root, source)}`);
|
|
1462
1468
|
const index = source.indexOf(marker);
|
|
1463
|
-
|
|
1464
|
-
const destination = `${source.slice(0, index)}${path.sep}
|
|
1469
|
+
const directory = marker.split(path.sep).filter(Boolean)[0];
|
|
1470
|
+
const destination = `${source.slice(0, index)}${path.sep}${directory}${path.sep}${kind}${path.sep}${source.slice(index + marker.length)}`;
|
|
1465
1471
|
if (!isInsideRoot(root, destination)) throw new Error(`Promotion destination escapes repository: ${destination}`);
|
|
1466
1472
|
return destination;
|
|
1467
1473
|
}
|
|
@@ -1480,7 +1486,7 @@ export async function promoteValidatedProposals(plan, { projectDir, ids = [] } =
|
|
|
1480
1486
|
|
|
1481
1487
|
const mapCache = new Map();
|
|
1482
1488
|
const mapForItem = (item) => {
|
|
1483
|
-
const relativeMapPath = item.generation?.mapPath || (item.groundedBy || []).find((entry) => entry.type === "ui-map-node")?.mapPath || ".
|
|
1489
|
+
const relativeMapPath = item.generation?.mapPath || (item.groundedBy || []).find((entry) => entry.type === "ui-map-node")?.mapPath || ".tapp/ui-map.json";
|
|
1484
1490
|
const absolute = path.resolve(root, relativeMapPath);
|
|
1485
1491
|
if (!isInsideRoot(root, absolute)) throw new Error(`UI Map for '${item.name}' escapes the repository: ${relativeMapPath}`);
|
|
1486
1492
|
if (!mapCache.has(absolute)) {
|
|
@@ -1499,7 +1505,7 @@ export async function promoteValidatedProposals(plan, { projectDir, ids = [] } =
|
|
|
1499
1505
|
for (const taskPath of item.generation.taskPaths || []) {
|
|
1500
1506
|
const source = path.resolve(root, taskPath);
|
|
1501
1507
|
if (!fs.existsSync(source)) throw new Error(`Generated Task is missing: ${taskPath}`);
|
|
1502
|
-
if (!String(source).includes(`${path.sep}
|
|
1508
|
+
if (![".tapp", ".autotap"].some((directory) => String(source).includes(`${path.sep}${directory}${path.sep}proposals${path.sep}tasks${path.sep}`))) continue;
|
|
1503
1509
|
const destination = promotedDestination(root, source, "tasks");
|
|
1504
1510
|
moves.set(source, destination);
|
|
1505
1511
|
let task = taskRecords.get(source);
|
|
@@ -31,6 +31,7 @@ import { writeHtmlReport } from "./html-report.js";
|
|
|
31
31
|
import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
|
|
32
32
|
import { proposeSelectorMaintenance, validateWebMaintenanceProposal } from "./maintenance-proposal.js";
|
|
33
33
|
import { isBusinessUiMapNode, releasePlanCandidateFromUiMapNode } from "./application-model.js";
|
|
34
|
+
import { existingProjectArtifactPath } from "./project-paths.js";
|
|
34
35
|
|
|
35
36
|
function parseArgs(argv) {
|
|
36
37
|
const args = { flowLogs: [], failOn: "gate" };
|
|
@@ -201,14 +202,14 @@ function targetCoverageDisposition(target, currentNode, projectDir = "") {
|
|
|
201
202
|
provenance: "runtime-observed",
|
|
202
203
|
});
|
|
203
204
|
if (projectDir) {
|
|
204
|
-
const releasePlanPath =
|
|
205
|
+
const releasePlanPath = existingProjectArtifactPath(path.resolve(projectDir), "release-plan.json");
|
|
205
206
|
try {
|
|
206
207
|
const releasePlan = JSON.parse(fs.readFileSync(releasePlanPath, "utf8"));
|
|
207
208
|
const existing = (releasePlan.items || []).find((candidate) => candidate.id === item.id || candidate.name === item.name ||
|
|
208
209
|
(candidate.groundedBy || []).some((ground) => ground.type === "ui-map-node" && ground.id === currentNode.id));
|
|
209
210
|
if (existing) return {
|
|
210
211
|
existingReleasePlanItem: {
|
|
211
|
-
path: ".
|
|
212
|
+
path: ".tapp/release-plan.json",
|
|
212
213
|
id: existing.id,
|
|
213
214
|
name: existing.name,
|
|
214
215
|
decision: existing.decision,
|
|
@@ -219,7 +220,7 @@ function targetCoverageDisposition(target, currentNode, projectDir = "") {
|
|
|
219
220
|
kind: "release-plan-item-proposal",
|
|
220
221
|
status: "matches-existing-release-plan",
|
|
221
222
|
autoApply: false,
|
|
222
|
-
targetPath: ".
|
|
223
|
+
targetPath: ".tapp/release-plan.json",
|
|
223
224
|
operation: { op: "reconcile-item", item },
|
|
224
225
|
reason: "Fresh PR runtime and changed-file evidence can be attached to the existing grounded item only through explicit adoption; its current human decision is preserved.",
|
|
225
226
|
requiredValidation: "After explicit evidence reconciliation, keep the normal review, generation, deterministic replay, and promotion requirements.",
|
|
@@ -231,7 +232,7 @@ function targetCoverageDisposition(target, currentNode, projectDir = "") {
|
|
|
231
232
|
kind: "release-plan-item-proposal",
|
|
232
233
|
status: "awaiting-explicit-adoption",
|
|
233
234
|
autoApply: false,
|
|
234
|
-
targetPath: ".
|
|
235
|
+
targetPath: ".tapp/release-plan.json",
|
|
235
236
|
operation: { op: "add-item", item },
|
|
236
237
|
reason: `The changed ${currentNode.name} surface was observed in this PR run but is not covered by a selected release contract.`,
|
|
237
238
|
requiredValidation: "Explicitly adopt and review this item, generate reusable UI-Map-backed Tasks, then replay the resulting contract against the real target before promotion.",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
|
|
3
4
|
|
|
4
5
|
function inside(root, candidate) {
|
|
5
6
|
const value = path.relative(root, candidate);
|
|
@@ -36,7 +37,12 @@ export function selectApplicationTarget(model, { platform = "", target = "" } =
|
|
|
36
37
|
|
|
37
38
|
export function baselinePathForTarget(projectDir, target) {
|
|
38
39
|
const root = fs.realpathSync(path.resolve(projectDir));
|
|
39
|
-
return path.join(root, ".
|
|
40
|
+
return path.join(root, ".tapp", "baselines", target.platform, `${targetSlug(target.id)}.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function existingBaselinePathForTarget(projectDir, target) {
|
|
44
|
+
const root = fs.realpathSync(path.resolve(projectDir));
|
|
45
|
+
return existingProjectArtifactPath(root, "baselines", target.platform, `${targetSlug(target.id)}.json`);
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export function validateBaselineReport(report, { platform, targetId } = {}) {
|
|
@@ -134,8 +140,11 @@ function posix(value) {
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
function suiteDirectories(root, target, kind) {
|
|
137
|
-
const candidates = [path.join(root,
|
|
138
|
-
if (target.sourcePath && target.sourcePath !== ".")
|
|
143
|
+
const candidates = [path.join(root, projectArtifactDirectory(root), kind)];
|
|
144
|
+
if (target.sourcePath && target.sourcePath !== ".") {
|
|
145
|
+
const targetRoot = path.join(root, target.sourcePath);
|
|
146
|
+
candidates.push(path.join(targetRoot, projectArtifactDirectory(targetRoot), kind));
|
|
147
|
+
}
|
|
139
148
|
return [...new Set(candidates)].filter((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
|
|
140
149
|
.map((candidate) => `${posix(path.relative(root, candidate))}/*.yml`);
|
|
141
150
|
}
|
|
@@ -176,7 +185,7 @@ function credentialConfiguration(model, targetContracts = []) {
|
|
|
176
185
|
if (requirements.has("email")) bindings.add("TAPP_TEST_EMAIL");
|
|
177
186
|
if (requirements.has("password")) bindings.add("TAPP_TEST_PASSWORD");
|
|
178
187
|
}
|
|
179
|
-
for (const name of bindings) if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(String(name))) throw new Error(`Actor credential binding '${name}' is not a safe environment-variable name; rerun tapp init from valid .
|
|
188
|
+
for (const name of bindings) if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(String(name))) throw new Error(`Actor credential binding '${name}' is not a safe environment-variable name; rerun tapp init from valid .tapp/project.json`);
|
|
180
189
|
const inputs = {};
|
|
181
190
|
const primaryEmail = primary.credentialBindings?.email || (!primary.credentialBindings && requirements.has("email") ? "TAPP_TEST_EMAIL" : "");
|
|
182
191
|
const primaryPassword = primary.credentialBindings?.password || (!primary.credentialBindings && requirements.has("password") ? "TAPP_TEST_PASSWORD" : "");
|
|
@@ -220,7 +229,7 @@ function targetInputs(root, model, target) {
|
|
|
220
229
|
if (flows.length) inputs.flows = flows.join(" ");
|
|
221
230
|
if (scenarios.length) inputs.scenarios = scenarios.join(" ");
|
|
222
231
|
if (contracts.length) inputs.contracts = contracts.join(" ");
|
|
223
|
-
const baselinePath =
|
|
232
|
+
const baselinePath = existingBaselinePathForTarget(root, target);
|
|
224
233
|
if (fs.existsSync(baselinePath)) inputs.baseline = posix(path.relative(root, baselinePath));
|
|
225
234
|
const credentials = credentialConfiguration(model, targetContracts);
|
|
226
235
|
Object.assign(inputs, credentials.inputs);
|
|
@@ -292,7 +301,7 @@ export function renderGithubWorkflow({ projectDir, model, actionRef, defaultBran
|
|
|
292
301
|
jobs.push(lines.join("\n"));
|
|
293
302
|
}
|
|
294
303
|
const workflow = [
|
|
295
|
-
"# Generated by `tapp ci install` from .
|
|
304
|
+
"# Generated by `tapp ci install` from .tapp/application-model.json.",
|
|
296
305
|
"# Review this patch. Tapp never overwrites it silently.",
|
|
297
306
|
"name: Tapp release gate",
|
|
298
307
|
"",
|
|
@@ -341,7 +350,7 @@ export function renderGithubWorkflow({ projectDir, model, actionRef, defaultBran
|
|
|
341
350
|
return { workflow, manifest };
|
|
342
351
|
}
|
|
343
352
|
|
|
344
|
-
export function writeCiInstallation({ projectDir, workflow, manifest, workflowPath = ".github/workflows/tapp.yml", manifestPath = ".
|
|
353
|
+
export function writeCiInstallation({ projectDir, workflow, manifest, workflowPath = ".github/workflows/tapp.yml", manifestPath = ".tapp/ci.json", replace = false } = {}) {
|
|
345
354
|
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
346
355
|
const destinations = [path.resolve(root, workflowPath), path.resolve(root, manifestPath)];
|
|
347
356
|
for (const destination of destinations) if (!inside(root, destination)) throw new Error("CI installation outputs must remain inside the repository");
|
package/mcp-server/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
|
|
15
15
|
import { parseOcqaMarkers, buildQaReport, computeRegression } from "./report.js";
|
|
16
|
+
import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
|
|
16
17
|
|
|
17
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
19
|
const __dirname = path.dirname(__filename);
|
|
@@ -918,7 +919,7 @@ export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAss
|
|
|
918
919
|
steps,
|
|
919
920
|
};
|
|
920
921
|
const slug = flowName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "flow";
|
|
921
|
-
const dir = path.join(root, ".
|
|
922
|
+
const dir = path.join(root, ".tapp", "flows");
|
|
922
923
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
923
924
|
if (fs.existsSync(outPath) && !replace) {
|
|
924
925
|
const error = new Error(`Flow '${path.relative(root, outPath)}' already exists. Choose another name or explicitly replace it.`);
|
|
@@ -1589,7 +1590,7 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, onPro
|
|
|
1589
1590
|
export async function runInitExploration({
|
|
1590
1591
|
projectDir,
|
|
1591
1592
|
platform,
|
|
1592
|
-
outDir = ".
|
|
1593
|
+
outDir = ".tapp",
|
|
1593
1594
|
url = "",
|
|
1594
1595
|
target = "",
|
|
1595
1596
|
bundleId = "",
|
|
@@ -1610,7 +1611,7 @@ export async function runInitExploration({
|
|
|
1610
1611
|
catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
|
|
1611
1612
|
const selected = String(platform || (url ? "web" : appId || apkPath ? "android" : "ios")).toLowerCase();
|
|
1612
1613
|
if (!["ios", "android", "web"].includes(selected)) return { error: "platform must be ios|android|web" };
|
|
1613
|
-
const mapPath = path.resolve(root, outDir, "ui-map.json");
|
|
1614
|
+
const mapPath = path.resolve(root, projectArtifactDirectory(root, outDir), "ui-map.json");
|
|
1614
1615
|
if (!isInsideDir(root, mapPath)) return { error: "UI Map output must remain inside the repository" };
|
|
1615
1616
|
|
|
1616
1617
|
let resolvedTarget = "";
|
|
@@ -2152,7 +2153,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2152
2153
|
testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
|
|
2153
2154
|
testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
|
|
2154
2155
|
maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
|
|
2155
|
-
outDir: { type: "string", description: "Repo-relative artifact directory; default .
|
|
2156
|
+
outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
|
|
2156
2157
|
},
|
|
2157
2158
|
},
|
|
2158
2159
|
},
|
|
@@ -2160,7 +2161,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2160
2161
|
name: "tapp_actor_config",
|
|
2161
2162
|
title: "Inspect or configure named test actors without storing credential values",
|
|
2162
2163
|
description:
|
|
2163
|
-
"Manage the repository-native .
|
|
2164
|
+
"Manage the repository-native .tapp/project.json actor/session contract used by init, release-contract generation, and CI. `read` is inspect-only. `set` writes an explicit actor role, isolation/provisioning policy, and credential-name to environment-variable-name bindings. The tool never accepts, returns, or persists credential values and never overwrites an actor unless replace is explicit.",
|
|
2164
2165
|
inputSchema: {
|
|
2165
2166
|
type: "object",
|
|
2166
2167
|
properties: {
|
|
@@ -2180,14 +2181,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2180
2181
|
name: "tapp_release_plan",
|
|
2181
2182
|
title: "Inspect or explicitly review a Tapp release plan",
|
|
2182
2183
|
description:
|
|
2183
|
-
"Read the repository-native release plan, apply explicit approve/reject/defer decisions, generate grounded Task/contract drafts, deterministically validate drafts on a real target, or explicitly promote fully replay-validated drafts into reviewed repository-native artifacts. Review changes only decision metadata. Generation writes under .
|
|
2184
|
+
"Read the repository-native release plan, apply explicit approve/reject/defer decisions, generate grounded Task/contract drafts, deterministically validate drafts on a real target, or explicitly promote fully replay-validated drafts into reviewed repository-native artifacts. Review changes only decision metadata. Generation writes under .tapp/proposals, never overwrites, never invokes AI, and remains untrusted until real deterministic replay passes. Web validation can build/start/stop the detected managed target when url is omitted.",
|
|
2184
2185
|
inputSchema: {
|
|
2185
2186
|
type: "object",
|
|
2186
2187
|
properties: {
|
|
2187
2188
|
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2188
2189
|
operation: { type: "string", enum: ["read", "review", "generate", "validate", "promote"], default: "read" },
|
|
2189
|
-
planPath: { type: "string", description: "Repo-relative plan path; default .
|
|
2190
|
-
projectDir: { type: "string", description: "Generate: repo-relative project root containing the scoped .
|
|
2190
|
+
planPath: { type: "string", description: "Repo-relative plan path; default .tapp/release-plan.json" },
|
|
2191
|
+
projectDir: { type: "string", description: "Generate: repo-relative project root containing the scoped .tapp Task directories" },
|
|
2191
2192
|
approve: { type: "array", items: { type: "string" }, description: "Plan item ids or names to approve" },
|
|
2192
2193
|
reject: { type: "array", items: { type: "string" }, description: "Plan item ids or names to reject" },
|
|
2193
2194
|
defer: { type: "array", items: { type: "string" }, description: "Plan item ids or names to defer" },
|
|
@@ -2214,11 +2215,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2214
2215
|
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2215
2216
|
operation: { type: "string", enum: ["inspect", "install", "baseline"], default: "inspect" },
|
|
2216
2217
|
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2217
|
-
modelPath: { type: "string", description: "Repo-relative application model path; defaults to <projectDir>/.
|
|
2218
|
+
modelPath: { type: "string", description: "Repo-relative application model path; defaults to <projectDir>/.tapp/application-model.json" },
|
|
2218
2219
|
actionRef: { type: "string", description: "GitHub Action reference owner/repository@release-tag-or-sha; defaults to the current Tapp release tag" },
|
|
2219
2220
|
defaultBranch: { type: "string", default: "main" },
|
|
2220
2221
|
workflowPath: { type: "string", description: "Install: project-relative output; default .github/workflows/tapp.yml" },
|
|
2221
|
-
manifestPath: { type: "string", description: "Install: project-relative output; default .
|
|
2222
|
+
manifestPath: { type: "string", description: "Install: project-relative output; default .tapp/ci.json" },
|
|
2222
2223
|
allowUnresolved: { type: "boolean", default: false, description: "Permit writing a draft whose manifest names unresolved target configuration" },
|
|
2223
2224
|
replace: { type: "boolean", default: false, description: "Explicitly replace an existing generated workflow/manifest or target baseline" },
|
|
2224
2225
|
reportPath: { type: "string", description: "Baseline: repo-relative successful conclusive portable-gate JSON report" },
|
|
@@ -2240,7 +2241,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2240
2241
|
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2241
2242
|
operation: { type: "string", enum: ["read", "build", "diff"], default: "read" },
|
|
2242
2243
|
captureId: { type: "string", description: "Read/build from this Tapp capture's ocqa-markers.txt/ui-map.json" },
|
|
2243
|
-
mapPath: { type: "string", description: "Repo-relative UI Map path for read, or build output (default .
|
|
2244
|
+
mapPath: { type: "string", description: "Repo-relative UI Map path for read, or build output (default .tapp/ui-map.json)" },
|
|
2244
2245
|
markersPath: { type: "string", description: "Repo-relative OCQA markers path for build when captureId is not supplied" },
|
|
2245
2246
|
beforePath: { type: "string", description: "Repo-relative baseline UI Map for diff" },
|
|
2246
2247
|
afterPath: { type: "string", description: "Repo-relative current UI Map for diff" },
|
|
@@ -2255,7 +2256,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2255
2256
|
name: "tapp_task",
|
|
2256
2257
|
title: "Inspect, validate, or compile a reusable deterministic Task",
|
|
2257
2258
|
description:
|
|
2258
|
-
"Work with repository-native compositional Tasks in .
|
|
2259
|
+
"Work with repository-native compositional Tasks in .tapp/tasks. Tasks define inputs, outputs, pre/postconditions, platform implementations, and the UI Map states/transitions they cover. " +
|
|
2259
2260
|
"Validation is deterministic and can ground selectors/coverage against ui-map.json. Compilation expands a Task into the shared keyless Flow contract with reviewable Task provenance; pass that returned flow to tapp_flow_run to replay it. No AI or API key is used.",
|
|
2260
2261
|
inputSchema: {
|
|
2261
2262
|
type: "object",
|
|
@@ -2263,7 +2264,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2263
2264
|
properties: {
|
|
2264
2265
|
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2265
2266
|
operation: { type: "string", enum: ["read", "validate", "compile"], default: "validate" },
|
|
2266
|
-
taskPath: { type: "string", description: "Repo-relative .
|
|
2267
|
+
taskPath: { type: "string", description: "Repo-relative .tapp/tasks/*.yml|json file" },
|
|
2267
2268
|
platform: { type: "string", enum: ["ios", "android", "web"], description: "Implementation to validate/compile" },
|
|
2268
2269
|
inputs: { type: "object", additionalProperties: { type: "string" }, description: "Task inputs for compile. Secret inputs must be environment placeholders such as $TEST_PASSWORD, never plaintext." },
|
|
2269
2270
|
mapPath: { type: "string", description: "Optional repo-relative UI Map v1 used to ground states, edges, and semantic controls" },
|
|
@@ -2276,7 +2277,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2276
2277
|
name: "tapp_release_contract",
|
|
2277
2278
|
title: "Inspect, validate, compile, or run a release contract",
|
|
2278
2279
|
description:
|
|
2279
|
-
"Work with repository-native TypeScript release contracts in .
|
|
2280
|
+
"Work with repository-native TypeScript release contracts in .tapp/contracts. Contracts express business guarantees through reusable Tasks, named actors, exact/eventual expectations, criticality, policy, and UI Map coverage. " +
|
|
2280
2281
|
"Compilation targets the same deterministic Flow/Scenario evidence contract; ordinary run is keyless and never invokes a model. Multi-actor isolated replay is currently web-only.",
|
|
2281
2282
|
inputSchema: {
|
|
2282
2283
|
type: "object",
|
|
@@ -2284,7 +2285,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2284
2285
|
properties: {
|
|
2285
2286
|
authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
|
|
2286
2287
|
operation: { type: "string", enum: ["read", "validate", "compile", "run"], default: "validate" },
|
|
2287
|
-
contractPath: { type: "string", description: "Repo-relative .
|
|
2288
|
+
contractPath: { type: "string", description: "Repo-relative .tapp/contracts/*.contract.ts file" },
|
|
2288
2289
|
platform: { type: "string", enum: ["ios", "android", "web"], description: "Target platform; optional when the contract declares exactly one" },
|
|
2289
2290
|
mapPath: { type: "string", description: "Optional repo-relative UI Map v1 for coverage grounding" },
|
|
2290
2291
|
updateMap: { type: "boolean", default: false, description: "Explicitly add the reviewed contract coverage to mapPath" },
|
|
@@ -2326,10 +2327,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2326
2327
|
},
|
|
2327
2328
|
projectDir: { type: "string", description: "Repo-relative project root; defaults to the MCP workspace root" },
|
|
2328
2329
|
platform: { type: "string", enum: ["ios", "android", "web"], description: "Optional platform filter" },
|
|
2329
|
-
mapPath: { type: "string", description: "Project-relative UI Map; defaults to .
|
|
2330
|
+
mapPath: { type: "string", description: "Project-relative UI Map; defaults to .tapp/ui-map.json" },
|
|
2330
2331
|
prPlanPath: { type: "string", description: "Adopt: project-relative executed PR plan containing conclusive exploration evidence" },
|
|
2331
2332
|
item: { type: "string", description: "Adopt: stable exploration target id whose reviewable proposal should be appended" },
|
|
2332
|
-
releasePlanPath: { type: "string", description: "Adopt: project-relative target; defaults to .
|
|
2333
|
+
releasePlanPath: { type: "string", description: "Adopt: project-relative target; defaults to .tapp/release-plan.json" },
|
|
2333
2334
|
},
|
|
2334
2335
|
},
|
|
2335
2336
|
},
|
|
@@ -2356,7 +2357,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2356
2357
|
description:
|
|
2357
2358
|
"Inline Flow: {name, app, steps:[...], vars?}. Example: {name:'login', app:'com.acme.app', steps:[{tap:'Sign In'}, {type:{field:'Email', value:'$TEST_EMAIL'}}, {tap:'Continue'}, {assert_screen:'Home'}]}",
|
|
2358
2359
|
},
|
|
2359
|
-
flowPath: { type: "string", description: "Alternative to `flow`: repo-relative path to a .yml/.json Flow (e.g. .
|
|
2360
|
+
flowPath: { type: "string", description: "Alternative to `flow`: repo-relative path to a .yml/.json Flow (e.g. .tapp/flows/login.yml)" },
|
|
2360
2361
|
platform: { type: "string", enum: ["ios", "web", "android"], description: "Overrides Flow platform detection" },
|
|
2361
2362
|
appBundleId: { type: "string", description: "iOS: overrides the Flow's `app:` field" },
|
|
2362
2363
|
androidAppId: { type: "string", description: "Android: overrides the Flow's `app:` field" },
|
|
@@ -2394,7 +2395,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2394
2395
|
"Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
|
|
2395
2396
|
"GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
|
|
2396
2397
|
"screen/control map (or reuses a recent run via captureId), then a model authors a Flow using only " +
|
|
2397
|
-
"screens/controls that were actually observed. Saves it to .
|
|
2398
|
+
"screens/controls that were actually observed. Saves it to .tapp/flows/<name>.yml and returns the " +
|
|
2398
2399
|
"YAML for review (optionally runs it). Needs a model backend (Tapp subscription token or " +
|
|
2399
2400
|
"ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
|
|
2400
2401
|
inputSchema: {
|
|
@@ -2418,7 +2419,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2418
2419
|
description:
|
|
2419
2420
|
"Save what you've done in the CURRENT interactive session as a reusable, deterministic Flow " +
|
|
2420
2421
|
"(record-by-doing). Every successful tapp_session_act (tap/type/swipe/back) is recorded; this " +
|
|
2421
|
-
"writes them to .
|
|
2422
|
+
"writes them to .tapp/flows/<name>.yml with wait_for steps auto-inserted on screen changes and a " +
|
|
2422
2423
|
"final assert_screen checkpoint. Typed credentials are templated to $TEST_EMAIL/$TEST_PASSWORD so the " +
|
|
2423
2424
|
"flow is shareable. The saved flow replays with tapp_flow_run. Do it once → it's a test.",
|
|
2424
2425
|
inputSchema: {
|
|
@@ -2908,7 +2909,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2908
2909
|
if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
|
|
2909
2910
|
const { initializeProductProject } = await import("./product-operations.js");
|
|
2910
2911
|
try {
|
|
2911
|
-
const outDir = isNonEmptyString(args.outDir) ? args.outDir.trim() : ".
|
|
2912
|
+
const outDir = isNonEmptyString(args.outDir) ? args.outDir.trim() : ".tapp";
|
|
2912
2913
|
const resolvedOut = path.resolve(projectDir, outDir);
|
|
2913
2914
|
if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
|
|
2914
2915
|
const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
|
|
@@ -2979,7 +2980,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2979
2980
|
if (unauthorized) return unauthorized;
|
|
2980
2981
|
const operation = String(args.operation || "read").toLowerCase();
|
|
2981
2982
|
if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
|
|
2982
|
-
const planPath =
|
|
2983
|
+
const planPath = isNonEmptyString(args.planPath) ? path.resolve(repoRoot, args.planPath.trim()) : existingProjectArtifactPath(repoRoot, "release-plan.json");
|
|
2983
2984
|
if (!isInsideDir(repoRoot, planPath)) return errorResult("planPath must be inside the repo");
|
|
2984
2985
|
if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
|
|
2985
2986
|
let plan;
|
|
@@ -3054,7 +3055,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3054
3055
|
if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
|
|
3055
3056
|
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
|
|
3056
3057
|
if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
|
|
3057
|
-
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(repoRoot, args.modelPath.trim()) :
|
|
3058
|
+
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(repoRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
3058
3059
|
if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
|
|
3059
3060
|
let model;
|
|
3060
3061
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -3078,7 +3079,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3078
3079
|
const rendered = prepareProductCi({ projectDir, modelPath, actionRef, defaultBranch });
|
|
3079
3080
|
if (operation === "inspect") return richResult(`🧩 CI plan — ${rendered.manifest.targets.length} target job(s) · ${rendered.manifest.unresolved.length} unresolved · read-only`, rendered);
|
|
3080
3081
|
if (rendered.manifest.unresolved.length && !asBoolean(args.allowUnresolved)) return errorResult("CI workflow not installed because target configuration remains unresolved", { unresolved: rendered.manifest.unresolved, next: "Resolve the application model requirements or explicitly allow an inspect-only draft." });
|
|
3081
|
-
const result = installProductCi({ projectDir, modelPath, actionRef, defaultBranch, workflowPath: isNonEmptyString(args.workflowPath) ? args.workflowPath.trim() : ".github/workflows/tapp.yml", manifestPath: isNonEmptyString(args.manifestPath) ? args.manifestPath.trim() : ".
|
|
3082
|
+
const result = installProductCi({ projectDir, modelPath, actionRef, defaultBranch, workflowPath: isNonEmptyString(args.workflowPath) ? args.workflowPath.trim() : ".github/workflows/tapp.yml", manifestPath: isNonEmptyString(args.manifestPath) ? args.manifestPath.trim() : ".tapp/ci.json", replace: asBoolean(args.replace), allowUnresolved: asBoolean(args.allowUnresolved) });
|
|
3082
3083
|
return richResult(`✅ Reviewable CI gate installed — ${result.manifest.targets.length} target job(s); no commit, push, branch protection, or GitHub resource was created`, result);
|
|
3083
3084
|
} catch (error) { return errorResult("Could not prepare CI installation", { detail: error.message || String(error) }); }
|
|
3084
3085
|
}
|
|
@@ -3109,7 +3110,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3109
3110
|
const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
|
|
3110
3111
|
if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
|
|
3111
3112
|
if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
|
|
3112
|
-
const outPath = resolveRepoFile(args.mapPath
|
|
3113
|
+
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
|
|
3113
3114
|
if (!outPath) return errorResult("mapPath must be inside the repo");
|
|
3114
3115
|
try {
|
|
3115
3116
|
const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
|
|
@@ -3120,7 +3121,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3120
3121
|
} catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
|
|
3121
3122
|
}
|
|
3122
3123
|
if (operation !== "read") return errorResult("operation must be read|build|diff");
|
|
3123
|
-
const mapPath = capture ? path.join(capture.path, "ui-map.json") : resolveRepoFile(args.mapPath
|
|
3124
|
+
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
|
|
3124
3125
|
if (!mapPath) return errorResult("mapPath must be inside the repo");
|
|
3125
3126
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
|
|
3126
3127
|
try {
|
|
@@ -3480,7 +3481,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3480
3481
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3481
3482
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3482
3483
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3483
|
-
const dir = path.join(repoRoot, ".
|
|
3484
|
+
const dir = path.join(repoRoot, ".tapp", "flows");
|
|
3484
3485
|
fs.mkdirSync(dir, { recursive: true });
|
|
3485
3486
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3486
3487
|
const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
|
|
@@ -125,16 +125,16 @@ export async function validateWebMaintenanceProposal({ proposal, projectDir, url
|
|
|
125
125
|
if (!projectDir || !url) throw new Error("web maintenance validation requires projectDir and the running target URL");
|
|
126
126
|
const root = fs.realpathSync(path.resolve(projectDir));
|
|
127
127
|
const operation = proposal.operations[0];
|
|
128
|
-
const taskRoot = fs.realpathSync(path.join(root, ".
|
|
128
|
+
const taskRoot = fs.realpathSync(path.join(root, ".tapp", "tasks"));
|
|
129
129
|
const sourceTask = fs.realpathSync(path.resolve(root, operation.taskPath));
|
|
130
130
|
const sourceContract = fs.realpathSync(path.resolve(root, proposal.contractIntent.path));
|
|
131
|
-
if (!inside(taskRoot, sourceTask)) throw new Error("maintenance Task must be a regular reviewed file under .
|
|
131
|
+
if (!inside(taskRoot, sourceTask)) throw new Error("maintenance Task must be a regular reviewed file under .tapp/tasks");
|
|
132
132
|
if (!inside(root, sourceContract)) throw new Error("maintenance contract must remain inside the project");
|
|
133
133
|
if (digest(sourceTask) !== operation.taskSha256) throw new Error("Task digest changed after the proposal was created");
|
|
134
134
|
if (digest(sourceContract) !== proposal.contractIntent.sha256) throw new Error("release-contract intent digest changed after the proposal was created");
|
|
135
135
|
|
|
136
136
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tapp-maintenance-validation-"));
|
|
137
|
-
const tempTasks = path.join(tempRoot, ".
|
|
137
|
+
const tempTasks = path.join(tempRoot, ".tapp", "tasks");
|
|
138
138
|
const stem = String(proposal.contractIntent.name || "contract").replace(/[^A-Za-z0-9._-]/g, "-");
|
|
139
139
|
const outputDir = evidenceDir ? path.resolve(evidenceDir, stem) : path.join(tempRoot, "evidence");
|
|
140
140
|
const logPath = path.join(outputDir, "validation.log");
|
|
@@ -153,7 +153,7 @@ export async function validateWebMaintenanceProposal({ proposal, projectDir, url
|
|
|
153
153
|
if (!(contract.setup || []).length || !(contract.teardown || []).length) {
|
|
154
154
|
throw new Error("automatic disposable maintenance validation requires controlled contract setup and teardown");
|
|
155
155
|
}
|
|
156
|
-
const pseudoContractPath = path.join(tempRoot, ".
|
|
156
|
+
const pseudoContractPath = path.join(tempRoot, ".tapp", "contracts", path.basename(sourceContract));
|
|
157
157
|
const execution = compileReleaseContract(contract, { platform: "web", sourcePath: pseudoContractPath });
|
|
158
158
|
const result = await runWebFlow({ flow: execution, url, logPath, screenshotDir: outputDir });
|
|
159
159
|
const contractUnchanged = digest(sourceContract) === proposal.contractIntent.sha256;
|