@frankzhang2026/opencode-android-orchestrator 1.0.5 → 1.1.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/CHANGELOG.md +67 -0
- package/README.md +76 -15
- package/dist/config/queue-policy.d.ts.map +1 -1
- package/dist/config/queue-policy.js +1 -2
- package/dist/config/queue-policy.js.map +1 -1
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +14 -3
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/installation.d.ts +1 -1
- package/dist/doctor/installation.d.ts.map +1 -1
- package/dist/doctor/installation.js +10 -1
- package/dist/doctor/installation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/adaptive-templates.d.ts +4 -1
- package/dist/installer/adaptive-templates.d.ts.map +1 -1
- package/dist/installer/adaptive-templates.js +38 -13
- package/dist/installer/adaptive-templates.js.map +1 -1
- package/dist/installer/android-project.d.ts +3 -1
- package/dist/installer/android-project.d.ts.map +1 -1
- package/dist/installer/android-project.js.map +1 -1
- package/dist/installer/capabilities-script.d.ts +2 -0
- package/dist/installer/capabilities-script.d.ts.map +1 -0
- package/dist/installer/capabilities-script.js +77 -0
- package/dist/installer/capabilities-script.js.map +1 -0
- package/dist/installer/gradle-verification.d.ts.map +1 -1
- package/dist/installer/gradle-verification.js +13 -6
- package/dist/installer/gradle-verification.js.map +1 -1
- package/dist/installer/init.d.ts.map +1 -1
- package/dist/installer/init.js +2 -1
- package/dist/installer/init.js.map +1 -1
- package/dist/installer/opencode-config.d.ts +3 -3
- package/dist/installer/opencode-config.js +1 -1
- package/dist/installer/project-capabilities.d.ts +35 -0
- package/dist/installer/project-capabilities.d.ts.map +1 -0
- package/dist/installer/project-capabilities.js +82 -0
- package/dist/installer/project-capabilities.js.map +1 -0
- package/dist/installer/upgrade.d.ts.map +1 -1
- package/dist/installer/upgrade.js +9 -1
- package/dist/installer/upgrade.js.map +1 -1
- package/dist/queue/cli.d.ts.map +1 -1
- package/dist/queue/cli.js +32 -5
- package/dist/queue/cli.js.map +1 -1
- package/dist/queue/executor.d.ts +1 -0
- package/dist/queue/executor.d.ts.map +1 -1
- package/dist/queue/executor.js +155 -11
- package/dist/queue/executor.js.map +1 -1
- package/dist/queue/process-ownership.d.ts +12 -0
- package/dist/queue/process-ownership.d.ts.map +1 -0
- package/dist/queue/process-ownership.js +58 -0
- package/dist/queue/process-ownership.js.map +1 -0
- package/dist/queue/queue.d.ts +25 -1
- package/dist/queue/queue.d.ts.map +1 -1
- package/dist/queue/queue.js +96 -11
- package/dist/queue/queue.js.map +1 -1
- package/dist/queue/service.d.ts.map +1 -1
- package/dist/queue/service.js +19 -2
- package/dist/queue/service.js.map +1 -1
- package/dist/queue/storage.d.ts +3 -1
- package/dist/queue/storage.d.ts.map +1 -1
- package/dist/queue/storage.js +25 -15
- package/dist/queue/storage.js.map +1 -1
- package/dist/queue/supervision.d.ts +38 -0
- package/dist/queue/supervision.d.ts.map +1 -0
- package/dist/queue/supervision.js +247 -0
- package/dist/queue/supervision.js.map +1 -0
- package/dist/queue/tools.d.ts.map +1 -1
- package/dist/queue/tools.js +4 -2
- package/dist/queue/tools.js.map +1 -1
- package/docs/MIGRATION.md +110 -9
- package/docs/QUEUE.md +350 -16
- package/docs/SECURITY.md +5 -2
- package/docs/TROUBLESHOOTING.md +82 -8
- package/package.json +1 -1
- package/templates/.opencode/agents/scheduled-coder.md +14 -1
- package/templates/.opencode/agents/scheduled-planner.md +64 -3
- package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +29 -3
- package/templates/.opencode/skills/scheduled-quality-orchestrator/SKILL.md +65 -4
- package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +19 -1
- package/templates/AGENTS.md.fragment +10 -4
- package/templates/README.md +3 -1
- package/templates/automation/config.schema.json +212 -22
- package/templates/automation/task-contract.schema.json +573 -49
- package/templates/automation/tasks/TASK-TEMPLATE.json.example +9 -4
- package/templates/automation/verification/collect.init.gradle +98 -0
- package/templates/automation/verification/contract.cjs +93 -0
- package/templates/automation/verification/inventory.cjs +433 -0
- package/templates/automation/verification/project.cjs +172 -0
- package/templates/automation/verification/recovery.cjs +426 -0
- package/templates/scripts/automation/acceptance-report.sh +17 -0
- package/templates/scripts/automation/claim-task.sh +35 -2
- package/templates/scripts/automation/integration-scope-gate.sh +3 -3
- package/templates/scripts/automation/lib.sh +61 -19
- package/templates/scripts/automation/orchestrate-task.sh +7 -1
- package/templates/scripts/automation/preflight.sh +1 -2
- package/templates/scripts/automation/quality-gate.sh +10 -1
- package/templates/scripts/automation/record-red.sh +13 -1
- package/templates/scripts/automation/resume-task.sh +3 -0
- package/templates/scripts/automation/scope-gate.sh +4 -4
- package/templates/scripts/automation/show-acceptance-review.sh +1 -0
- package/templates/scripts/automation/status.sh +32 -0
- package/templates/scripts/automation/submit-review.sh +10 -1
- package/templates/scripts/automation/validate-contract.sh +10 -1
- package/templates/scripts/automation/verify-integration.sh +3 -0
- package/templates/scripts/automation/verify-task.sh +3 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const cp = require("node:child_process");
|
|
6
|
+
const { createHash, randomUUID } = require("node:crypto");
|
|
7
|
+
const { validateContract, identity, matchesPath } = require("./contract.cjs");
|
|
8
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
9
|
+
const digest = value => hash(JSON.stringify(value));
|
|
10
|
+
const read = file => {
|
|
11
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
12
|
+
catch { throw new InventoryError("EVIDENCE_CHANGED", `Missing or invalid evidence/configuration: ${file}`); }
|
|
13
|
+
};
|
|
14
|
+
const fileHash = file => hash(fs.readFileSync(file));
|
|
15
|
+
const same = (a, b) => digest(a) === digest(b);
|
|
16
|
+
const sorted = values => [...new Set(values)].sort();
|
|
17
|
+
class InventoryError extends Error {
|
|
18
|
+
constructor(code, message, issues = []) { super(message); this.code = code; this.issues = issues; }
|
|
19
|
+
}
|
|
20
|
+
function requireThat(condition, code, message, issues) {
|
|
21
|
+
if (!condition) throw new InventoryError(code, message, issues);
|
|
22
|
+
}
|
|
23
|
+
const recovery = (code, phase) => phase === "baseline"
|
|
24
|
+
? "Baseline capture is blocked. If baseline.json exists, /resume-task cannot retry this task: inspect the attempt, request approved abort/archive, then approve a new task after correcting the cause. Do not delete or overwrite baseline evidence."
|
|
25
|
+
: ({
|
|
26
|
+
EXECUTION_FAILURE: "Inspect the attempt log and fix the build or environment; incomplete execution cannot become RED.",
|
|
27
|
+
INCOMPLETE_COLLECTION: "Inspect the retained attempt; restore actual, complete Test execution and retry within the preparation budget.",
|
|
28
|
+
AMBIGUOUS_IDENTITY: "Give parameterized or dynamic cases stable distinct names, then approve a corrected contract if behavior identities change.",
|
|
29
|
+
BASELINE_NOT_GREEN: "Restore a passing baseline; existing failures cannot be approved automatically as regression coverage.",
|
|
30
|
+
EMPTY_BASELINE: "Correct the focused scope, or approve a new contract explicitly allowing an empty baseline for new tests.",
|
|
31
|
+
BASELINE_SKIPPED: "Restore the skipped tests or approve a new contract explicitly preserving existing skips.",
|
|
32
|
+
COVERAGE_MISMATCH: "Restore the listed missing or skipped cases; newly added behavior requires explicit contract coverage.",
|
|
33
|
+
CASE_EXPECTATION_MISMATCH: "Inspect each listed case; fix test preparation only within the approved requirements, otherwise revise the contract.",
|
|
34
|
+
EVIDENCE_CHANGED: "Restore the sealed inputs or create a revised task; do not overwrite baseline or RED evidence.",
|
|
35
|
+
UNSUPPORTED_TEST_INPUT: "Use repository-local test source/resource directories separated from production and generated outputs, then retry with a corrected configuration.",
|
|
36
|
+
INPUT_CHANGED_DURING_RUN: "Stop concurrent edits or input-generating side effects and rerun; this attempt was not sealed.",
|
|
37
|
+
PREPARATION_BUDGET: "The preparation budget is exhausted; preserve attempts and approve a revised task.",
|
|
38
|
+
}[code] ?? "Inspect inventory-status.json and the retained attempt before resuming.");
|
|
39
|
+
function atomic(file, value, exclusive = false) {
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
42
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
43
|
+
try { if (exclusive) { fs.linkSync(temp, file); } else { fs.renameSync(temp, file); } }
|
|
44
|
+
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
|
|
45
|
+
}
|
|
46
|
+
function groupedTargets(contract) {
|
|
47
|
+
return sorted(contract.targetTests.map(item => item.gradleTask)).map(taskPath => ({ taskPath,
|
|
48
|
+
filters: sorted(contract.targetTests.filter(item => item.gradleTask === taskPath).map(item => item.filter)) }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Counts and completion records authenticate the event stream independently of
|
|
52
|
+
// which cases are expected to fail. Duplicate identities are never deduplicated.
|
|
53
|
+
function validateCollection(events, request, status, discovery = false) {
|
|
54
|
+
requireThat(status === 0, "EXECUTION_FAILURE", `Gradle exited with ${status}`);
|
|
55
|
+
const ofKind = kind => events.filter(item => item.kind === kind);
|
|
56
|
+
requireThat(events.length > 1 && events.every(item => item.runId === request.runId) &&
|
|
57
|
+
events[0].kind === "start" && events[0].phase === request.phase && events.at(-1).kind === "end" &&
|
|
58
|
+
ofKind("start").length === 1 && ofKind("end").length === 1 && events.at(-1).failure === null &&
|
|
59
|
+
events.every(item => ["start", "task", "case", "suite", "taskEnd", "end"].includes(item.kind)),
|
|
60
|
+
"INCOMPLETE_COLLECTION", "Missing, stale, malformed or interrupted Gradle event stream");
|
|
61
|
+
const targets = groupedTargets({ targetTests: request.targets });
|
|
62
|
+
const tasks = ofKind("task"), cases = ofKind("case");
|
|
63
|
+
requireThat(tasks.length === targets.length && targets.every(target => tasks.filter(item => item.taskPath === target.taskPath && same(item.filters, target.filters)).length === 1),
|
|
64
|
+
"INCOMPLETE_COLLECTION", "Resolved Test tasks or filters differ from the approved scope");
|
|
65
|
+
requireThat(events.every(item => !item.taskPath || targets.some(target => target.taskPath === item.taskPath)), "INCOMPLETE_COLLECTION", "Unexpected task in collected results");
|
|
66
|
+
if (discovery) return { tasks, cases: [] };
|
|
67
|
+
const keys = new Set();
|
|
68
|
+
for (const item of cases) {
|
|
69
|
+
requireThat(typeof item.className === "string" && item.className.length > 0 && typeof item.name === "string" && item.name.length > 0 &&
|
|
70
|
+
["SUCCESS", "FAILURE", "SKIPPED"].includes(item.result) && Array.isArray(item.failures) &&
|
|
71
|
+
(item.result === "FAILURE" ? item.failures.length > 0 : item.failures.length === 0), "INCOMPLETE_COLLECTION", "Invalid test case event", [item]);
|
|
72
|
+
const key = identity(item);
|
|
73
|
+
requireThat(!keys.has(key), "AMBIGUOUS_IDENTITY", "Repeated case identity; parameter instances must remain distinguishable", [item]);
|
|
74
|
+
keys.add(key);
|
|
75
|
+
}
|
|
76
|
+
for (const target of targets) {
|
|
77
|
+
const endings = ofKind("taskEnd").filter(item => item.taskPath === target.taskPath);
|
|
78
|
+
const suites = ofKind("suite").filter(item => item.taskPath === target.taskPath);
|
|
79
|
+
const actual = cases.filter(item => item.taskPath === target.taskPath);
|
|
80
|
+
const end = endings[0];
|
|
81
|
+
requireThat(endings.length === 1 && end.executed === true && end.failure === null,
|
|
82
|
+
"INCOMPLETE_COLLECTION", `Test task did not finish: ${target.taskPath}`);
|
|
83
|
+
if (end.noSource === true && request.phase === "baseline") {
|
|
84
|
+
requireThat(actual.length === 0 && suites.length === 0 && end.skipMessage === "NO-SOURCE", "INCOMPLETE_COLLECTION", "Invalid NO-SOURCE completion");
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
requireThat(end.skipped === false && end.upToDate === false && end.noSource === false && suites.length === 1,
|
|
88
|
+
"INCOMPLETE_COLLECTION", `Test task was cached, skipped, empty or incomplete: ${target.taskPath}`);
|
|
89
|
+
const suite = suites[0];
|
|
90
|
+
requireThat(suite.tests === actual.length && suite.failures === actual.filter(item => item.result === "FAILURE").length &&
|
|
91
|
+
suite.skipped === actual.filter(item => item.result === "SKIPPED").length,
|
|
92
|
+
"INCOMPLETE_COLLECTION", `Suite counts disagree with individual results: ${target.taskPath}`);
|
|
93
|
+
requireThat(request.phase === "baseline" || actual.length > 0, "COVERAGE_MISMATCH", `No focused tests executed: ${target.taskPath}`);
|
|
94
|
+
}
|
|
95
|
+
return { tasks, cases: cases.map(({ runId, kind, ...item }) => item).sort((a, b) => identity(a).localeCompare(identity(b))) };
|
|
96
|
+
}
|
|
97
|
+
function evaluateBaseline(contract, collection) {
|
|
98
|
+
const { cases, tasks } = collection, policy = contract.verification.inventory;
|
|
99
|
+
const failed = cases.filter(item => item.result === "FAILURE");
|
|
100
|
+
requireThat(failed.length === 0, "BASELINE_NOT_GREEN", "Existing focused tests fail on the execution baseline", failed);
|
|
101
|
+
const skipped = cases.filter(item => item.result === "SKIPPED");
|
|
102
|
+
requireThat(policy.existingSkips === "preserve" || skipped.length === 0, "BASELINE_SKIPPED", "The approved policy rejects existing skipped tests", skipped);
|
|
103
|
+
const empty = tasks.filter(task => !cases.some(item => item.taskPath === task.taskPath));
|
|
104
|
+
requireThat(policy.emptyBaseline === "allow" || empty.length === 0, "EMPTY_BASELINE", "One or more focused tasks have an empty baseline", empty);
|
|
105
|
+
return { cases, summary: { existing: cases.length, passed: cases.length - skipped.length, skipped: skipped.length, emptyTasks: empty.length } };
|
|
106
|
+
}
|
|
107
|
+
function evaluateCoverage(contract, baseline, collection, phase, manifest) {
|
|
108
|
+
const actual = new Map(collection.cases.map(item => [identity(item), item]));
|
|
109
|
+
const expected = new Map(baseline.cases.map(item => [identity(item), { ...item, classification: "regression", id: identity(item) }]));
|
|
110
|
+
for (const item of contract.verification.cases) {
|
|
111
|
+
const value = { ...item, ...item.test, taskPath: contract.targetTests[item.test.target].gradleTask, classification: "behavior" };
|
|
112
|
+
expected.set(identity(value), value);
|
|
113
|
+
}
|
|
114
|
+
if (phase === "green") {
|
|
115
|
+
requireThat(manifest && same([...expected.keys()].sort(), manifest.cases.map(identity).sort()), "EVIDENCE_CHANGED", "Manifest coverage differs from baseline and contract");
|
|
116
|
+
}
|
|
117
|
+
const coverage = [], mismatches = [], cases = [];
|
|
118
|
+
for (const [key, item] of expected) {
|
|
119
|
+
const result = actual.get(key);
|
|
120
|
+
if (!result) { coverage.push({ ...item, problem: "missing" }); continue; }
|
|
121
|
+
let valid;
|
|
122
|
+
if (phase === "green") {
|
|
123
|
+
const sealed = manifest.cases.find(candidate => identity(candidate) === key);
|
|
124
|
+
valid = result.result === "SUCCESS" || (sealed.allowSkip && result.result === "SKIPPED");
|
|
125
|
+
} else if (item.classification === "regression") {
|
|
126
|
+
valid = result.result === "SUCCESS" || (item.result === "SKIPPED" && contract.verification.inventory.existingSkips === "preserve" && result.result === "SKIPPED");
|
|
127
|
+
} else {
|
|
128
|
+
const failure = item.expectedFailure;
|
|
129
|
+
const matched = result.result === "FAILURE" && failure && result.failures.length === 1 &&
|
|
130
|
+
result.failures[0].type === failure.type && (!failure.messageIncludes || result.failures[0].message.includes(failure.messageIncludes));
|
|
131
|
+
valid = item.before === "pass" ? result.result === "SUCCESS" : item.before === "fail" ? matched : result.result === "SUCCESS" || matched;
|
|
132
|
+
}
|
|
133
|
+
const entry = { id: item.id, taskPath: item.taskPath, className: item.className, name: item.name,
|
|
134
|
+
classification: item.classification, intent: item.intent ?? "regression", valid: Boolean(valid), actual: result,
|
|
135
|
+
allowSkip: item.classification === "regression" && item.result === "SKIPPED" && result.result === "SKIPPED" };
|
|
136
|
+
cases.push(entry);
|
|
137
|
+
if (!valid) (result.result === "SKIPPED" ? coverage : mismatches).push({ ...entry, problem: result.result === "SKIPPED" ? "newly-skipped" : "unexpected-result" });
|
|
138
|
+
}
|
|
139
|
+
for (const [key, item] of actual) if (!expected.has(key)) coverage.push({ ...item, problem: "undeclared-new-case" });
|
|
140
|
+
requireThat(coverage.length === 0, "COVERAGE_MISMATCH", "Focused test coverage changed", coverage);
|
|
141
|
+
requireThat(mismatches.length === 0, "CASE_EXPECTATION_MISMATCH", "Behavior or existing regression results do not match the approved expectations", mismatches);
|
|
142
|
+
const expectedRed = cases.filter(item => item.intent === "change" && item.actual.result === "FAILURE").length;
|
|
143
|
+
requireThat(phase !== "red" || expectedRed > 0, "CASE_EXPECTATION_MISMATCH", "No approved failing behavior provides RED");
|
|
144
|
+
return { cases, summary: { declared: contract.verification.cases.length, regression: cases.filter(item => item.classification === "regression").length,
|
|
145
|
+
total: cases.length, expectedRed, skipped: cases.filter(item => item.actual.result === "SKIPPED").length } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function git(root, args) {
|
|
149
|
+
const result = cp.spawnSync("git", ["-C", root, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
150
|
+
requireThat(result.status === 0, "EVIDENCE_CHANGED", result.stderr || "Unable to inspect Git baseline");
|
|
151
|
+
return result.stdout;
|
|
152
|
+
}
|
|
153
|
+
function localPath(root, file) {
|
|
154
|
+
const absolute = path.resolve(root, file), relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
155
|
+
requireThat(relative !== "" && relative !== ".." && !relative.startsWith("../") && !path.isAbsolute(relative) &&
|
|
156
|
+
!relative.split("/").some(part => [".git", ".gradle", "build"].includes(part)), "UNSUPPORTED_TEST_INPUT", `Unsafe or generated test input: ${file}`);
|
|
157
|
+
let parent = absolute;
|
|
158
|
+
while (parent !== root) {
|
|
159
|
+
requireThat(!fs.existsSync(parent) || !fs.lstatSync(parent).isSymbolicLink(), "UNSUPPORTED_TEST_INPUT", `Symlink test input is unsupported: ${file}`);
|
|
160
|
+
parent = path.dirname(parent);
|
|
161
|
+
}
|
|
162
|
+
return relative;
|
|
163
|
+
}
|
|
164
|
+
function testPatterns(root, config, tasks) {
|
|
165
|
+
const discovered = tasks.flatMap(task => task.sourceRoots.map(file => `${localPath(root, file)}/**`));
|
|
166
|
+
return sorted([...config.androidProject.testPaths, ...discovered]);
|
|
167
|
+
}
|
|
168
|
+
function isTest(config, patterns, file) {
|
|
169
|
+
return patterns.some(pattern => matchesPath(pattern, file)) &&
|
|
170
|
+
![...config.protectedPaths, ...config.androidProject.productionPaths].some(pattern => matchesPath(pattern, file));
|
|
171
|
+
}
|
|
172
|
+
function snapshot(root, config, patterns, excluded = []) {
|
|
173
|
+
root = fs.realpathSync(root);
|
|
174
|
+
const files = new Set();
|
|
175
|
+
function visit(file) {
|
|
176
|
+
if (!fs.existsSync(file)) return;
|
|
177
|
+
const relative = localPath(root, file), stat = fs.lstatSync(file);
|
|
178
|
+
if (stat.isDirectory()) { for (const name of fs.readdirSync(file).sort()) visit(path.join(file, name)); }
|
|
179
|
+
else if (stat.isFile() && patterns.some(pattern => matchesPath(pattern, relative))) {
|
|
180
|
+
requireThat(!excluded.includes(relative), "UNSUPPORTED_TEST_INPUT", `Human-owned path cannot be a test evidence input: ${relative}`);
|
|
181
|
+
requireThat(isTest(config, patterns, relative), "UNSUPPORTED_TEST_INPUT", `Test inputs overlap production or protected paths: ${relative}`);
|
|
182
|
+
files.add(relative);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const pattern of patterns) {
|
|
186
|
+
const wildcard = pattern.search(/[?*]/);
|
|
187
|
+
let prefix = wildcard < 0 ? pattern : pattern.slice(0, wildcard);
|
|
188
|
+
if (wildcard >= 0 && !prefix.endsWith("/")) prefix = prefix.slice(0, prefix.lastIndexOf("/") + 1);
|
|
189
|
+
requireThat(prefix.length > 0, "UNSUPPORTED_TEST_INPUT", `Test path needs a bounded directory: ${pattern}`);
|
|
190
|
+
visit(path.resolve(root, prefix));
|
|
191
|
+
}
|
|
192
|
+
return [...files].sort().map(file => ({ path: file, sha256: fileHash(path.join(root, file)), mode: fs.statSync(path.join(root, file)).mode & 0o777 }));
|
|
193
|
+
}
|
|
194
|
+
function worktree(root, excluded = []) {
|
|
195
|
+
const files = sorted(git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]).split("\0").filter(file => file && !excluded.includes(file)));
|
|
196
|
+
return digest(files.map(file => {
|
|
197
|
+
const full = path.join(root, file);
|
|
198
|
+
let stat;
|
|
199
|
+
try { stat = fs.lstatSync(full); } catch { return [file, "absent"]; }
|
|
200
|
+
return [file, stat.mode & 0o777, stat.isFile() ? fileHash(full) : stat.isSymbolicLink() ? `symlink:${fs.readlinkSync(full)}` : "non-file"];
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
function humanOwnedPaths(contract, evidence) {
|
|
204
|
+
const workspaceFile = path.join(path.dirname(path.dirname(evidence)), "workspaces", `${contract.id}.json`);
|
|
205
|
+
const allowlist = fs.existsSync(workspaceFile) ? read(workspaceFile).worktreeAllowlist ?? [] : [];
|
|
206
|
+
requireThat(Array.isArray(allowlist) && allowlist.every(item => typeof item === "string"), "EVIDENCE_CHANGED", "Invalid worktree allowlist");
|
|
207
|
+
return sorted([".automation-worktree-allowlist", "automation/automation-commit-prefix", ...allowlist]);
|
|
208
|
+
}
|
|
209
|
+
function unchangedProduction(root, contract, config, patterns, phase, evidence) {
|
|
210
|
+
const changed = sorted([...git(root, ["diff", "--name-only", "--no-renames", "HEAD", "-z"]).split("\0"),
|
|
211
|
+
...git(root, ["ls-files", "--others", "--exclude-standard", "-z"]).split("\0")].filter(Boolean));
|
|
212
|
+
const ignored = new Set([contract.planPath, `automation/tasks/${contract.id}.json`, ...humanOwnedPaths(contract, evidence)]);
|
|
213
|
+
const invalid = changed.filter(file => !ignored.has(file) && (phase === "baseline" || !isTest(config, patterns, file)));
|
|
214
|
+
requireThat(invalid.length === 0, "EVIDENCE_CHANGED", "Baseline/RED requires unchanged production and configuration", invalid.map(file => ({ path: file })));
|
|
215
|
+
}
|
|
216
|
+
function collectorHash() { return digest(["contract.cjs", "inventory.cjs", "collect.init.gradle"].map(file => [file, fileHash(path.join(__dirname, file))])); }
|
|
217
|
+
function evidenceFiles(evidence, directory) {
|
|
218
|
+
const result = [];
|
|
219
|
+
function visit(folder) { for (const name of fs.readdirSync(folder).sort()) { const file = path.join(folder, name); const stat = fs.lstatSync(file);
|
|
220
|
+
requireThat(!stat.isSymbolicLink(), "EVIDENCE_CHANGED", "Evidence cannot contain symbolic links");
|
|
221
|
+
if (stat.isDirectory()) visit(file); else if (stat.isFile()) result.push({ path: path.relative(evidence, file), sha256: fileHash(file) });
|
|
222
|
+
} }
|
|
223
|
+
visit(directory); return result;
|
|
224
|
+
}
|
|
225
|
+
function checkFiles(evidence, files) {
|
|
226
|
+
requireThat(Array.isArray(files) && files.length > 0, "EVIDENCE_CHANGED", "Evidence file binding is missing");
|
|
227
|
+
for (const item of files) {
|
|
228
|
+
const file = path.resolve(evidence, item.path), relative = path.relative(evidence, file);
|
|
229
|
+
requireThat(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) &&
|
|
230
|
+
fs.existsSync(file) && fs.lstatSync(file).isFile() && fileHash(file) === item.sha256, "EVIDENCE_CHANGED", `Evidence file changed: ${item.path}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function checkEvaluation(evidence, value, phase) {
|
|
234
|
+
requireThat(value.phase === phase && typeof value.attemptPath === "string" &&
|
|
235
|
+
new RegExp(`^inventory-attempts/${phase}-[0-9]+-[a-f0-9-]+$`).test(value.attemptPath),
|
|
236
|
+
"EVIDENCE_CHANGED", "Invalid sealed attempt reference");
|
|
237
|
+
const { files, baselineInventorySha256, redSha256, manifestSha256, ...report } = value;
|
|
238
|
+
requireThat(same(report, read(path.join(evidence, value.attemptPath, "evaluation.json"))),
|
|
239
|
+
"EVIDENCE_CHANGED", "Inventory report differs from its completed evaluation");
|
|
240
|
+
if (files) requireThat(same(files, evidenceFiles(evidence, path.join(evidence, value.attemptPath))),
|
|
241
|
+
"EVIDENCE_CHANGED", "Attempt file inventory changed");
|
|
242
|
+
}
|
|
243
|
+
function runGradle(root, config, contract, phase, directory, discovery) {
|
|
244
|
+
const buildRoot = require("./project.cjs").gradleBuildRoot(config, root);
|
|
245
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
246
|
+
const request = { runId: randomUUID(), phase, targets: contract.targetTests, output: path.join(directory, "events.jsonl") };
|
|
247
|
+
const requestFile = path.join(directory, "request.json"); atomic(requestFile, request, true);
|
|
248
|
+
fs.writeFileSync(request.output, "", { flag: "wx" });
|
|
249
|
+
const args = [...groupedTargets(contract).map(item => item.taskPath), "--no-configuration-cache", "--console=plain", "--no-build-cache",
|
|
250
|
+
"--init-script", path.join(__dirname, "collect.init.gradle"), `-Dorchestrator.inventoryRequest=${requestFile}`, ...(discovery ? ["--dry-run"] : [])];
|
|
251
|
+
const fd = fs.openSync(path.join(directory, "gradle.log"), "wx");
|
|
252
|
+
let result;
|
|
253
|
+
if (process.env.AUTOMATION_WORKER_TOKEN) args.push("--no-daemon");
|
|
254
|
+
try { result = cp.spawnSync(path.join(buildRoot, "gradlew"), args, { cwd: buildRoot, stdio: ["ignore", fd, fd] }); }
|
|
255
|
+
finally { fs.closeSync(fd); }
|
|
256
|
+
atomic(path.join(directory, "process.json"), { status: result.status, signal: result.signal, error: result.error?.message ?? null, args, cwd: buildRoot });
|
|
257
|
+
if (contract.schemaVersion >= 7 && phase !== "baseline" && result.status !== 0 && !result.signal && !result.error) {
|
|
258
|
+
const failure = require("./recovery.cjs").classifyFailure({ phase, exitCode: result.status, log: fs.readFileSync(path.join(directory, "gradle.log"), "utf8") });
|
|
259
|
+
if (failure.retryable) {
|
|
260
|
+
const error = new InventoryError("ENVIRONMENT_FAILURE", `Gradle environment failure: ${failure.reasonCode}`);
|
|
261
|
+
error.failure = failure; throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
let events;
|
|
265
|
+
try { events = fs.readFileSync(request.output, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)); }
|
|
266
|
+
catch { throw new InventoryError("INCOMPLETE_COLLECTION", "Invalid or truncated collector output"); }
|
|
267
|
+
return { ...validateCollection(events, request, result.status, discovery), processExitCode: result.status };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Invalidate previous GREEN before even validating inputs. A crash, early
|
|
271
|
+
// rejection or failed rerun must never leave an older success acceptable.
|
|
272
|
+
function runPhase(phase, contractFile, configFile, root, evidence) {
|
|
273
|
+
if (phase !== "green") return runPhaseInternal(phase, contractFile, configFile, root, evidence);
|
|
274
|
+
const markerFile = path.join(evidence, "green-verification.json");
|
|
275
|
+
const verificationRunId = randomUUID();
|
|
276
|
+
const marker = { verificationRunId, queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID ?? null, startedAt: new Date().toISOString() };
|
|
277
|
+
atomic(markerFile, { ...marker, state: "RUNNING" });
|
|
278
|
+
try {
|
|
279
|
+
const result = runPhaseInternal(phase, contractFile, configFile, root, evidence, verificationRunId);
|
|
280
|
+
atomic(markerFile, { ...marker, state: "PASSED", greenSha256: fileHash(path.join(evidence, "green-inventory.json")) });
|
|
281
|
+
return result;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
atomic(markerFile, { ...marker, state: "FAILED", reasonCode: error.code ?? "EVIDENCE_CHANGED" });
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function runPhaseInternal(phase, contractFile, configFile, root, evidence, verificationRunId) {
|
|
288
|
+
// Gradle canonicalizes the project root (for example /var -> /private/var
|
|
289
|
+
// on macOS). Normalize the root, while still rejecting symlinks inside it.
|
|
290
|
+
root = fs.realpathSync(root);
|
|
291
|
+
const contract = read(contractFile), config = read(configFile);
|
|
292
|
+
validateContract(contract, config);
|
|
293
|
+
const excluded = humanOwnedPaths(contract, evidence);
|
|
294
|
+
const head = git(root, ["rev-parse", "HEAD"]).trim();
|
|
295
|
+
const baselineFile = path.join(evidence, "baseline-inventory.json"), manifestFile = path.join(evidence, "test-manifest.json"), redFile = path.join(evidence, "red.json");
|
|
296
|
+
const baselineMeta = path.join(evidence, contract.schemaVersion >= 5 && phase === "baseline" ? "baseline-full.json" : "baseline.json");
|
|
297
|
+
const binding = { taskId: contract.id, contractSha256: fileHash(contractFile), configSha256: fileHash(configFile),
|
|
298
|
+
baselineHead: read(baselineMeta).head, baselineEvidenceSha256: fileHash(baselineMeta), collectorSha256: collectorHash(), excludedPathsSha256: digest(excluded) };
|
|
299
|
+
const checkBinding = value => requireThat(same(value.binding, binding), "EVIDENCE_CHANGED", "Contract, baseline, configuration or collector binding changed");
|
|
300
|
+
const inputEvidence = [];
|
|
301
|
+
const bindInput = file => { inputEvidence.push({ file, sha256: fileHash(file) }); };
|
|
302
|
+
let baseline, manifest, red, patterns;
|
|
303
|
+
if (phase !== "baseline") {
|
|
304
|
+
baseline = read(baselineFile); checkBinding(baseline); checkFiles(evidence, baseline.files);
|
|
305
|
+
checkEvaluation(evidence, baseline, "baseline");
|
|
306
|
+
requireThat(baseline.valid === true && baseline.phase === "baseline", "EVIDENCE_CHANGED", "A valid baseline inventory is required");
|
|
307
|
+
bindInput(baselineFile);
|
|
308
|
+
patterns = baseline.testPatterns;
|
|
309
|
+
if (phase === "green" || phase === "check") {
|
|
310
|
+
red = read(redFile); manifest = read(manifestFile); checkBinding(red); checkBinding(manifest);
|
|
311
|
+
requireThat(red.manifestSha256 === fileHash(manifestFile) && red.baselineInventorySha256 === fileHash(baselineFile) &&
|
|
312
|
+
red.preflightSha256 === fileHash(path.join(evidence, "test-preflight.json")), "EVIDENCE_CHANGED", "RED manifest, inventory or evaluation changed");
|
|
313
|
+
checkFiles(evidence, red.files);
|
|
314
|
+
checkEvaluation(evidence, manifest, "red");
|
|
315
|
+
requireThat(red.structuredCasesVerified === true && manifest.valid === true && manifest.phase === "red", "EVIDENCE_CHANGED", "Valid sealed RED evidence is required");
|
|
316
|
+
for (const file of [redFile, manifestFile, path.join(evidence, "test-preflight.json")]) bindInput(file);
|
|
317
|
+
requireThat(same(snapshot(root, config, patterns, excluded), manifest.testSnapshot), "EVIDENCE_CHANGED", "Test sources or resources changed after RED");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (phase === "baseline" || phase === "red") requireThat(head === binding.baselineHead, "EVIDENCE_CHANGED", "Execution HEAD changed before RED");
|
|
321
|
+
if (phase === "check") {
|
|
322
|
+
const green = read(path.join(evidence, "green-inventory.json")); checkBinding(green); checkFiles(evidence, green.files);
|
|
323
|
+
const latest = read(path.join(evidence, "green-verification.json"));
|
|
324
|
+
requireThat(latest.state === "PASSED" && latest.verificationRunId === green.verificationRunId &&
|
|
325
|
+
latest.greenSha256 === fileHash(path.join(evidence, "green-inventory.json")),
|
|
326
|
+
"EVIDENCE_CHANGED", "Latest GREEN failed, was interrupted or is not the sealed successful run; rerun GREEN");
|
|
327
|
+
checkEvaluation(evidence, green, "green");
|
|
328
|
+
requireThat(green.valid && green.redSha256 === fileHash(redFile) && green.manifestSha256 === fileHash(manifestFile), "EVIDENCE_CHANGED", "GREEN is not bound to the sealed RED and manifest");
|
|
329
|
+
requireThat(green.verifiedHead === head && green.worktreeSha256 === worktree(root, excluded), "EVIDENCE_CHANGED", "Repository inputs changed after GREEN; rerun verification");
|
|
330
|
+
return green;
|
|
331
|
+
}
|
|
332
|
+
requireThat(["baseline", "red", "green"].includes(phase), "EVIDENCE_CHANGED", "Unknown inventory phase");
|
|
333
|
+
if (phase === "baseline") requireThat(!fs.existsSync(baselineFile), "EVIDENCE_CHANGED", "Baseline inventory already exists");
|
|
334
|
+
if (phase === "red") requireThat(!fs.existsSync(redFile), "EVIDENCE_CHANGED", "RED already exists");
|
|
335
|
+
const attempts = path.join(evidence, "inventory-attempts"); fs.mkdirSync(attempts, { recursive: true });
|
|
336
|
+
const number = fs.readdirSync(attempts).filter(name => name.startsWith(`${phase}-`)).length + 1;
|
|
337
|
+
if (phase === "red") {
|
|
338
|
+
const environmentAttempts = contract.schemaVersion >= 7 ? fs.readdirSync(attempts).filter(name => {
|
|
339
|
+
const file = path.join(attempts, name, "evaluation.json");
|
|
340
|
+
return name.startsWith("red-") && fs.existsSync(file) && read(file).failure?.retryable === true;
|
|
341
|
+
}).length : 0;
|
|
342
|
+
requireThat(number - environmentAttempts <= contract.verification.maxPreparationFixes + 1, "PREPARATION_BUDGET", "Test preparation retry budget exhausted");
|
|
343
|
+
}
|
|
344
|
+
const attemptPath = `inventory-attempts/${phase}-${String(number).padStart(3, "0")}-${randomUUID()}`;
|
|
345
|
+
const attempt = path.join(evidence, attemptPath); fs.mkdirSync(attempt);
|
|
346
|
+
const startedAt = new Date().toISOString();
|
|
347
|
+
try {
|
|
348
|
+
if (phase === "baseline") {
|
|
349
|
+
const beforeDiscovery = worktree(root, excluded);
|
|
350
|
+
const discoveryFile = path.join(evidence, "baseline-discovery.json");
|
|
351
|
+
let discovered;
|
|
352
|
+
if (contract.schemaVersion >= 5 && fs.existsSync(discoveryFile)) {
|
|
353
|
+
discovered = read(discoveryFile); checkBinding(discovered); checkFiles(evidence, discovered.files);
|
|
354
|
+
requireThat(discovered.worktreeSha256 === beforeDiscovery && discovered.phase === "baseline-discovery",
|
|
355
|
+
"EVIDENCE_CHANGED", "Baseline discovery inputs changed");
|
|
356
|
+
} else {
|
|
357
|
+
discovered = runGradle(root, config, contract, phase, path.join(attempt, "discovery"), true);
|
|
358
|
+
requireThat(beforeDiscovery === worktree(root, excluded), "INPUT_CHANGED_DURING_RUN", "Discovery changed repository inputs");
|
|
359
|
+
if (contract.schemaVersion >= 5) atomic(discoveryFile, { ...discovered, phase: "baseline-discovery", binding,
|
|
360
|
+
worktreeSha256: beforeDiscovery, files: evidenceFiles(evidence, path.join(attempt, "discovery")) }, true);
|
|
361
|
+
}
|
|
362
|
+
requireThat(beforeDiscovery === worktree(root, excluded), "INPUT_CHANGED_DURING_RUN", "Discovery changed repository inputs");
|
|
363
|
+
patterns = testPatterns(root, config, discovered.tasks);
|
|
364
|
+
}
|
|
365
|
+
if (phase !== "green") unchangedProduction(root, contract, config, patterns, phase, evidence);
|
|
366
|
+
const before = snapshot(root, config, patterns, excluded), repositoryBefore = worktree(root, excluded);
|
|
367
|
+
const collection = runGradle(root, config, contract, phase, path.join(attempt, "collection"), false);
|
|
368
|
+
requireThat(same(patterns, testPatterns(root, config, collection.tasks)), "EVIDENCE_CHANGED", "Test source/resource discovery changed after baseline");
|
|
369
|
+
const after = snapshot(root, config, patterns, excluded);
|
|
370
|
+
requireThat(same(before, after) && repositoryBefore === worktree(root, excluded) && head === git(root, ["rev-parse", "HEAD"]).trim(),
|
|
371
|
+
"INPUT_CHANGED_DURING_RUN", "Repository or test inputs changed during Gradle execution");
|
|
372
|
+
if (phase !== "green") unchangedProduction(root, contract, config, patterns, phase, evidence);
|
|
373
|
+
requireThat(same(binding, { ...binding, contractSha256: fileHash(contractFile), configSha256: fileHash(configFile),
|
|
374
|
+
baselineEvidenceSha256: fileHash(baselineMeta), collectorSha256: collectorHash(), excludedPathsSha256: digest(humanOwnedPaths(contract, evidence)) }), "EVIDENCE_CHANGED", "Execution policy changed during collection");
|
|
375
|
+
for (const item of inputEvidence) requireThat(fileHash(item.file) === item.sha256, "EVIDENCE_CHANGED", `Evidence changed during collection: ${item.file}`);
|
|
376
|
+
if (baseline) checkFiles(evidence, baseline.files);
|
|
377
|
+
if (red) checkFiles(evidence, red.files);
|
|
378
|
+
const evaluation = phase === "baseline" ? evaluateBaseline(contract, collection) : evaluateCoverage(contract, baseline, collection, phase, manifest);
|
|
379
|
+
const report = { schemaVersion: 1, taskId: contract.id, phase, attempt: number, attemptPath, startedAt, finishedAt: new Date().toISOString(),
|
|
380
|
+
valid: true, reasonCode: phase === "red" ? "VALID_RED" : "VALID_INVENTORY", binding, ...evaluation, testPatterns: patterns, testSnapshot: after,
|
|
381
|
+
processExitCode: collection.processExitCode, verifiedHead: head, worktreeSha256: repositoryBefore,
|
|
382
|
+
queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID ?? null, ...(verificationRunId ? { verificationRunId } : {}) };
|
|
383
|
+
atomic(path.join(attempt, "evaluation.json"), report, true);
|
|
384
|
+
const files = evidenceFiles(evidence, attempt);
|
|
385
|
+
if (phase === "baseline") atomic(baselineFile, { ...report, files }, true);
|
|
386
|
+
if (phase === "red") {
|
|
387
|
+
atomic(path.join(evidence, "test-preflight.json"), report);
|
|
388
|
+
atomic(manifestFile, { ...report, baselineInventorySha256: fileHash(baselineFile) });
|
|
389
|
+
atomic(redFile, { schemaVersion: 4, taskId: contract.id, binding, attemptPath, files, structuredCasesVerified: true,
|
|
390
|
+
baselineInventorySha256: fileHash(baselineFile), manifestSha256: fileHash(manifestFile),
|
|
391
|
+
preflightSha256: fileHash(path.join(evidence, "test-preflight.json")), processExitCode: collection.processExitCode,
|
|
392
|
+
expectedFailureCount: evaluation.summary.expectedRed, exitCode: 1, exitCodeMeaning: "approved-case-failure" }, true);
|
|
393
|
+
}
|
|
394
|
+
if (phase === "green") atomic(path.join(evidence, "green-inventory.json"), { ...report, files, redSha256: fileHash(redFile), manifestSha256: fileHash(manifestFile) });
|
|
395
|
+
atomic(path.join(evidence, "inventory-status.json"), { ...report, nextAction: null });
|
|
396
|
+
return report;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const report = { taskId: contract.id, phase, attempt: number, attemptPath, startedAt, finishedAt: new Date().toISOString(), valid: false,
|
|
399
|
+
reasonCode: error.code ?? "EXECUTION_FAILURE", message: error.message, issues: error.issues ?? [],
|
|
400
|
+
...(error.failure ? { failure: error.failure } : {}),
|
|
401
|
+
nextAction: contract.schemaVersion >= 5 && phase === "baseline" ? "Inspect the sealed baseline recovery status; only approved classified retries may resume incomplete capture." : recovery(error.code, phase),
|
|
402
|
+
queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID ?? null };
|
|
403
|
+
atomic(path.join(attempt, "evaluation.json"), report);
|
|
404
|
+
if (phase === "red") atomic(path.join(evidence, "test-preflight.json"), report);
|
|
405
|
+
atomic(path.join(evidence, "inventory-status.json"), report);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
module.exports = { identity, validateCollection, evaluateBaseline, evaluateCoverage, snapshot, testPatterns, isTest, runPhase };
|
|
410
|
+
if (require.main === module) {
|
|
411
|
+
const [phase, contractFile, configFile, root, evidence] = process.argv.slice(2);
|
|
412
|
+
try {
|
|
413
|
+
if (phase === "is-test") {
|
|
414
|
+
const baseline = read(contractFile), config = read(configFile);
|
|
415
|
+
process.exitCode = isTest(config, baseline.testPatterns, evidence) ? 0 : 1;
|
|
416
|
+
} else {
|
|
417
|
+
const result = runPhase(phase, contractFile, configFile, root, evidence);
|
|
418
|
+
console.log(`Inventory ${phase}: ${result.reasonCode}; ${JSON.stringify(result.summary)}`);
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const reasonCode = error instanceof InventoryError ? error.code : "EVIDENCE_CHANGED";
|
|
422
|
+
console.error(`${reasonCode}: ${error.message}\n${recovery(reasonCode, phase)}`);
|
|
423
|
+
if (evidence && phase !== "is-test") {
|
|
424
|
+
const statusFile = path.join(evidence, "inventory-status.json");
|
|
425
|
+
let previous = null;
|
|
426
|
+
try { if (fs.existsSync(statusFile)) previous = read(statusFile); } catch { /* Replace invalid status, never sealed evidence. */ }
|
|
427
|
+
const queueRunId = process.env.AUTOMATION_QUEUE_RUN_ID ?? null;
|
|
428
|
+
if (!previous || previous.valid || previous.message !== error.message || previous.phase !== phase || previous.queueRunId !== queueRunId)
|
|
429
|
+
atomic(statusFile, { phase, queueRunId, valid: false, reasonCode, message: error.message, issues: error.issues ?? [], nextAction: recovery(reasonCode, phase), finishedAt: new Date().toISOString() });
|
|
430
|
+
}
|
|
431
|
+
process.exitCode = 1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Shared validation for discovery, installation, doctor and managed Shell.
|
|
3
|
+
const { lstatSync } = require("node:fs");
|
|
4
|
+
const { dirname, isAbsolute, resolve } = require("node:path");
|
|
5
|
+
exports.validateProjectCapabilities = validateProjectCapabilities;
|
|
6
|
+
const fail = (message) => { throw new Error(`Invalid Android capability model: ${message}`); };
|
|
7
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
8
|
+
const modulePath = (v) => typeof v === "string" && /^:(?:[A-Za-z0-9_.-]+(?::[A-Za-z0-9_.-]+)*)?$/.test(v);
|
|
9
|
+
const types = ["application", "library", "dynamic-feature", "test", "asset-pack", "jvm-library"];
|
|
10
|
+
function keys(value, expected) {
|
|
11
|
+
if (!object(value) || Object.keys(value).length !== expected.length || !expected.every(k => Object.hasOwn(value, k)))
|
|
12
|
+
fail("unexpected fields");
|
|
13
|
+
}
|
|
14
|
+
function safePath(root, value, pattern = false) {
|
|
15
|
+
if (typeof value !== "string" || !value || isAbsolute(value) || !/^[A-Za-z0-9._/*-]+$/.test(value))
|
|
16
|
+
fail("unsafe repository path");
|
|
17
|
+
const text = value;
|
|
18
|
+
const bare = pattern && text.endsWith("/**") ? text.slice(0, -3) : text;
|
|
19
|
+
if (bare.includes("*") || (bare !== "." && bare.split("/").some(p => !p || p === "." || p === ".." || [".git", ".gradle", ".automation-plugin", ".opencode"].includes(p))))
|
|
20
|
+
fail("unsafe repository path");
|
|
21
|
+
let path = resolve(root, bare);
|
|
22
|
+
for (; path !== resolve(root); path = dirname(path)) {
|
|
23
|
+
try {
|
|
24
|
+
if (lstatSync(path).isSymbolicLink()) fail("symbolic-link source or module path");
|
|
25
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
26
|
+
if (dirname(path) === path)
|
|
27
|
+
fail("path outside repository");
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
const under = (parent, child) => parent === "." || child === parent || child.startsWith(`${parent}/`);
|
|
32
|
+
const base = (p) => p.endsWith("/**") ? p.slice(0, -3) : p;
|
|
33
|
+
const overlap = (a, b) => a === b || (a.endsWith("/**") && under(base(a), base(b))) || (b.endsWith("/**") && under(base(b), base(a)));
|
|
34
|
+
/** Validate persisted portable snapshots independently of Gradle output parsing. */
|
|
35
|
+
function validateProjectCapabilities(value, root) {
|
|
36
|
+
keys(value, ["version", "buildRoot", "modules"]);
|
|
37
|
+
if (value.version !== 1 || !Array.isArray(value.modules) || !value.modules.length)
|
|
38
|
+
fail("unsupported or empty model");
|
|
39
|
+
const buildRoot = safePath(root, value.buildRoot);
|
|
40
|
+
const modules = value.modules;
|
|
41
|
+
const ids = new Set();
|
|
42
|
+
for (const raw of modules) {
|
|
43
|
+
keys(raw, ["gradlePath", "directory", "buildFile", "type", "namespace", "applicationId", "dependencies", "sources", "tasks"]);
|
|
44
|
+
if (!modulePath(raw.gradlePath) || ids.has(raw.gradlePath))
|
|
45
|
+
fail("duplicate or invalid module identity");
|
|
46
|
+
ids.add(raw.gradlePath);
|
|
47
|
+
if (!types.includes(raw.type))
|
|
48
|
+
fail("unsupported module type");
|
|
49
|
+
for (const name of ["namespace", "applicationId"])
|
|
50
|
+
if (raw[name] !== null && (typeof raw[name] !== "string" || !raw[name]))
|
|
51
|
+
fail("invalid module identifier");
|
|
52
|
+
const directory = safePath(root, raw.directory), buildFile = safePath(root, raw.buildFile);
|
|
53
|
+
if (!under(buildRoot, directory) || !under(buildRoot, buildFile))
|
|
54
|
+
fail("module outside selected build");
|
|
55
|
+
if (!Array.isArray(raw.dependencies) || !raw.dependencies.every(modulePath) || new Set(raw.dependencies).size !== raw.dependencies.length)
|
|
56
|
+
fail("invalid dependencies");
|
|
57
|
+
if (!Array.isArray(raw.sources) || !Array.isArray(raw.tasks))
|
|
58
|
+
fail("missing sources or tasks");
|
|
59
|
+
const names = new Set();
|
|
60
|
+
for (const source of raw.sources) {
|
|
61
|
+
keys(source, ["name", "kind", "paths"]);
|
|
62
|
+
if (typeof source.name !== "string" || !/^[A-Za-z0-9_.-]+$/.test(source.name) || names.has(source.name))
|
|
63
|
+
fail("invalid source set");
|
|
64
|
+
names.add(source.name);
|
|
65
|
+
if (!["production", "test"].includes(source.kind) || !Array.isArray(source.paths))
|
|
66
|
+
fail("invalid source classification");
|
|
67
|
+
for (const p of source.paths) {
|
|
68
|
+
const path = safePath(root, p, true);
|
|
69
|
+
if (!under(buildRoot, base(path)) || base(path) === buildRoot || /(^|\/)(build|automation|scripts)(\/|$)/.test(path))
|
|
70
|
+
fail("generated or protected source root");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const tasks = new Set();
|
|
74
|
+
for (const task of raw.tasks) {
|
|
75
|
+
keys(task, ["path", "kind", "variant"]);
|
|
76
|
+
const prefix = raw.gradlePath === ":" ? ":" : `${raw.gradlePath}:`;
|
|
77
|
+
if (typeof task.path !== "string" || !task.path.startsWith(prefix) || !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(task.path.slice(prefix.length)) || tasks.has(task.path))
|
|
78
|
+
fail("invalid task identity");
|
|
79
|
+
tasks.add(task.path);
|
|
80
|
+
if (!["unit", "assemble", "lint", "device"].includes(task.kind) || (task.variant !== null && (typeof task.variant !== "string" || !/^[A-Za-z0-9]+$/.test(task.variant))))
|
|
81
|
+
fail("invalid task capability");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const model = value;
|
|
85
|
+
const reached = new Set(model.modules.filter(m => m.type !== "jvm-library").map(m => m.gradlePath));
|
|
86
|
+
if (!reached.size)
|
|
87
|
+
fail("a standalone JVM build is unsupported");
|
|
88
|
+
for (let changed = true; changed;) {
|
|
89
|
+
changed = false;
|
|
90
|
+
for (const m of model.modules) {
|
|
91
|
+
for (const dep of m.dependencies) {
|
|
92
|
+
if (!ids.has(dep))
|
|
93
|
+
fail("unresolved project dependency");
|
|
94
|
+
if (reached.has(m.gradlePath) && !reached.has(dep)) {
|
|
95
|
+
reached.add(dep);
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (reached.size !== model.modules.length)
|
|
102
|
+
fail("unrelated JVM module");
|
|
103
|
+
const production = model.modules.flatMap(m => m.sources.filter(s => s.kind === "production").flatMap(s => s.paths));
|
|
104
|
+
const tests = model.modules.flatMap(m => m.sources.filter(s => s.kind === "test").flatMap(s => s.paths));
|
|
105
|
+
if (production.some(p => tests.some(t => overlap(p, t))))
|
|
106
|
+
fail("production and test roots overlap");
|
|
107
|
+
const protectedFiles = model.modules.map(m => m.buildFile);
|
|
108
|
+
if ([...production, ...tests].some(p => protectedFiles.some(f => overlap(p, f))))
|
|
109
|
+
fail("source roots contain build configuration");
|
|
110
|
+
return model;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
exports.safePath = safePath;
|
|
114
|
+
|
|
115
|
+
function buildProtectedPaths(buildRoot) {
|
|
116
|
+
const prefix = buildRoot === "." ? "" : buildRoot + "/";
|
|
117
|
+
return ["gradle/**", "gradlew", "gradlew.bat", "settings.gradle", "settings.gradle.kts",
|
|
118
|
+
"build.gradle", "build.gradle.kts", "gradle.properties", "local.properties", "buildSrc/**", "build-logic/**"]
|
|
119
|
+
.map(p => prefix + p);
|
|
120
|
+
}
|
|
121
|
+
exports.buildProtectedPaths = buildProtectedPaths;
|
|
122
|
+
|
|
123
|
+
// The argument is always a repository/worktree root. Resolve the same portable
|
|
124
|
+
// build selection inside that checkout; never retain an absolute source path.
|
|
125
|
+
function gradleBuildRoot(config, root) {
|
|
126
|
+
validateConfigurationModel(config, root);
|
|
127
|
+
return resolve(root, config.androidProject?.capabilities?.buildRoot ?? ".");
|
|
128
|
+
}
|
|
129
|
+
exports.gradleBuildRoot = gradleBuildRoot;
|
|
130
|
+
|
|
131
|
+
function validateConfigurationModel(config, root) {
|
|
132
|
+
if (config.androidProject?.capabilities === undefined) return;
|
|
133
|
+
const model = validateProjectCapabilities(config.androidProject.capabilities, root);
|
|
134
|
+
const prefix = model.buildRoot === "." ? "" : model.buildRoot + "/";
|
|
135
|
+
if (![prefix + "settings.gradle", prefix + "settings.gradle.kts"].includes(config.androidProject.settingsFile))
|
|
136
|
+
fail("settings file differs from selected build");
|
|
137
|
+
// Legacy root snapshots retain their original policy. Nested builds require
|
|
138
|
+
// protection of their wrapper, settings, local environment and build logic.
|
|
139
|
+
if (model.buildRoot !== "." && buildProtectedPaths(model.buildRoot).some(p => !config.protectedPaths.includes(p)))
|
|
140
|
+
fail("selected build configuration is not protected");
|
|
141
|
+
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
142
|
+
const paths = kind => [...new Set(model.modules.flatMap(m => m.sources.filter(s => s.kind === kind).flatMap(s => s.paths)))];
|
|
143
|
+
for (const [key, kind] of [["productionPaths", "production"], ["testPaths", "test"]])
|
|
144
|
+
if (!equal(config.androidProject[key], paths(kind))) fail("source paths differ from capability snapshot");
|
|
145
|
+
const modules = model.modules.map(m => ({gradlePath:m.gradlePath, directory:m.directory, buildFile:m.buildFile,
|
|
146
|
+
dsl:m.buildFile.endsWith(".kts") ? "kotlin" : "groovy", type:m.type, namespace:m.namespace, applicationId:m.applicationId}));
|
|
147
|
+
if (!equal(config.androidProject.modules, modules)) fail("module identities differ from capability snapshot");
|
|
148
|
+
const groups = {fullUnitTestTasks:"unit", focusedTestTasks:"unit", assembleTasks:"assemble", lintTasks:"lint", deviceTestTasks:"device"};
|
|
149
|
+
for (const [group, kind] of Object.entries(groups)) {
|
|
150
|
+
const allowed = new Set(model.modules.flatMap(m => m.tasks.filter(t => t.kind === kind).map(t => t.path)));
|
|
151
|
+
if (!config.gradleVerification[group].every(t => allowed.has(t))) fail("verification task was not discovered: " + group);
|
|
152
|
+
}
|
|
153
|
+
for (const m of model.modules) {
|
|
154
|
+
const required = m.tasks.filter(t => t.kind === "unit");
|
|
155
|
+
if (required.some(t => !config.gradleVerification.fullUnitTestTasks.includes(t.path)))
|
|
156
|
+
fail("full verification omits module: " + m.gradlePath);
|
|
157
|
+
const builds = m.tasks.filter(t => t.kind === "assemble");
|
|
158
|
+
if (!builds.length || builds.some(t => !config.gradleVerification.assembleTasks.includes(t.path)))
|
|
159
|
+
fail("build verification omits module: " + m.gradlePath);
|
|
160
|
+
}
|
|
161
|
+
const sources = [...paths("production"), ...paths("test")];
|
|
162
|
+
if (sources.some(p => config.protectedPaths.some(f => overlap(p, f.endsWith("/") ? f + "**" : f)))) fail("source paths overlap protected configuration");
|
|
163
|
+
}
|
|
164
|
+
exports.validateConfigurationModel = validateConfigurationModel;
|
|
165
|
+
if (require.main === module) {
|
|
166
|
+
try {
|
|
167
|
+
const config = JSON.parse(require("node:fs").readFileSync(process.argv[2], "utf8"));
|
|
168
|
+
const root = gradleBuildRoot(config, process.argv[3]);
|
|
169
|
+
if (process.argv[4] === "--build-root") console.log(root);
|
|
170
|
+
}
|
|
171
|
+
catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
172
|
+
}
|