@frankzhang2026/opencode-android-orchestrator 1.1.0 → 1.2.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +12 -8
  3. package/dist/doctor/index.d.ts.map +1 -1
  4. package/dist/doctor/index.js +13 -0
  5. package/dist/doctor/index.js.map +1 -1
  6. package/dist/doctor/installation.d.ts +1 -1
  7. package/dist/doctor/installation.d.ts.map +1 -1
  8. package/dist/doctor/installation.js +2 -1
  9. package/dist/doctor/installation.js.map +1 -1
  10. package/dist/installer/adaptive-templates.d.ts.map +1 -1
  11. package/dist/installer/adaptive-templates.js +4 -0
  12. package/dist/installer/adaptive-templates.js.map +1 -1
  13. package/dist/installer/capabilities-script.d.ts.map +1 -1
  14. package/dist/installer/capabilities-script.js +13 -3
  15. package/dist/installer/capabilities-script.js.map +1 -1
  16. package/dist/installer/init.js +1 -1
  17. package/dist/installer/init.js.map +1 -1
  18. package/dist/installer/opencode-config.d.ts +3 -3
  19. package/dist/installer/opencode-config.js +1 -1
  20. package/dist/installer/upgrade.js +1 -1
  21. package/dist/installer/upgrade.js.map +1 -1
  22. package/dist/queue/executor.d.ts.map +1 -1
  23. package/dist/queue/executor.js +5 -0
  24. package/dist/queue/executor.js.map +1 -1
  25. package/dist/queue/queue.js +1 -1
  26. package/dist/queue/queue.js.map +1 -1
  27. package/docs/GRADLE-6.7.1.md +113 -0
  28. package/docs/MIGRATION.md +36 -14
  29. package/docs/QUEUE.md +3 -3
  30. package/docs/SECURITY.md +1 -1
  31. package/docs/TROUBLESHOOTING.md +7 -8
  32. package/package.json +1 -1
  33. package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +8 -1
  34. package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +6 -2
  35. package/templates/automation/task-contract.schema.json +88 -2
  36. package/templates/automation/tasks/TASK-TEMPLATE.json.example +5 -4
  37. package/templates/automation/verification/collect.init.gradle +60 -36
  38. package/templates/automation/verification/contract.cjs +28 -2
  39. package/templates/automation/verification/inventory.cjs +113 -10
  40. package/templates/automation/verification/project.cjs +58 -0
  41. package/templates/automation/verification/recovery.cjs +2 -3
  42. package/templates/automation/verification/runtime.init.gradle +58 -0
  43. package/templates/scripts/automation/acceptance-report.sh +2 -2
  44. package/templates/scripts/automation/status.sh +3 -0
@@ -32,8 +32,43 @@
32
32
  "minLength": 3
33
33
  },
34
34
  "origin": {
35
- "type": "string",
36
- "minLength": 12
35
+ "oneOf": [
36
+ {
37
+ "type": "string",
38
+ "minLength": 12
39
+ },
40
+ {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "required": [
44
+ "className",
45
+ "methodName"
46
+ ],
47
+ "properties": {
48
+ "className": {
49
+ "type": "string",
50
+ "pattern": "^[A-Za-z_$][A-Za-z0-9_.$]*$"
51
+ },
52
+ "methodName": {
53
+ "type": "string",
54
+ "minLength": 1
55
+ },
56
+ "fileName": {
57
+ "type": "string",
58
+ "minLength": 1
59
+ },
60
+ "lineNumber": {
61
+ "type": "integer",
62
+ "minimum": 1
63
+ }
64
+ },
65
+ "dependentRequired": {
66
+ "lineNumber": [
67
+ "fileName"
68
+ ]
69
+ }
70
+ }
71
+ ]
37
72
  }
38
73
  }
39
74
  },
@@ -363,6 +398,57 @@
363
398
  ]
364
399
  }
365
400
  }
401
+ },
402
+ "criteriaEvidence": {
403
+ "type": "array",
404
+ "items": {
405
+ "type": "object",
406
+ "additionalProperties": false,
407
+ "required": [
408
+ "criterion",
409
+ "kind",
410
+ "references"
411
+ ],
412
+ "properties": {
413
+ "criterion": {
414
+ "type": "integer",
415
+ "minimum": 1
416
+ },
417
+ "kind": {
418
+ "enum": [
419
+ "behavior",
420
+ "build",
421
+ "lint",
422
+ "device"
423
+ ]
424
+ },
425
+ "references": {
426
+ "type": "array",
427
+ "minItems": 1,
428
+ "uniqueItems": true,
429
+ "items": {
430
+ "type": "string",
431
+ "minLength": 1
432
+ }
433
+ }
434
+ }
435
+ }
436
+ },
437
+ "supplementalTests": {
438
+ "type": "object",
439
+ "additionalProperties": false,
440
+ "required": [
441
+ "mode",
442
+ "maxRevisions"
443
+ ],
444
+ "properties": {
445
+ "mode": {
446
+ "const": "baselinePassingNewFiles"
447
+ },
448
+ "maxRevisions": {
449
+ "const": 1
450
+ }
451
+ }
366
452
  }
367
453
  }
368
454
  },
@@ -37,9 +37,7 @@
37
37
  "android-orchestrator-verification-before-completion"
38
38
  ],
39
39
  "acceptanceCriteria": [
40
- "Replace with an observable input/output or UI behavior",
41
- "A focused test demonstrates the behavior and is captured RED before implementation",
42
- "Debug build passes; unit tests and Android lint also pass when their configuration flags are enabled"
40
+ "Replace with an observable input/output or UI behavior"
43
41
  ],
44
42
  "nonGoals": [
45
43
  "Do not refactor unrelated screens",
@@ -72,7 +70,10 @@
72
70
  },
73
71
  "expectedFailure": {
74
72
  "type": "java.lang.AssertionError",
75
- "origin": "The assertion at the approved behavior call"
73
+ "origin": {
74
+ "className": "ReplaceWithFocusedTest",
75
+ "methodName": "replace with observable behavior"
76
+ }
76
77
  }
