@frankzhang2026/opencode-android-orchestrator 1.0.5 → 1.2.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 +79 -0
- package/README.md +79 -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 +27 -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 +11 -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 +42 -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 +84 -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 +3 -2
- 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 +10 -2
- 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 +160 -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/GRADLE-6.7.1.md +112 -0
- package/docs/MIGRATION.md +125 -11
- package/docs/QUEUE.md +350 -16
- package/docs/SECURITY.md +5 -2
- package/docs/TROUBLESHOOTING.md +81 -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 +36 -3
- package/templates/.opencode/skills/scheduled-quality-orchestrator/SKILL.md +65 -4
- package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +23 -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 +659 -49
- package/templates/automation/tasks/TASK-TEMPLATE.json.example +14 -8
- package/templates/automation/verification/collect.init.gradle +108 -0
- package/templates/automation/verification/contract.cjs +119 -0
- package/templates/automation/verification/inventory.cjs +536 -0
- package/templates/automation/verification/project.cjs +230 -0
- package/templates/automation/verification/recovery.cjs +425 -0
- package/templates/automation/verification/runtime.init.gradle +58 -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 +35 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 4,
|
|
3
3
|
"id": "TASK-EXAMPLE-001",
|
|
4
4
|
"title": "Replace with one small, observable behavior change",
|
|
5
5
|
"designApproved": true,
|
|
@@ -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",
|
|
@@ -47,7 +45,7 @@
|
|
|
47
45
|
],
|
|
48
46
|
"targetTests": [
|
|
49
47
|
{
|
|
50
|
-
"gradleTask": "testDebugUnitTest",
|
|
48
|
+
"gradleTask": ":app:testDebugUnitTest",
|
|
51
49
|
"filter": "*ReplaceWithFocusedTest"
|
|
52
50
|
}
|
|
53
51
|
],
|
|
@@ -55,7 +53,7 @@
|
|
|
55
53
|
"testPolicy": "required",
|
|
56
54
|
"testPolicyReason": "Behavior changes require a focused regression test",
|
|
57
55
|
"verification": {
|
|
58
|
-
"version":
|
|
56
|
+
"version": 2,
|
|
59
57
|
"maxPreparationFixes": 1,
|
|
60
58
|
"cases": [
|
|
61
59
|
{
|
|
@@ -72,9 +70,17 @@
|
|
|
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
|
+
],
|
|
80
|
+
"inventory": {
|
|
81
|
+
"mode": "focusedBaseline",
|
|
82
|
+
"existingSkips": "reject",
|
|
83
|
+
"emptyBaseline": "reject"
|
|
84
|
+
}
|
|
79
85
|
}
|
|
80
86
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import groovy.json.JsonOutput
|
|
2
|
+
import groovy.json.JsonSlurper
|
|
3
|
+
import org.gradle.api.tasks.testing.Test
|
|
4
|
+
import org.gradle.api.tasks.testing.TestDescriptor
|
|
5
|
+
import org.gradle.api.tasks.testing.TestListener
|
|
6
|
+
import org.gradle.api.tasks.testing.TestResult
|
|
7
|
+
|
|
8
|
+
// All three phases use this collector. Never infer execution from existing XML.
|
|
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)
|
|
20
|
+
def outputFile = new File(request.output as String)
|
|
21
|
+
def append = { Map value ->
|
|
22
|
+
synchronized (outputFile) {
|
|
23
|
+
outputFile << JsonOutput.toJson(value + [runId: request.runId]) << '\n'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
append([kind: 'start', phase: request.phase, gradleVersion: gradle.gradleVersion])
|
|
27
|
+
def filtersByTask = request.targets.groupBy { it.gradleTask }.collectEntries { path, targets ->
|
|
28
|
+
[(path): targets.collect { it.filter }.unique().sort()]
|
|
29
|
+
}
|
|
30
|
+
gradle.taskGraph.whenReady { graph ->
|
|
31
|
+
filtersByTask.each { path, filters ->
|
|
32
|
+
def task = graph.allTasks.find { it.path == path }
|
|
33
|
+
if (!(task instanceof Test)) throw new GradleException("Inventory target must resolve to an exact Test task: ${path}")
|
|
34
|
+
def roots = [] as Set
|
|
35
|
+
def classes = task.testClassesDirs.files.collect { it.canonicalFile } as Set
|
|
36
|
+
def sourceSets = task.project.extensions.findByName('sourceSets')
|
|
37
|
+
sourceSets?.each { sourceSet ->
|
|
38
|
+
if (sourceSet.output.classesDirs.files.any { classes.contains(it.canonicalFile) }) {
|
|
39
|
+
roots.addAll(sourceSet.allSource.srcDirs)
|
|
40
|
+
roots.addAll(sourceSet.resources.srcDirs)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// AGP variants expose the source providers actually used by each unit test.
|
|
44
|
+
def android = task.project.extensions.findByName('android')
|
|
45
|
+
if (android != null && android.hasProperty('unitTestVariants')) {
|
|
46
|
+
android.unitTestVariants.each { variant ->
|
|
47
|
+
if (task.name == "test${variant.name.capitalize()}") {
|
|
48
|
+
variant.sourceSets.each { provider ->
|
|
49
|
+
roots.addAll(provider.javaDirectories)
|
|
50
|
+
roots.addAll(provider.resourcesDirectories)
|
|
51
|
+
if (provider.hasProperty('kotlinDirectories')) roots.addAll(provider.kotlinDirectories)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Modern AGP also exposes variant-specific test source sets through its DSL.
|
|
57
|
+
// Only unit-test providers are classified here, never production providers.
|
|
58
|
+
if (android != null && android.hasProperty('sourceSets')) {
|
|
59
|
+
android.sourceSets.findAll { it.name == 'test' || it.name.startsWith('test') && it.name.length() > 4 && Character.isUpperCase(it.name.charAt(4)) }.each { sourceSet ->
|
|
60
|
+
roots.addAll(sourceSet.java.srcDirs)
|
|
61
|
+
roots.addAll(sourceSet.resources.srcDirs)
|
|
62
|
+
if (sourceSet.hasProperty('kotlin')) roots.addAll(sourceSet.kotlin.srcDirs)
|
|
63
|
+
roots.addAll(sourceSet.assets.srcDirs)
|
|
64
|
+
roots.addAll(sourceSet.res.srcDirs)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
append([kind: 'task', taskPath: path, filters: filters,
|
|
68
|
+
sourceRoots: roots.collect { it.absoluteFile.toPath().normalize().toString() }.sort(),
|
|
69
|
+
testClassesDirs: classes.collect { it.toString() }.sort()])
|
|
70
|
+
task.outputs.upToDateWhen { false }
|
|
71
|
+
task.outputs.doNotCacheIf('Orchestrator inventory requires actual test execution') { true }
|
|
72
|
+
task.ignoreFailures = true
|
|
73
|
+
task.failFast = false
|
|
74
|
+
if (task.hasProperty('dryRun')) task.dryRun = false
|
|
75
|
+
// An approved new test class need not exist before Coder starts.
|
|
76
|
+
task.filter.failOnNoMatchingTests = request.phase != 'baseline'
|
|
77
|
+
if (task.hasProperty('failOnNoDiscoveredTests')) task.failOnNoDiscoveredTests = request.phase != 'baseline'
|
|
78
|
+
task.filter.setIncludePatterns(*(filters as String[]))
|
|
79
|
+
def reportDir = new File(outputFile.parentFile, 'reports/' + path.substring(1).replace(':', '/'))
|
|
80
|
+
task.reports.junitXml.required.set(true)
|
|
81
|
+
task.reports.junitXml.outputLocation.set(reportDir)
|
|
82
|
+
task.addTestListener(new TestListener() {
|
|
83
|
+
void beforeSuite(TestDescriptor descriptor) {}
|
|
84
|
+
void beforeTest(TestDescriptor descriptor) {}
|
|
85
|
+
void afterTest(TestDescriptor descriptor, TestResult result) {
|
|
86
|
+
def failures = result.exceptions.collect { failure ->
|
|
87
|
+
[type: failure.class.name, message: failure.message ?: '', stack: failure.stackTrace.collect { it.toString() }]
|
|
88
|
+
}
|
|
89
|
+
append([kind: 'case', taskPath: path, className: descriptor.className ?: '',
|
|
90
|
+
name: descriptor.name ?: '', displayName: descriptor.displayName,
|
|
91
|
+
result: result.resultType.toString(), failures: failures])
|
|
92
|
+
}
|
|
93
|
+
void afterSuite(TestDescriptor descriptor, TestResult result) {
|
|
94
|
+
if (descriptor.parent == null) append([kind: 'suite', taskPath: path,
|
|
95
|
+
tests: result.testCount, failures: result.failedTestCount, skipped: result.skippedTestCount])
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
gradle.taskGraph.afterTask { task, state ->
|
|
101
|
+
if (filtersByTask.containsKey(task.path)) append([kind: 'taskEnd', taskPath: task.path,
|
|
102
|
+
executed: state.executed, skipped: state.skipped, noSource: state.noSource,
|
|
103
|
+
upToDate: state.upToDate, skipMessage: state.skipMessage,
|
|
104
|
+
failure: state.failure == null ? null : state.failure.toString()])
|
|
105
|
+
}
|
|
106
|
+
gradle.buildFinished { result ->
|
|
107
|
+
append([kind: 'end', failure: result.failure == null ? null : result.failure.toString()])
|
|
108
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// One V4/V5 validator is used by both the queue and the installed shell entry.
|
|
4
|
+
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
5
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
const text = value => typeof value === "string" && value.length > 0 && !/[\u0000\r\n]/.test(value);
|
|
7
|
+
const fields = (value, required, optional = []) => object(value) && required.every(key => Object.hasOwn(value, key)) &&
|
|
8
|
+
Object.keys(value).every(key => required.includes(key) || optional.includes(key));
|
|
9
|
+
const identity = value => JSON.stringify([value.taskPath, value.className, value.name]);
|
|
10
|
+
function matchesPath(pattern, path) {
|
|
11
|
+
const expression = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
12
|
+
.replace(/\*\*/g, "\u0001").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
13
|
+
.replace(/\u0001\//g, "(?:.*/)?").replace(/\u0001/g, ".*");
|
|
14
|
+
return new RegExp(`^${expression}${pattern.endsWith("/") ? ".*" : ""}$`).test(path);
|
|
15
|
+
}
|
|
16
|
+
function validateContract(contract, config) {
|
|
17
|
+
assert(fields(contract, ["schemaVersion", "id", "title", "designApproved", "planPath", "ambiguityPolicy",
|
|
18
|
+
"maxFixLoops", "maxChangedFiles", "allowedPaths", "forbiddenPaths", "allowedWorkflowSkills",
|
|
19
|
+
"acceptanceCriteria", "nonGoals", "targetTests", "deviceTestsRequired", "testPolicy", "verification"],
|
|
20
|
+
contract.schemaVersion === 8 ? ["testPolicyReason", "recovery", "execution", "stageRecovery", "continuity"] : contract.schemaVersion === 7 ? ["testPolicyReason", "recovery", "execution", "stageRecovery"] : contract.schemaVersion === 6 ? ["testPolicyReason", "recovery", "execution"] : contract.schemaVersion === 5 ? ["testPolicyReason", "recovery"] : ["testPolicyReason"]), "Invalid inventory contract fields");
|
|
21
|
+
assert([4, 5, 6, 7, 8].includes(contract.schemaVersion) && /^TASK-[A-Z0-9-]+$/.test(contract.id), "Invalid inventory contract identity");
|
|
22
|
+
if (contract.schemaVersion >= 5) require("./recovery.cjs").validateRecoveryPolicy(contract.recovery);
|
|
23
|
+
if (contract.schemaVersion >= 6) require("./recovery.cjs").validateExecutionPolicy(contract.execution);
|
|
24
|
+
if (contract.schemaVersion >= 7) require("./recovery.cjs").validateStageRecoveryPolicy(contract.stageRecovery);
|
|
25
|
+
if (contract.schemaVersion === 8) {
|
|
26
|
+
const policy = contract.continuity;
|
|
27
|
+
assert(fields(policy, ["version", "isolatedAutoIntegration", "planningRefresh", "planningInputs"]) && policy.version === 1 &&
|
|
28
|
+
typeof policy.isolatedAutoIntegration === "boolean" && ["reject", "completedQueueTasks"].includes(policy.planningRefresh), "Invalid continuity policy");
|
|
29
|
+
assert(Array.isArray(policy.planningInputs) && policy.planningInputs.length > 0 && policy.planningInputs.length <= 128 &&
|
|
30
|
+
new Set(policy.planningInputs).size === policy.planningInputs.length && policy.planningInputs.every(p =>
|
|
31
|
+
typeof p === "string" && p.length <= 512 && /^[A-Za-z0-9._/?*-]+$/.test(p) &&
|
|
32
|
+
!p.startsWith("/") && !p.includes("..") && p.split("/").every(part => part && part !== "." && part !== ".git")), "Invalid planning inputs");
|
|
33
|
+
}
|
|
34
|
+
assert(text(contract.title) && contract.designApproved === true && contract.ambiguityPolicy === "BLOCKED", "An approved bounded design is required");
|
|
35
|
+
assert(contract.planPath === `docs/plans/${contract.id}.md`, "Invalid task plan path");
|
|
36
|
+
assert(Number.isInteger(contract.maxFixLoops) && contract.maxFixLoops >= 0 && contract.maxFixLoops <= 1, "Invalid fix-loop limit");
|
|
37
|
+
assert(Number.isInteger(contract.maxChangedFiles) && contract.maxChangedFiles >= 1 && contract.maxChangedFiles <= 12, "Invalid file limit");
|
|
38
|
+
for (const key of ["allowedPaths", "forbiddenPaths", "acceptanceCriteria", "nonGoals"])
|
|
39
|
+
assert(Array.isArray(contract[key]) && contract[key].length > 0 && contract[key].every(text), `Invalid ${key}`);
|
|
40
|
+
for (const path of [...contract.allowedPaths, ...contract.forbiddenPaths])
|
|
41
|
+
assert(/^[A-Za-z0-9._/?*-]+$/.test(path) && !path.startsWith("/") && !path.includes(".."), "Unsafe contract path");
|
|
42
|
+
for (const path of config.protectedPaths) {
|
|
43
|
+
assert(!contract.allowedPaths.some(pattern => matchesPath(pattern, path)), `Allowed paths overlap protected path: ${path}`);
|
|
44
|
+
assert(contract.forbiddenPaths.some(pattern => matchesPath(pattern, path)), `Forbidden paths must cover: ${path}`);
|
|
45
|
+
}
|
|
46
|
+
assert(JSON.stringify(contract.allowedWorkflowSkills) === JSON.stringify(["test-driven-development", "systematic-debugging", "verification-before-completion"].map(name => `android-orchestrator-${name}`)), "Invalid workflow skill allowlist");
|
|
47
|
+
assert(typeof contract.deviceTestsRequired === "boolean" && contract.testPolicy === "required", "Inventory verification requires tests");
|
|
48
|
+
assert(!contract.deviceTestsRequired || config.gradleVerification.deviceTestTasks?.length > 0,
|
|
49
|
+
"Device tests are required by this contract but no device test tasks are available");
|
|
50
|
+
assert(config.unitTestsEnabled !== false, "Inventory verification requires unitTestsEnabled=true");
|
|
51
|
+
assert(!Object.hasOwn(contract, "testPolicyReason") || typeof contract.testPolicyReason === "string", "Invalid test policy reason");
|
|
52
|
+
assert(Array.isArray(contract.targetTests) && contract.targetTests.length > 0, "Focused tests are required");
|
|
53
|
+
const targets = new Set();
|
|
54
|
+
for (const target of contract.targetTests) {
|
|
55
|
+
assert(fields(target, ["gradleTask", "filter"]) && /^(?::[A-Za-z0-9_.-]+)+$/.test(target.gradleTask), "Inventory verification requires a fully qualified Test task path");
|
|
56
|
+
assert(config.gradleVerification.focusedTestTasks.includes(target.gradleTask), "Focused test task is not configured");
|
|
57
|
+
assert(typeof target.filter === "string" && /^[A-Za-z0-9_.#$*-]+$/.test(target.filter), "Unsafe test filter");
|
|
58
|
+
const key = JSON.stringify([target.gradleTask, target.filter]);
|
|
59
|
+
assert(!targets.has(key), "Focused test targets must be unique"); targets.add(key);
|
|
60
|
+
}
|
|
61
|
+
const verification = contract.verification;
|
|
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");
|
|
66
|
+
assert(Number.isInteger(verification.maxPreparationFixes) && verification.maxPreparationFixes >= 0 && verification.maxPreparationFixes <= 1, "Invalid preparation retry budget");
|
|
67
|
+
const policy = verification.inventory;
|
|
68
|
+
assert(fields(policy, ["mode", "existingSkips", "emptyBaseline"]) && policy.mode === "focusedBaseline" &&
|
|
69
|
+
["reject", "preserve"].includes(policy.existingSkips) && ["reject", "allow"].includes(policy.emptyBaseline), "Invalid inventory coverage, existing-skip or empty-baseline policy");
|
|
70
|
+
assert(Array.isArray(verification.cases) && verification.cases.length > 0, "Behavior cases are required");
|
|
71
|
+
const ids = new Set(), identities = new Set();
|
|
72
|
+
for (const item of verification.cases) {
|
|
73
|
+
assert(fields(item, ["id", "criterion", "intent", "before", "after", "source", "test"], ["expectedFailure"]), "Invalid behavior case fields");
|
|
74
|
+
assert(typeof item.id === "string" && /^[A-Z][A-Z0-9-]{2,63}$/.test(item.id) && !ids.has(item.id), "Behavior IDs must be unique and stable"); ids.add(item.id);
|
|
75
|
+
assert(Number.isInteger(item.criterion) && item.criterion >= 1 && item.criterion <= contract.acceptanceCriteria.length && item.after === "pass", "Invalid behavior criterion or GREEN expectation");
|
|
76
|
+
assert(fields(item.test, ["target", "className", "name"]) && Number.isInteger(item.test.target) && item.test.target >= 0 &&
|
|
77
|
+
item.test.target < contract.targetTests.length && text(item.test.className) && text(item.test.name), "Invalid exact test identity (include each parameter instance)");
|
|
78
|
+
const key = identity({ taskPath: contract.targetTests[item.test.target].gradleTask, ...item.test });
|
|
79
|
+
assert(!identities.has(key), "Behavior identities overlap across target filters"); identities.add(key);
|
|
80
|
+
if (item.intent === "preserve") assert(item.before === "pass" && ["existingTest", "baselineCapture", "measuredFact"].includes(item.source) && !Object.hasOwn(item, "expectedFailure"), "Preserved behavior must pass on a measured baseline");
|
|
81
|
+
else if (item.intent === "change") assert(item.before === "fail" && item.source === "userRequirement" && object(item.expectedFailure), "Changed behavior requires an approved expected failure");
|
|
82
|
+
else assert(item.intent === "observe" && item.before === "observe" && ["userRequirement", "existingTest", "baselineCapture", "measuredFact"].includes(item.source), "Invalid observed behavior");
|
|
83
|
+
if (Object.hasOwn(item, "expectedFailure")) {
|
|
84
|
+
const failure = item.expectedFailure;
|
|
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) &&
|
|
92
|
+
(!Object.hasOwn(failure, "messageIncludes") || (text(failure.messageIncludes) && failure.messageIncludes.length >= 3)), "Invalid expected failure");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
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");
|
|
113
|
+
assert(!/replace with|TASK-EXAMPLE|\btodo\b|\btbd\b|placeholder/i.test(JSON.stringify(contract)), "Contract contains placeholders");
|
|
114
|
+
}
|
|
115
|
+
module.exports = { validateContract, identity, matchesPath, assert };
|
|
116
|
+
if (require.main === module) {
|
|
117
|
+
try { const fs = require("node:fs"); validateContract(JSON.parse(fs.readFileSync(process.argv[2], "utf8")), JSON.parse(fs.readFileSync(process.argv[3], "utf8"))); }
|
|
118
|
+
catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
119
|
+
}
|