@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.
- package/CHANGELOG.md +23 -0
- package/README.md +12 -8
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +13 -0
- 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 +2 -1
- package/dist/doctor/installation.js.map +1 -1
- package/dist/installer/adaptive-templates.d.ts.map +1 -1
- package/dist/installer/adaptive-templates.js +4 -0
- package/dist/installer/adaptive-templates.js.map +1 -1
- package/dist/installer/capabilities-script.d.ts.map +1 -1
- package/dist/installer/capabilities-script.js +13 -3
- package/dist/installer/capabilities-script.js.map +1 -1
- package/dist/installer/init.js +1 -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/upgrade.js +1 -1
- package/dist/installer/upgrade.js.map +1 -1
- package/dist/queue/executor.d.ts.map +1 -1
- package/dist/queue/executor.js +5 -0
- package/dist/queue/executor.js.map +1 -1
- package/dist/queue/queue.js +1 -1
- package/dist/queue/queue.js.map +1 -1
- package/docs/GRADLE-6.7.1.md +113 -0
- package/docs/MIGRATION.md +36 -14
- package/docs/QUEUE.md +3 -3
- package/docs/SECURITY.md +1 -1
- package/docs/TROUBLESHOOTING.md +7 -8
- package/package.json +1 -1
- package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +8 -1
- package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +6 -2
- package/templates/automation/task-contract.schema.json +88 -2
- package/templates/automation/tasks/TASK-TEMPLATE.json.example +5 -4
- package/templates/automation/verification/collect.init.gradle +60 -36
- package/templates/automation/verification/contract.cjs +28 -2
- package/templates/automation/verification/inventory.cjs +113 -10
- package/templates/automation/verification/project.cjs +58 -0
- package/templates/automation/verification/recovery.cjs +2 -3
- package/templates/automation/verification/runtime.init.gradle +58 -0
- package/templates/scripts/automation/acceptance-report.sh +2 -2
- package/templates/scripts/automation/status.sh +3 -0
|
@@ -32,8 +32,43 @@
|
|
|
32
32
|
"minLength": 3
|
|
33
33
|
},
|
|
34
34
|
"origin": {
|
|
35
|
-
"
|
|
36
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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),
|
|
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) {
|