77
78
  }
78
79
  ],
@@ -6,7 +6,17 @@ import org.gradle.api.tasks.testing.TestListener
6
6
  import org.gradle.api.tasks.testing.TestResult
7
7
 
8
8
  // All three phases use this collector. Never infer execution from existing XML.
9
- def request = new JsonSlurper().parse(new File(System.getProperty('orchestrator.inventoryRequest')))
9
+ // Gradle 6.7.1 does not expose command-line -D properties through System yet
10
+ // while evaluating init scripts in a (including single-use) daemon.
11
+ def requestPath = gradle.startParameter.systemPropertiesArgs['orchestrator.inventoryRequest'] ?: System.getProperty('orchestrator.inventoryRequest')
12
+ if (!(requestPath instanceof String) || requestPath.trim().isEmpty()) {
13
+ throw new GradleException('Missing orchestrator.inventoryRequest: pass an absolute request JSON path with -D')
14
+ }
15
+ def requestFile = new File(requestPath)
16
+ if (!requestFile.isAbsolute() || !requestFile.isFile() || !requestFile.canRead()) {
17
+ throw new GradleException('Invalid orchestrator.inventoryRequest: an existing readable absolute file is required')
18
+ }
19
+ def request = new JsonSlurper().parse(requestFile)
10
20
  def outputFile = new File(request.output as String)
11
21
  def append = { Map value ->
12
22
  synchronized (outputFile) {
@@ -17,46 +27,56 @@ append([kind: 'start', phase: request.phase, gradleVersion: gradle.gradleVersion
17
27
  def filtersByTask = request.targets.groupBy { it.gradleTask }.collectEntries { path, targets ->
18
28
  [(path): targets.collect { it.filter }.unique().sort()]
19
29
  }
20
- gradle.taskGraph.whenReady { graph ->
21
- filtersByTask.each { path, filters ->
22
- def task = graph.allTasks.find { it.path == path }
23
- if (!(task instanceof Test)) throw new GradleException("Inventory target must resolve to an exact Test task: ${path}")
24
- def roots = [] as Set
25
- def classes = task.testClassesDirs.files.collect { it.canonicalFile } as Set
26
- def sourceSets = task.project.extensions.findByName('sourceSets')
27
- sourceSets?.each { sourceSet ->
28
- if (sourceSet.output.classesDirs.files.any { classes.contains(it.canonicalFile) }) {
29
- roots.addAll(sourceSet.allSource.srcDirs)
30
- roots.addAll(sourceSet.resources.srcDirs)
31
- }
30
+ // Keep dry-run source discovery independent of Android output providers.
31
+ // Real output metadata is collected after producer tasks have executed.
32
+ def recordTask = { task, boolean discovery ->
33
+ def path = task.path
34
+ def filters = filtersByTask[path]
35
+ def roots = [] as Set
36
+ def android = task.project.extensions.findByName('android')
37
+ // Dry-run discovery cannot execute producer tasks. Android source roots
38
+ // come from its DSL; class-output paths are recorded on the real run.
39
+ def classes = discovery && android != null ? ([] as Set) :
40
+ (task.testClassesDirs.files.collect { it.canonicalFile } as Set)
41
+ def sourceSets = android == null ? task.project.extensions.findByName('sourceSets') : null
42
+ sourceSets?.each { sourceSet ->
43
+ if (sourceSet.output.classesDirs.files.any { classes.contains(it.canonicalFile) }) {
44
+ roots.addAll(sourceSet.allSource.srcDirs)
45
+ roots.addAll(sourceSet.resources.srcDirs)
32
46
  }
33
- // AGP variants expose the source providers actually used by each unit test.
34
- def android = task.project.extensions.findByName('android')
35
- if (android != null && android.hasProperty('unitTestVariants')) {
36
- android.unitTestVariants.each { variant ->
37
- if (task.name == "test${variant.name.capitalize()}") {
38
- variant.sourceSets.each { provider ->
39
- roots.addAll(provider.javaDirectories)
40
- roots.addAll(provider.resourcesDirectories)
41
- if (provider.hasProperty('kotlinDirectories')) roots.addAll(provider.kotlinDirectories)
42
- }
47
+ }
48
+ // AGP variants expose the source providers actually used by each unit test.
49
+ if (android != null && android.hasProperty('unitTestVariants')) {
50
+ android.unitTestVariants.each { variant ->
51
+ if (task.name == "test${variant.name.capitalize()}") {
52
+ variant.sourceSets.each { provider ->
53
+ roots.addAll(provider.javaDirectories)
54
+ roots.addAll(provider.resourcesDirectories)
55
+ if (provider.hasProperty('kotlinDirectories')) roots.addAll(provider.kotlinDirectories)
43
56
  }
44
57
  }
45
58
  }
46
- // Modern AGP also exposes variant-specific test source sets through its DSL.
47
- // Only unit-test providers are classified here, never production providers.
48
- if (android != null && android.hasProperty('sourceSets')) {
49
- android.sourceSets.findAll { it.name == 'test' || it.name.startsWith('test') && it.name.length() > 4 && Character.isUpperCase(it.name.charAt(4)) }.each { sourceSet ->
50
- roots.addAll(sourceSet.java.srcDirs)
51
- roots.addAll(sourceSet.resources.srcDirs)
52
- if (sourceSet.hasProperty('kotlin')) roots.addAll(sourceSet.kotlin.srcDirs)
53
- roots.addAll(sourceSet.assets.srcDirs)
54
- roots.addAll(sourceSet.res.srcDirs)
55
- }
59
+ }
60
+ // Modern AGP also exposes variant-specific test source sets through its DSL.
61
+ // Only unit-test providers are classified here, never production providers.
62
+ if (android != null && android.hasProperty('sourceSets')) {
63
+ android.sourceSets.findAll { it.name == 'test' || it.name.startsWith('test') && it.name.length() > 4 && Character.isUpperCase(it.name.charAt(4)) }.each { sourceSet ->
64
+ roots.addAll(sourceSet.java.srcDirs)
65
+ roots.addAll(sourceSet.resources.srcDirs)
66
+ if (sourceSet.hasProperty('kotlin')) roots.addAll(sourceSet.kotlin.srcDirs)
67
+ roots.addAll(sourceSet.assets.srcDirs)
68
+ roots.addAll(sourceSet.res.srcDirs)
56
69
  }
57
- append([kind: 'task', taskPath: path, filters: filters,
58
- sourceRoots: roots.collect { it.absoluteFile.toPath().normalize().toString() }.sort(),
59
- testClassesDirs: classes.collect { it.toString() }.sort()])
70
+ }
71
+ append([kind: 'task', taskPath: path, filters: filters,
72
+ sourceRoots: roots.collect { it.absoluteFile.toPath().normalize().toString() }.sort(),
73
+ testClassesDirs: classes.collect { it.toString() }.sort()])
74
+ }
75
+ gradle.taskGraph.whenReady { graph ->
76
+ filtersByTask.each { path, filters ->
77
+ def task = graph.allTasks.find { it.path == path }
78
+ if (!(task instanceof Test)) throw new GradleException("Inventory target must resolve to an exact Test task: ${path}")
79
+ if (gradle.startParameter.dryRun) recordTask(task, true)
60
80
  task.outputs.upToDateWhen { false }
61
81
  task.outputs.doNotCacheIf('Orchestrator inventory requires actual test execution') { true }
62
82
  task.ignoreFailures = true
@@ -87,6 +107,10 @@ gradle.taskGraph.whenReady { graph ->
87
107
  })
88
108
  }
89
109
  }
110
+ // beforeTask also records skipped/NO-SOURCE tasks for inventory validation.
111
+ gradle.taskGraph.beforeTask { task ->
112
+ if (filtersByTask.containsKey(task.path)) recordTask(task, false)
113
+ }
90
114
  gradle.taskGraph.afterTask { task, state ->
91
115
  if (filtersByTask.containsKey(task.path)) append([kind: 'taskEnd', taskPath: task.path,
92
116
  executed: state.executed, skipped: state.skipped, noSource: state.noSource,
@@ -59,7 +59,10 @@ function validateContract(contract, config) {
59
59
  assert(!targets.has(key), "Focused test targets must be unique"); targets.add(key);
60
60
  }
61
61
  const verification = contract.verification;
62
- assert(fields(verification, ["version", "maxPreparationFixes", "cases", "inventory"]) && verification.version === 2, "Inventory verification requires verification version 2 with explicit inventory policy");
62
+ assert(fields(verification, ["version", "maxPreparationFixes", "cases", "inventory"], ["criteriaEvidence", "supplementalTests"]) && verification.version === 2, "Inventory verification requires verification version 2 with explicit inventory policy");
63
+ if (Object.hasOwn(verification, "supplementalTests")) assert(fields(verification.supplementalTests, ["mode", "maxRevisions"]) &&
64
+ verification.supplementalTests.mode === "baselinePassingNewFiles" && verification.supplementalTests.maxRevisions === 1,
65
+ "Supplemental tests require explicit baselinePassingNewFiles authorization and one revision");
63
66
  assert(Number.isInteger(verification.maxPreparationFixes) && verification.maxPreparationFixes >= 0 && verification.maxPreparationFixes <= 1, "Invalid preparation retry budget");
64
67
  const policy = verification.inventory;
65
68
  assert(fields(policy, ["mode", "existingSkips", "emptyBaseline"]) && policy.mode === "focusedBaseline" &&
@@ -79,11 +82,34 @@ function validateContract(contract, config) {
79
82
  else assert(item.intent === "observe" && item.before === "observe" && ["userRequirement", "existingTest", "baselineCapture", "measuredFact"].includes(item.source), "Invalid observed behavior");
80
83
  if (Object.hasOwn(item, "expectedFailure")) {
81
84
  const failure = item.expectedFailure;
82
- assert(fields(failure, ["type", "origin"], ["messageIncludes"]) && text(failure.type) && text(failure.origin) && failure.origin.length >= 12 &&
85
+ const origin = failure.origin;
86
+ const structuredOrigin = fields(origin, ["className", "methodName"], ["fileName", "lineNumber"]) &&
87
+ text(origin.className) && /^[A-Za-z_$][A-Za-z0-9_.$]*$/.test(origin.className) && text(origin.methodName) &&
88
+ !/[().]/.test(origin.methodName) && (!Object.hasOwn(origin, "fileName") || text(origin.fileName) && !/[\\/]/.test(origin.fileName)) &&
89
+ (!Object.hasOwn(origin, "lineNumber") || Object.hasOwn(origin, "fileName") && Number.isSafeInteger(origin.lineNumber) && origin.lineNumber > 0);
90
+ assert(fields(failure, ["type", "origin"], ["messageIncludes"]) && text(failure.type) &&
91
+ (structuredOrigin || text(origin) && origin.length >= 12) &&
83
92
  (!Object.hasOwn(failure, "messageIncludes") || (text(failure.messageIncludes) && failure.messageIncludes.length >= 3)), "Invalid expected failure");
84
93
  }
85
94
  }
86
95
  assert(verification.cases.some(item => item.intent === "change"), "At least one changed behavior must provide genuine RED");
96
+ const covered = new Set(verification.cases.map(item => item.criterion));
97
+ if (Object.hasOwn(verification, "criteriaEvidence")) {
98
+ assert(Array.isArray(verification.criteriaEvidence), "Invalid criterion evidence mapping");
99
+ const explicit = new Set();
100
+ for (const evidence of verification.criteriaEvidence) {
101
+ assert(fields(evidence, ["criterion", "kind", "references"]) && Number.isInteger(evidence.criterion) &&
102
+ evidence.criterion >= 1 && evidence.criterion <= contract.acceptanceCriteria.length && !explicit.has(evidence.criterion), "Invalid or duplicate criterion evidence");
103
+ explicit.add(evidence.criterion);
104
+ assert(Array.isArray(evidence.references) && evidence.references.length > 0 && new Set(evidence.references).size === evidence.references.length, "Criterion evidence requires unique references");
105
+ const available = evidence.kind === "behavior" ? [...ids] : evidence.kind === "build" ? config.gradleVerification.assembleTasks :
106
+ evidence.kind === "lint" && config.lintEnabled === true ? config.gradleVerification.lintTasks :
107
+ evidence.kind === "device" && contract.deviceTestsRequired ? config.gradleVerification.deviceTestTasks : undefined;
108
+ assert(Array.isArray(available) && evidence.references.every(ref => available.includes(ref)), "Criterion evidence must reference approved behavior cases or mandatory enabled verification tasks");
109
+ covered.add(evidence.criterion);
110
+ }
111
+ }
112
+ assert(contract.acceptanceCriteria.every((_, index) => covered.has(index + 1)), "Every acceptance criterion requires an explicit behavior or verification evidence mapping");
87
113
  assert(!/replace with|TASK-EXAMPLE|\btodo\b|\btbd\b|placeholder/i.test(JSON.stringify(contract)), "Contract contains placeholders");
88
114
  }
89
115
  module.exports = { validateContract, identity, matchesPath, assert };
@@ -104,6 +104,20 @@ function evaluateBaseline(contract, collection) {
104
104
  requireThat(policy.emptyBaseline === "allow" || empty.length === 0, "EMPTY_BASELINE", "One or more focused tasks have an empty baseline", empty);
105
105
  return { cases, summary: { existing: cases.length, passed: cases.length - skipped.length, skipped: skipped.length, emptyTasks: empty.length } };
106
106
  }
107
+ function matchesFailureOrigin(expected, actual, test) {
108
+ if (!Array.isArray(actual.stack) || !actual.stack.length) return false;
109
+ const frames = actual.stack.map(frame => typeof frame === "string" &&
110
+ /^(?:[^/]+\/)?(.+)\.([^.(]+)\(([^():]+)(?::([0-9]+))?\)$/.exec(frame));
111
+ if (frames.some(frame => !frame)) return false;
112
+ const methodName = test.name.replace(/\[[^\]]*\]$/, "").replace(/\([^)]*\)$/, "");
113
+ // Before/after hooks, rules and initialization failures do not include the
114
+ // test body's invocation. Merely sharing an exception/message is not RED.
115
+ if (!frames.some(frame => frame[1] === test.className && frame[2] === methodName)) return false;
116
+ const origin = typeof expected.origin === "object" ? expected.origin : { className: test.className, methodName };
117
+ const first = frames.find(frame => !/^(?:org\.junit\.(?:Assert|ComparisonFailure)$|org\.junit\.jupiter\.api\.(?:Assertions|AssertionUtils|Assert\w+)$|org\.opentest4j\.|kotlin\.test\.|org\.hamcrest\.MatcherAssert$|org\.assertj\.core\.)/.test(frame[1]));
118
+ return Boolean(first && first[1] === origin.className && first[2] === origin.methodName &&
119
+ (!origin.fileName || first[3] === origin.fileName) && (!origin.lineNumber || Number(first[4]) === origin.lineNumber));
120
+ }
107
121
  function evaluateCoverage(contract, baseline, collection, phase, manifest) {
108
122
  const actual = new Map(collection.cases.map(item => [identity(item), item]));
109
123
  const expected = new Map(baseline.cases.map(item => [identity(item), { ...item, classification: "regression", id: identity(item) }]));
@@ -127,7 +141,8 @@ function evaluateCoverage(contract, baseline, collection, phase, manifest) {
127
141
  } else {
128
142
  const failure = item.expectedFailure;
129
143
  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));
144
+ result.failures[0].type === failure.type && (!failure.messageIncludes || result.failures[0].message.includes(failure.messageIncludes)) &&
145
+ matchesFailureOrigin(failure, result.failures[0], item);
131
146
  valid = item.before === "pass" ? result.result === "SUCCESS" : item.before === "fail" ? matched : result.result === "SUCCESS" || matched;
132
147
  }
133
148
  const entry = { id: item.id, taskPath: item.taskPath, className: item.className, name: item.name,
@@ -213,7 +228,7 @@ function unchangedProduction(root, contract, config, patterns, phase, evidence)
213
228
  const invalid = changed.filter(file => !ignored.has(file) && (phase === "baseline" || !isTest(config, patterns, file)));
214
229
  requireThat(invalid.length === 0, "EVIDENCE_CHANGED", "Baseline/RED requires unchanged production and configuration", invalid.map(file => ({ path: file })));
215
230
  }
216
- function collectorHash() { return digest(["contract.cjs", "inventory.cjs", "collect.init.gradle"].map(file => [file, fileHash(path.join(__dirname, file))])); }
231
+ function collectorHash() { return digest(["contract.cjs", "inventory.cjs", "project.cjs", "collect.init.gradle", "runtime.init.gradle"].map(file => [file, fileHash(path.join(__dirname, file))])); }
217
232
  function evidenceFiles(evidence, directory) {
218
233
  const result = [];
219
234
  function visit(folder) { for (const name of fs.readdirSync(folder).sort()) { const file = path.join(folder, name); const stat = fs.lstatSync(file);
@@ -234,7 +249,7 @@ function checkEvaluation(evidence, value, phase) {
234
249
  requireThat(value.phase === phase && typeof value.attemptPath === "string" &&
235
250
  new RegExp(`^inventory-attempts/${phase}-[0-9]+-[a-f0-9-]+$`).test(value.attemptPath),
236
251
  "EVIDENCE_CHANGED", "Invalid sealed attempt reference");
237
- const { files, baselineInventorySha256, redSha256, manifestSha256, ...report } = value;
252
+ const { files, baselineInventorySha256, redSha256, manifestSha256, supplementSha256, ...report } = value;
238
253
  requireThat(same(report, read(path.join(evidence, value.attemptPath, "evaluation.json"))),
239
254
  "EVIDENCE_CHANGED", "Inventory report differs from its completed evaluation");
240
255
  if (files) requireThat(same(files, evidenceFiles(evidence, path.join(evidence, value.attemptPath))),
@@ -247,7 +262,7 @@ function runGradle(root, config, contract, phase, directory, discovery) {
247
262
  const requestFile = path.join(directory, "request.json"); atomic(requestFile, request, true);
248
263
  fs.writeFileSync(request.output, "", { flag: "wx" });
249
264
  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"] : [])];
265
+ "--init-script", path.join(__dirname, "runtime.init.gradle"), "--init-script", path.join(__dirname, "collect.init.gradle"), `-Dorchestrator.inventoryRequest=${requestFile}`, ...(discovery ? ["--dry-run"] : [])];
251
266
  const fd = fs.openSync(path.join(directory, "gradle.log"), "wx");
252
267
  let result;
253
268
  if (process.env.AUTOMATION_WORKER_TOKEN) args.push("--no-daemon");
@@ -264,7 +279,11 @@ function runGradle(root, config, contract, phase, directory, discovery) {
264
279
  let events;
265
280
  try { events = fs.readFileSync(request.output, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)); }
266
281
  catch { throw new InventoryError("INCOMPLETE_COLLECTION", "Invalid or truncated collector output"); }
267
- return { ...validateCollection(events, request, result.status, discovery), processExitCode: result.status };
282
+ const collection = validateCollection(events, request, result.status, discovery);
283
+ let runtime;
284
+ try { runtime = require("./project.cjs").parseBuildRuntime(fs.readFileSync(path.join(directory, "gradle.log"), "utf8")); }
285
+ catch (error) { throw new InventoryError("INCOMPLETE_COLLECTION", error.message); }
286
+ return { ...collection, runtime, processExitCode: result.status };
268
287
  }
269
288
 
270
289
  // Invalidate previous GREEN before even validating inputs. A crash, early
@@ -299,13 +318,15 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
299
318
  const checkBinding = value => requireThat(same(value.binding, binding), "EVIDENCE_CHANGED", "Contract, baseline, configuration or collector binding changed");
300
319
  const inputEvidence = [];
301
320
  const bindInput = file => { inputEvidence.push({ file, sha256: fileHash(file) }); };
302
- let baseline, manifest, red, patterns;
321
+ let baseline, manifest, red, patterns, supplement;
303
322
  if (phase !== "baseline") {
304
323
  baseline = read(baselineFile); checkBinding(baseline); checkFiles(evidence, baseline.files);
305
324
  checkEvaluation(evidence, baseline, "baseline");
306
325
  requireThat(baseline.valid === true && baseline.phase === "baseline", "EVIDENCE_CHANGED", "A valid baseline inventory is required");
307
326
  bindInput(baselineFile);
308
327
  patterns = baseline.testPatterns;
328
+ requireThat(baseline.environmentSha256 === require("./project.cjs").buildEnvironmentBinding(require("./project.cjs").gradleBuildRoot(config, root), baseline.runtime),
329
+ "EVIDENCE_CHANGED", "Build environment or Gradle user/JVM configuration changed after baseline");
309
330
  if (phase === "green" || phase === "check") {
310
331
  red = read(redFile); manifest = read(manifestFile); checkBinding(red); checkBinding(manifest);
311
332
  requireThat(red.manifestSha256 === fileHash(manifestFile) && red.baselineInventorySha256 === fileHash(baselineFile) &&
@@ -314,7 +335,14 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
314
335
  checkEvaluation(evidence, manifest, "red");
315
336
  requireThat(red.structuredCasesVerified === true && manifest.valid === true && manifest.phase === "red", "EVIDENCE_CHANGED", "Valid sealed RED evidence is required");
316
337
  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");
338
+ supplement = supplementalEvidence(phase, root, config, contract, evidence, binding, baseline, manifest, patterns, excluded);
339
+ if (supplement) {
340
+ bindInput(path.join(evidence, "test-supplement.json"));
341
+ baseline = { ...baseline, cases: [...baseline.cases, ...supplement.cases] };
342
+ manifest = { ...manifest, cases: [...manifest.cases, ...supplement.cases.map(item => ({ ...item, classification: "regression", id: identity(item), allowSkip: false }))],
343
+ testSnapshot: [...manifest.testSnapshot, ...supplement.addedFiles].sort((a, b) => a.path.localeCompare(b.path)) };
344
+ }
345
+ requireThat(same(snapshot(root, config, patterns, excluded), [...manifest.testSnapshot].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), "EVIDENCE_CHANGED", "Test sources or resources changed after sealed test evidence");
318
346
  }
319
347
  }
320
348
  if (phase === "baseline" || phase === "red") requireThat(head === binding.baselineHead, "EVIDENCE_CHANGED", "Execution HEAD changed before RED");
@@ -326,6 +354,7 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
326
354
  "EVIDENCE_CHANGED", "Latest GREEN failed, was interrupted or is not the sealed successful run; rerun GREEN");
327
355
  checkEvaluation(evidence, green, "green");
328
356
  requireThat(green.valid && green.redSha256 === fileHash(redFile) && green.manifestSha256 === fileHash(manifestFile), "EVIDENCE_CHANGED", "GREEN is not bound to the sealed RED and manifest");
357
+ requireThat((green.supplementSha256 ?? null) === (supplement ? fileHash(path.join(evidence, "test-supplement.json")) : null), "EVIDENCE_CHANGED", "GREEN is not bound to the supplemental test evidence");
329
358
  requireThat(green.verifiedHead === head && green.worktreeSha256 === worktree(root, excluded), "EVIDENCE_CHANGED", "Repository inputs changed after GREEN; rerun verification");
330
359
  return green;
331
360
  }
@@ -364,7 +393,11 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
364
393
  }
365
394
  if (phase !== "green") unchangedProduction(root, contract, config, patterns, phase, evidence);
366
395
  const before = snapshot(root, config, patterns, excluded), repositoryBefore = worktree(root, excluded);
396
+ const environmentBefore = require("./project.cjs").buildEnvironmentBinding(require("./project.cjs").gradleBuildRoot(config, root), baseline?.runtime);
367
397
  const collection = runGradle(root, config, contract, phase, path.join(attempt, "collection"), false);
398
+ requireThat(environmentBefore === require("./project.cjs").buildEnvironmentBinding(require("./project.cjs").gradleBuildRoot(config, root), baseline?.runtime),
399
+ "EVIDENCE_CHANGED", "Build environment changed during collection");
400
+ if (baseline) requireThat(same(collection.runtime, baseline.runtime), "EVIDENCE_CHANGED", "Actual Gradle, build JVM, plugins or test JVMs differ from baseline");
368
401
  requireThat(same(patterns, testPatterns(root, config, collection.tasks)), "EVIDENCE_CHANGED", "Test source/resource discovery changed after baseline");
369
402
  const after = snapshot(root, config, patterns, excluded);
370
403
  requireThat(same(before, after) && repositoryBefore === worktree(root, excluded) && head === git(root, ["rev-parse", "HEAD"]).trim(),
@@ -378,7 +411,9 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
378
411
  const evaluation = phase === "baseline" ? evaluateBaseline(contract, collection) : evaluateCoverage(contract, baseline, collection, phase, manifest);
379
412
  const report = { schemaVersion: 1, taskId: contract.id, phase, attempt: number, attemptPath, startedAt, finishedAt: new Date().toISOString(),
380
413
  valid: true, reasonCode: phase === "red" ? "VALID_RED" : "VALID_INVENTORY", binding, ...evaluation, testPatterns: patterns, testSnapshot: after,
381
- processExitCode: collection.processExitCode, verifiedHead: head, worktreeSha256: repositoryBefore,
414
+ processExitCode: collection.processExitCode, runtime: collection.runtime,
415
+ environmentSha256: require("./project.cjs").buildEnvironmentBinding(require("./project.cjs").gradleBuildRoot(config, root), collection.runtime),
416
+ verifiedHead: head, worktreeSha256: repositoryBefore,
382
417
  queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID ?? null, ...(verificationRunId ? { verificationRunId } : {}) };
383
418
  atomic(path.join(attempt, "evaluation.json"), report, true);
384
419
  const files = evidenceFiles(evidence, attempt);
@@ -391,7 +426,8 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
391
426
  preflightSha256: fileHash(path.join(evidence, "test-preflight.json")), processExitCode: collection.processExitCode,
392
427
  expectedFailureCount: evaluation.summary.expectedRed, exitCode: 1, exitCodeMeaning: "approved-case-failure" }, true);
393
428
  }
394
- if (phase === "green") atomic(path.join(evidence, "green-inventory.json"), { ...report, files, redSha256: fileHash(redFile), manifestSha256: fileHash(manifestFile) });
429
+ if (phase === "green") atomic(path.join(evidence, "green-inventory.json"), { ...report, files, redSha256: fileHash(redFile), manifestSha256: fileHash(manifestFile),
430
+ supplementSha256: supplement ? fileHash(path.join(evidence, "test-supplement.json")) : null });
395
431
  atomic(path.join(evidence, "inventory-status.json"), { ...report, nextAction: null });
396
432
  return report;
397
433
  } catch (error) {
@@ -406,7 +442,74 @@ function runPhaseInternal(phase, contractFile, configFile, root, evidence, verif
406
442
  throw error;
407
443
  }
408
444
  }
409
- module.exports = { identity, validateCollection, evaluateBaseline, evaluateCoverage, snapshot, testPatterns, isTest, runPhase };
445
+ function supplementalEvidence(phase, root, config, contract, evidence, binding, baseline, manifest, patterns, excluded) {
446
+ const file = path.join(evidence, "test-supplement.json");
447
+ const original = manifest.testSnapshot, current = snapshot(root, config, patterns, excluded);
448
+ requireThat(original.every(item => current.some(candidate => same(item, candidate))), "EVIDENCE_CHANGED", "Original RED tests cannot be changed or deleted; approve a revised task for changed assertions");
449
+ const addedFiles = current.filter(item => !original.some(previous => previous.path === item.path));
450
+ const authorized = contract.verification.supplementalTests?.mode === "baselinePassingNewFiles";
451
+ const bound = { binding, redSha256: fileHash(path.join(evidence, "red.json")), manifestSha256: fileHash(path.join(evidence, "test-manifest.json")) };
452
+ if (fs.existsSync(file)) {
453
+ requireThat(authorized, "EVIDENCE_CHANGED", "Supplemental test permission was not approved");
454
+ const saved = read(file);
455
+ requireThat(saved.version === 1 && same(saved.bound, bound) && same(saved.addedFiles, addedFiles), "EVIDENCE_CHANGED", "Supplemental tests or their original RED binding changed");
456
+ checkFiles(evidence, saved.files);
457
+ requireThat(same(saved, read(path.join(evidence, saved.attemptPath, "supplement.json"))), "EVIDENCE_CHANGED", "Supplemental test report differs from its sealed attempt");
458
+ return saved;
459
+ }
460
+ if (!addedFiles.length) return null;
461
+ requireThat(phase === "green" && authorized, "EVIDENCE_CHANGED", "New test files require approved supplementalTests policy and a fresh GREEN; original RED remains frozen");
462
+ requireThat(addedFiles.length <= contract.maxChangedFiles && addedFiles.every(item =>
463
+ /\.(?:java|kt|groovy)$/.test(item.path) && contract.allowedPaths.some(pattern => matchesPath(pattern, item.path)) &&
464
+ !contract.forbiddenPaths.some(pattern => matchesPath(pattern, item.path))), "EVIDENCE_CHANGED", "Supplemental files must be new allowed test sources; resources and original assertions remain frozen");
465
+ const parent = path.join(evidence, "supplement-attempts"); fs.mkdirSync(parent, { recursive: true });
466
+ requireThat(fs.readdirSync(parent).length < contract.verification.supplementalTests.maxRevisions, "PREPARATION_BUDGET", "Supplemental revision budget exhausted; inspect retained evidence and approve a revised task");
467
+ const attemptPath = `supplement-attempts/${randomUUID()}`, attempt = path.join(evidence, attemptPath);
468
+ fs.mkdirSync(attempt);
469
+ const probe = path.join(attempt, "baseline-worktree"), sourceBefore = worktree(root, excluded);
470
+ let created = false;
471
+ try {
472
+ git(root, ["worktree", "add", "--detach", probe, binding.baselineHead]); created = true;
473
+ for (const item of addedFiles) {
474
+ const target = path.join(probe, localPath(probe, item.path));
475
+ requireThat(!fs.existsSync(target), "EVIDENCE_CHANGED", "Supplemental test overwrites a baseline file");
476
+ fs.mkdirSync(path.dirname(target), { recursive: true });
477
+ fs.writeFileSync(target, fs.readFileSync(path.join(root, item.path)), { flag: "wx", mode: item.mode });
478
+ }
479
+ // Only the SDK path may be passed to this diagnostic worktree. No ignored
480
+ // properties, credentials or arbitrary files are copied into model inputs.
481
+ const sourceBuild = require("./project.cjs").gradleBuildRoot(config, root);
482
+ const probeBuild = require("./project.cjs").gradleBuildRoot(config, probe);
483
+ const local = path.join(sourceBuild, "local.properties");
484
+ if (fs.existsSync(local)) {
485
+ const lines = fs.readFileSync(local, "utf8").split(/\r?\n/).filter(line => line.trim() && !/^\s*[#!]/.test(line));
486
+ requireThat(lines.every(line => /^\s*sdk\.dir\s*[=:]/.test(line) && !/\\$/.test(line)), "EVIDENCE_CHANGED", "Supplemental baseline requires explicit isolation setup for non-SDK local.properties entries");
487
+ fs.writeFileSync(path.join(probeBuild, "local.properties"), lines.join("\n") + "\n", { flag: "wx", mode: 0o600 });
488
+ }
489
+ const before = worktree(probe, excluded);
490
+ const collection = runGradle(probe, config, contract, "baseline", path.join(attempt, "collection"), false);
491
+ requireThat(before === worktree(probe, excluded) && sourceBefore === worktree(root, excluded) && same(current, snapshot(root, config, patterns, excluded)),
492
+ "INPUT_CHANGED_DURING_RUN", "Inputs changed while validating supplemental tests on the original baseline");
493
+ requireThat(same(collection.runtime, baseline.runtime), "EVIDENCE_CHANGED", "Supplemental baseline runtime differs from original baseline");
494
+ const expected = new Map(baseline.cases.map(item => [identity(item), item]));
495
+ requireThat(baseline.cases.every(item => collection.cases.some(result => identity(result) === identity(item) && result.result === item.result)),
496
+ "COVERAGE_MISMATCH", "Supplemental baseline changed existing regression outcomes");
497
+ const cases = collection.cases.filter(item => !expected.has(identity(item)));
498
+ requireThat(cases.length > 0 && cases.every(item => item.result === "SUCCESS" && !manifest.cases.some(original => identity(original) === identity(item))),
499
+ "CASE_EXPECTATION_MISMATCH", "Every new supplemental case must pass on the original baseline without replacing approved behavior identities");
500
+ git(root, ["worktree", "remove", "--force", probe]); created = false;
501
+ const saved = { version: 1, bound, addedFiles, cases, attemptPath, files: evidenceFiles(evidence, path.join(attempt, "collection")) };
502
+ atomic(path.join(attempt, "supplement.json"), saved, true);
503
+ atomic(file, saved, true);
504
+ return saved;
505
+ } catch (error) {
506
+ atomic(path.join(attempt, "failure.json"), { code: error.code ?? "SUPPLEMENT_FAILED", message: error.message });
507
+ throw error;
508
+ } finally {
509
+ if (created) git(root, ["worktree", "remove", "--force", probe]);
510
+ }
511
+ }
512
+ module.exports = { identity, validateCollection, evaluateBaseline, evaluateCoverage, matchesFailureOrigin, snapshot, testPatterns, isTest, runPhase };
410
513
  if (require.main === module) {
411
514
  const [phase, contractFile, configFile, root, evidence] = process.argv.slice(2);
412
515
  try {
@@ -162,6 +162,64 @@ function validateConfigurationModel(config, root) {
162
162
  if (sources.some(p => config.protectedPaths.some(f => overlap(p, f.endsWith("/") ? f + "**" : f)))) fail("source paths overlap protected configuration");
163
163
  }
164
164
  exports.validateConfigurationModel = validateConfigurationModel;
165
+
166
+ // Shared actual-runtime diagnostics. PATH java is only a launcher candidate;
167
+ // it is never substituted for the wrapper's build JVM attestation.
168
+ function parseBuildRuntime(output) {
169
+ const lines = output.split(/\r?\n/).filter(line => line.startsWith("ORCHESTRATOR_BUILD_RUNTIME="));
170
+ if (lines.length !== 1) throw new Error("Gradle did not produce exactly one actual build JVM record");
171
+ const value = JSON.parse(lines[0].slice("ORCHESTRATOR_BUILD_RUNTIME=".length));
172
+ if (value.version !== 1 || typeof value.gradleVersion !== "string" || !/^\d+\.\d+/.test(value.gradleVersion) ||
173
+ typeof value.javaVersion !== "string" || !/^(?:1\.)?\d+/.test(value.javaVersion) ||
174
+ typeof value.javaHome !== "string" || !isAbsolute(value.javaHome) || typeof value.javaVendor !== "string" ||
175
+ !Array.isArray(value.plugins) || !Array.isArray(value.tests)) throw new Error("Invalid actual Gradle/JVM record");
176
+ const java = Number(value.javaVersion.replace(/^1\./, "").match(/^\d+/)[0]);
177
+ if (/^6\.7(?:\.|$)/.test(value.gradleVersion) && (java < 8 || java > 15))
178
+ throw new Error("Gradle 6.7 requires an actual build JVM from Java 8 through 15");
179
+ for (const item of value.tests) if (!modulePath(item.task) || typeof item.executable !== "string" || !isAbsolute(item.executable))
180
+ throw new Error("Invalid test JVM record");
181
+ return value;
182
+ }
183
+ function inspectBuildRuntime(root, runner) {
184
+ const fs = require("node:fs"), path = require("node:path");
185
+ const executable = path.join(root, "gradlew");
186
+ const args = ["help", "--no-configuration-cache", "--console=plain", "--quiet", "--project-dir", root,
187
+ "--init-script", path.join(__dirname, "runtime.init.gradle")];
188
+ if (process.env.AUTOMATION_WORKER_TOKEN) args.push("--no-daemon");
189
+ const result = runner ? runner(executable, args) : require("node:child_process").spawnSync(executable, args,
190
+ { cwd: root, encoding: "utf8", timeout: 180000, maxBuffer: 8 * 1024 * 1024 });
191
+ if (result.status !== 0 || result.error) throw new Error(`Cannot verify the wrapper's actual Gradle/JVM: ${String(result.error ?? result.stderr ?? '').slice(-2000)}`);
192
+ return parseBuildRuntime(result.stdout);
193
+ }
194
+ function buildEnvironmentBinding(root, runtime) {
195
+ const fs = require("node:fs"), path = require("node:path"), crypto = require("node:crypto");
196
+ const hash = value => crypto.createHash("sha256").update(value).digest("hex");
197
+ const env = Object.fromEntries(["PATH", "JAVA_HOME", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "GRADLE_OPTS", "GRADLE_USER_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT"]
198
+ .map(key => [key, process.env[key] ?? null]));
199
+ const home = process.env.GRADLE_USER_HOME ?? path.join(require("node:os").homedir(), ".gradle");
200
+ const files = [path.join(home, "gradle.properties"), path.join(home, "init.gradle"), path.join(home, "init.gradle.kts"),
201
+ path.join(root, "gradle.properties"), path.join(root, "local.properties"), path.join(root, "gradle/gradle-daemon-jvm.properties")];
202
+ const init = path.join(home, "init.d");
203
+ if (fs.existsSync(init)) files.push(...fs.readdirSync(init).sort().filter(name => /\.gradle(?:\.kts)?$/.test(name)).map(name => path.join(init, name)));
204
+ const homes = [process.env.JAVA_HOME, runtime?.javaHome].filter(Boolean);
205
+ for (const javaHome of homes) files.push(path.join(javaHome, "bin/java"), path.join(javaHome, "release"));
206
+ if (runtime) files.push(...runtime.tests.map(test => test.executable));
207
+ const inputs = [...new Set(files)].sort().map(file => [file, fs.existsSync(file) ? hash(fs.readFileSync(file)) : null]);
208
+ return hash(JSON.stringify({ env, inputs }));
209
+ }
210
+ exports.parseBuildRuntime = parseBuildRuntime;
211
+ exports.inspectBuildRuntime = inspectBuildRuntime;
212
+ exports.buildEnvironmentBinding = buildEnvironmentBinding;
213
+ function assertIsolatedBuildEnvironment(root) {
214
+ const fs = require("node:fs"), path = require("node:path");
215
+ const local = path.join(root, "local.properties");
216
+ if (!fs.existsSync(local)) return;
217
+ if (fs.lstatSync(local).isSymbolicLink()) throw new Error("Isolated builds require a regular local.properties file");
218
+ const lines = fs.readFileSync(local, "utf8").split(/\r?\n/).filter(line => line.trim() && !/^\s*[#!]/.test(line));
219
+ if (!lines.every(line => /^\s*sdk\.dir\s*[=:]/.test(line) && !/\\$/.test(line)))
220
+ throw new Error("ISOLATED_ENVIRONMENT_UNDECLARED: local.properties contains non-SDK inputs. Configure them through reviewed tracked build configuration or use an explicitly approved fixed-branch task; ignored files are not copied to worktrees.");
221
+ }
222
+ exports.assertIsolatedBuildEnvironment = assertIsolatedBuildEnvironment;
165
223
  if (require.main === module) {
166
224
  try {
167
225
  const config = JSON.parse(require("node:fs").readFileSync(process.argv[2], "utf8"));
@@ -133,12 +133,11 @@ function inputBinding(root, contractFile, configFile, contract, allowProduct = f
133
133
  }
134
134
  return [file, stat.mode & 0o777, hash(stat.isSymbolicLink() ? fs.readlinkSync(absolute) : fs.readFileSync(absolute))];
135
135
  });
136
- const java = cp.spawnSync("java", ["-version"], { encoding: "utf8", timeout: 10000 });
137
- assert(java.status === 0, "Cannot identify the Java toolchain for baseline recovery");
138
136
  const environment = Object.fromEntries(["JAVA_HOME", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "GRADLE_OPTS", "GRADLE_USER_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT"].map(key => [key, process.env[key] ?? null]));
139
137
  const localProperties = path.join(require("./project.cjs").gradleBuildRoot(read(configFile), root), "local.properties");
140
138
  return { contractSha256: fileHash(contractFile), configSha256: fileHash(configFile), head: git(root, ["rev-parse", "HEAD"]).trim(),
141
- inputsSha256: digest(inputs), excludedPathsSha256: digest(excluded), environmentSha256: digest(environment), javaSha256: hash(java.stdout + java.stderr), node: process.version,
139
+ inputsSha256: digest(inputs), excludedPathsSha256: digest(excluded), environmentSha256: digest(environment),
140
+ buildEnvironmentSha256: require("./project.cjs").buildEnvironmentBinding(require("./project.cjs").gradleBuildRoot(read(configFile), root)), node: process.version,
142
141
  localPropertiesSha256: fs.existsSync(localProperties) ? fileHash(localProperties) : null };
143
142
  }
144
143
  function artifactHashes(evidence, directory) {