Skip to content

Commit cdc8b66

Browse files
committed
test(contracts): establish C10 evidence foundation and C01 RED process tests
Add shared, hash-locked synthetic argv vectors and a Java 8-compatible child probe. Add fail-closed layered Surefire reporting with 12 self-tests and pinned OpenSpec strict CI. This is the intentional C01 RED checkpoint; no product Java sources changed. Keep C10 integration and the remaining OpenSpec changes open.
1 parent d0c8056 commit cdc8b66

9 files changed

Lines changed: 609 additions & 0 deletions

File tree

‎.github/workflows/contracts.yml‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
name: OpenCLI Contracts
2+
3+
on:
4+
push:
5+
branches:
6+
- 'feature/1.0.x'
7+
- 'feature/2.0.x'
8+
- 'feature/3.0.x'
9+
- 'feature/*-contract-hardening'
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
specification:
17+
name: OpenSpec strict and evidence-runner tests
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
env:
21+
OPENSPEC_TELEMETRY: '0'
22+
DO_NOT_TRACK: '1'
23+
CI: 'true'
24+
steps:
25+
- uses: actions/checkout@v7
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: '22'
29+
- name: Verify report runner and shared fixture hashes
30+
run: |
31+
python3 -m unittest discover -s scripts/tests -v
32+
(cd src/test/resources/opencli-contracts/v1 && sha256sum -c SHA256SUMS)
33+
- name: Install pinned official OpenSpec
34+
run: npm install --prefix "$RUNNER_TEMP/opencli-openspec" --ignore-scripts --no-audit --no-fund @fission-ai/openspec@1.13.1
35+
- name: Validate all proposals and each change with strict mode
36+
shell: bash
37+
run: |
38+
export PATH="$RUNNER_TEMP/opencli-openspec/node_modules/.bin:$PATH"
39+
mkdir -p .spec-evidence
40+
openspec --version | tee .spec-evidence/version.txt
41+
openspec list --json > .spec-evidence/list.json
42+
failed=0
43+
for path in openspec/changes/*; do
44+
[[ -d "$path" && "$(basename "$path")" != archive ]] || continue
45+
change="$(basename "$path")"
46+
if openspec validate "$change" --strict --no-interactive > ".spec-evidence/$change.log" 2>&1; then
47+
printf '%s\tPASS\n' "$change"
48+
else
49+
cat ".spec-evidence/$change.log"
50+
failed=1
51+
fi
52+
done
53+
if ! openspec validate --all --strict --no-interactive > .spec-evidence/all.log 2>&1; then
54+
cat .spec-evidence/all.log
55+
failed=1
56+
fi
57+
exit "$failed"
58+
- name: Upload specification evidence
59+
if: always()
60+
uses: actions/upload-artifact@v7
61+
with:
62+
name: openspec-strict-${{ github.sha }}
63+
path: .spec-evidence
64+
if-no-files-found: error
65+
66+
jvm:
67+
name: Branch-specific JVM contract verification
68+
runs-on: ubuntu-latest
69+
timeout-minutes: 30
70+
steps:
71+
- uses: actions/checkout@v7
72+
- name: Select declared branch line
73+
id: line
74+
shell: bash
75+
run: |
76+
case "$GITHUB_REF_NAME" in
77+
feature/1.0.x*) echo 'java=8' >> "$GITHUB_OUTPUT" ;;
78+
feature/2.0.x*) echo 'java=17' >> "$GITHUB_OUTPUT" ;;
79+
feature/3.0.x*) echo 'java=21' >> "$GITHUB_OUTPUT" ;;
80+
*) echo 'Unsupported branch line' >&2; exit 1 ;;
81+
esac
82+
- uses: actions/setup-java@v5
83+
with:
84+
distribution: temurin
85+
java-version: ${{ steps.line.outputs.java }}
86+
cache: maven
87+
- name: Record exact source and actual tools
88+
run: |
89+
mkdir -p .contract-evidence
90+
git rev-parse HEAD > .contract-evidence/head.txt
91+
java -version > .contract-evidence/java.txt 2>&1
92+
bash ./mvnw -version > .contract-evidence/maven.txt 2>&1
93+
- name: Full clean verify using this branch wrapper
94+
id: verify
95+
shell: bash
96+
run: |
97+
set +e
98+
bash ./mvnw -B --no-transfer-progress clean verify > .contract-evidence/maven.log 2>&1
99+
result=$?
100+
printf '%s\n' "$result" > .contract-evidence/maven.exit
101+
tail -100 .contract-evidence/maven.log
102+
exit "$result"
103+
- name: Require nonempty, non-skipped contract evidence
104+
if: always()
105+
shell: bash
106+
run: |
107+
test -f .contract-evidence/maven.exit
108+
python3 scripts/contract_report.py \
109+
--reports target/surefire-reports \
110+
--head "$(cat .contract-evidence/head.txt)" \
111+
--branch "$GITHUB_REF_NAME" \
112+
--java-version-file .contract-evidence/java.txt \
113+
--maven-version-file .contract-evidence/maven.txt \
114+
--exit-code "$(cat .contract-evidence/maven.exit)" \
115+
--suite argv=io.github.easy4j.opencli.contract.OpenCliArgvContractTest \
116+
--output .contract-evidence/report.json
117+
- name: Upload actual JVM evidence
118+
if: always()
119+
uses: actions/upload-artifact@v7
120+
with:
121+
name: contracts-jdk-${{ steps.line.outputs.java }}-${{ github.sha }}
122+
path: |
123+
.contract-evidence
124+
target/surefire-reports
125+
target/site/jacoco
126+
if-no-files-found: error
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# C10 foundation and C01 implementation ledger
2+
3+
## Approved ordering and scope
4+
5+
The user approved C10 foundation, then C01/C02/C03/C04/C05/C09, then C06/C07/C08, and only then C10 integration closure. `feature/2.0.x` is the canonical implementation line. This file records execution, not completion of the ten changes.
6+
7+
An isolated implementation branch starts at `d0c8056990f7a47fcc202acffa387ba066bcfc67`. The 1.x/3.x baseline refs and permitted JDK/Jackson/Maven differences are recorded in `src/test/resources/opencli-contracts/v1/sources.lock.json`. No changes to `main`, dependency versions or coverage thresholds are part of this increment.
8+
9+
## C10 foundation
10+
11+
Shared synthetic UTF-8/base64 argv vectors preserve empty and trailing empty fields. They have content hashes and a specification ref, not an invented upstream capture provenance. A Java 8-compatible child prints each actual argument. The regression suite uses the real SDK executor, adapter and Browser paths; a Recording executor is not used to prove process behavior.
12+
13+
The report runner separates enumeration, argv, protocol, typed-result and real-execution evidence. Only explicitly selected suites contribute; the real-execution layer remains NOT_RUN because a Java argv probe is not a live OpenCLI website test. Missing reports, zero tests, skipped required tests, nonzero Maven exit, malformed XML and inconsistent testcase counts fail closed.
14+
15+
Local runner TDD: 12 failures before the runner existed, then 12 passing tests. The probe compiled with `javac --release 8` on JDK 21 and emitted an empty token unchanged. This does not constitute an actual JDK 8 runtime test. Full JVM evidence is produced by the branch-specific GitHub Actions workflow using each line's checked-in Maven wrapper.
16+
17+
## C01 RED checkpoint
18+
19+
The initial Java contract suite deliberately demands lossless values before any product source is changed. The first CI run must be inspected for assertion failures at the real child boundary, not treated as a completed fix. Compilation errors or tool setup failures are not valid RED proof.
20+
21+
## Still open
22+
23+
C01 schema-aware repeated/false option handling, C02/C03/C04/C05/C09 production fixes, discovery, Browser result models, context/diagnostics and three-branch integration closure are not complete. Official OpenSpec strict is configured but must be observed at the exact workflow run before claiming it passed. No OpenSpec implementation tasks are pre-checked.

‎scripts/contract_report.py‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env python3
2+
"""Build fail-closed, layer-specific evidence from real Surefire XML reports.
3+
4+
This is not a code coverage percentage or a claim that OpenCLI websites work.
5+
Only explicitly selected suites contribute to a layer. No network is used.
6+
"""
7+
import argparse
8+
import json
9+
import pathlib
10+
import re
11+
import sys
12+
import xml.etree.ElementTree as ET
13+
14+
LAYERS = ('enumeration', 'argv', 'protocol', 'typed-result', 'real-execution')
15+
16+
17+
def _suite_summary(suite):
18+
declared = {key: int(suite.attrib.get(key, '0')) for key in ('tests', 'failures', 'errors', 'skipped')}
19+
cases = suite.findall('testcase')
20+
actual = {'tests': len(cases), 'failures': sum(c.find('failure') is not None for c in cases),
21+
'errors': sum(c.find('error') is not None for c in cases),
22+
'skipped': sum(c.find('skipped') is not None for c in cases)}
23+
if declared != actual or any(v < 0 for v in declared.values()):
24+
raise ValueError('declared testcase counts disagree with XML evidence')
25+
actual['executed'] = actual['tests'] - actual['skipped']
26+
return actual
27+
28+
29+
def build_report(reports_dir, *, head, branch, java_version, maven_version, exit_code, required_suites):
30+
"""Return a JSON-serializable report; malformed/missing evidence fails closed."""
31+
problems = []
32+
if not re.fullmatch(r'[0-9a-fA-F]{40}', head or ''):
33+
problems.append('exact 40-character source HEAD is required')
34+
if not branch or not java_version.strip() or not maven_version.strip():
35+
problems.append('branch and actual tool versions are required')
36+
if exit_code != 0:
37+
problems.append('verification command exited nonzero')
38+
if not required_suites or any(layer not in LAYERS or not names for layer, names in required_suites.items()):
39+
problems.append('non-empty required suites must use recognized layers')
40+
suites = {}
41+
for path in sorted(pathlib.Path(reports_dir).glob('TEST-*.xml')):
42+
try:
43+
root = ET.parse(path).getroot()
44+
nodes = [root] if root.tag == 'testsuite' else list(root.findall('testsuite'))
45+
if not nodes:
46+
raise ValueError('no testsuite')
47+
for suite in nodes:
48+
name = suite.attrib['name']
49+
if name in suites:
50+
raise ValueError('duplicate testsuite')
51+
suites[name] = _suite_summary(suite)
52+
except (ET.ParseError, OSError, KeyError, ValueError):
53+
# Do not copy testcase failure bodies or captured application output.
54+
problems.append('invalid or duplicate Surefire report: ' + path.name)
55+
layers = {}
56+
for layer in LAYERS:
57+
names = list(required_suites.get(layer, []))
58+
counts = dict.fromkeys(('tests', 'executed', 'skipped', 'failures', 'errors'), 0)
59+
missing = [name for name in names if name not in suites]
60+
for name in names:
61+
for key in counts:
62+
counts[key] += suites.get(name, {}).get(key, 0)
63+
state = 'NOT_RUN'
64+
if names:
65+
state = 'PASS' if (not missing and counts['executed'] > 0 and
66+
not any(counts[key] for key in ('skipped', 'failures', 'errors'))) else 'FAIL'
67+
layers[layer] = dict(counts, status=state, requiredSuites=names, missingSuites=missing)
68+
if state == 'FAIL':
69+
problems.append('required layer lacks passing, non-skipped execution: ' + layer)
70+
return {'schemaVersion': 1, 'status': 'FAIL' if problems else 'PASS',
71+
'head': head, 'branch': branch, 'javaVersion': java_version,
72+
'mavenVersion': maven_version, 'commandExitCode': exit_code,
73+
'layers': layers, 'problems': problems,
74+
'scope': 'Explicit Surefire suites only; synthetic argv probes are not live OpenCLI verification.'}
75+
76+
77+
def main(argv=None):
78+
parser = argparse.ArgumentParser(description=__doc__)
79+
parser.add_argument('--reports', type=pathlib.Path, required=True)
80+
parser.add_argument('--head', required=True)
81+
parser.add_argument('--branch', required=True)
82+
parser.add_argument('--java-version-file', type=pathlib.Path, required=True)
83+
parser.add_argument('--maven-version-file', type=pathlib.Path, required=True)
84+
parser.add_argument('--exit-code', type=int, required=True)
85+
parser.add_argument('--suite', action='append', required=True, help='layer=fully.qualified.TestClass; repeatable')
86+
parser.add_argument('--output', type=pathlib.Path, required=True)
87+
args = parser.parse_args(argv)
88+
required = {}
89+
for item in args.suite:
90+
layer, separator, name = item.partition('=')
91+
if not separator or layer not in LAYERS or not name:
92+
parser.error('--suite must be recognized-layer=fully.qualified.TestClass')
93+
if name in required.setdefault(layer, []):
94+
parser.error('duplicate --suite')
95+
required[layer].append(name)
96+
try:
97+
report = build_report(args.reports, head=args.head, branch=args.branch,
98+
java_version=args.java_version_file.read_text(encoding='utf-8'),
99+
maven_version=args.maven_version_file.read_text(encoding='utf-8'),
100+
exit_code=args.exit_code, required_suites=required)
101+
except OSError as exc:
102+
print('Missing tool-version evidence: ' + exc.__class__.__name__, file=sys.stderr)
103+
return 1
104+
args.output.parent.mkdir(parents=True, exist_ok=True)
105+
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
106+
print(json.dumps({'status': report['status'], 'head': report['head'], 'layers': report['layers']}))
107+
return 0 if report['status'] == 'PASS' else 1
108+
109+
110+
if __name__ == '__main__':
111+
sys.exit(main())
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import importlib.util
2+
import json
3+
import pathlib
4+
import tempfile
5+
import unittest
6+
7+
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'contract_report.py'
8+
HEAD = 'd0c8056990f7a47fcc202acffa387ba066bcfc67'
9+
SUITE = 'io.github.easy4j.opencli.contract.OpenCliArgvContractTest'
10+
11+
12+
class ContractReportTest(unittest.TestCase):
13+
def setUp(self):
14+
self.assertTrue(SCRIPT.is_file(), 'C10 contract report runner is not implemented')
15+
spec = importlib.util.spec_from_file_location('contract_report', SCRIPT)
16+
self.module = importlib.util.module_from_spec(spec)
17+
spec.loader.exec_module(self.module)
18+
self.temp = tempfile.TemporaryDirectory()
19+
self.addCleanup(self.temp.cleanup)
20+
self.root = pathlib.Path(self.temp.name)
21+
22+
def xml(self, tests=2, skipped=0, failures=0, errors=0):
23+
executed = tests - skipped
24+
cases = []
25+
for i in range(tests):
26+
child = '<skipped/>' if i >= executed else ('<failure/>' if i < failures else ('<error/>' if i < failures + errors else ''))
27+
cases.append('<testcase name="case' + str(i) + '">' + child + '</testcase>')
28+
(self.root / ('TEST-' + SUITE + '.xml')).write_text(
29+
'<testsuite name="' + SUITE + '" tests="' + str(tests) + '" skipped="' + str(skipped)
30+
+ '" failures="' + str(failures) + '" errors="' + str(errors) + '">' + ''.join(cases) + '</testsuite>', encoding='utf-8')
31+
32+
def report(self, **kwargs):
33+
return self.module.build_report(self.root, head=kwargs.get('head', HEAD),
34+
branch='feature/2.0.x', java_version='openjdk 17 (test fixture)',
35+
maven_version='Apache Maven (test fixture)', exit_code=kwargs.get('exit_code', 0),
36+
required_suites={'argv': [SUITE]})
37+
38+
def test_complete_argv_evidence_passes_but_live_is_not_run(self):
39+
self.xml()
40+
report = self.report()
41+
self.assertEqual('PASS', report['status'])
42+
self.assertEqual(2, report['layers']['argv']['executed'])
43+
self.assertEqual('NOT_RUN', report['layers']['real-execution']['status'])
44+
self.assertEqual(HEAD, report['head'])
45+
json.dumps(report)
46+
47+
def test_missing_report_fails(self):
48+
self.assertEqual('FAIL', self.report()['status'])
49+
50+
def test_zero_tests_fails(self):
51+
self.xml(tests=0)
52+
self.assertEqual('FAIL', self.report()['status'])
53+
54+
def test_all_skipped_fails(self):
55+
self.xml(tests=2, skipped=2)
56+
self.assertEqual('FAIL', self.report()['status'])
57+
58+
def test_partly_skipped_fails(self):
59+
self.xml(tests=2, skipped=1)
60+
self.assertEqual('FAIL', self.report()['status'])
61+
62+
def test_failure_fails(self):
63+
self.xml(failures=1)
64+
self.assertEqual('FAIL', self.report()['status'])
65+
66+
def test_error_fails(self):
67+
self.xml(errors=1)
68+
self.assertEqual('FAIL', self.report()['status'])
69+
70+
def test_nonzero_command_exit_fails_despite_green_xml(self):
71+
self.xml()
72+
self.assertEqual('FAIL', self.report(exit_code=1)['status'])
73+
74+
def test_malformed_xml_fails_closed(self):
75+
(self.root / 'TEST-broken.xml').write_text('<broken', encoding='utf-8')
76+
self.assertEqual('FAIL', self.report()['status'])
77+
78+
def test_partial_sha_is_not_accepted(self):
79+
self.xml()
80+
self.assertEqual('FAIL', self.report(head='d0c8056')['status'])
81+
82+
def test_declared_counts_cannot_hide_missing_testcases(self):
83+
self.xml()
84+
path = self.root / ('TEST-' + SUITE + '.xml')
85+
path.write_text(path.read_text().replace('tests="2"', 'tests="100"'), encoding='utf-8')
86+
self.assertEqual('FAIL', self.report()['status'])
87+
88+
def test_undeclared_failure_fails(self):
89+
self.xml()
90+
path = self.root / ('TEST-' + SUITE + '.xml')
91+
path.write_text(path.read_text().replace('</testcase>', '<failure/></testcase>', 1), encoding='utf-8')
92+
self.assertEqual('FAIL', self.report()['status'])
93+
94+
95+
if __name__ == '__main__':
96+
unittest.main()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.github.easy4j.opencli.contract;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.util.Base64;
5+
6+
/** Offline child process: emits every actual JVM argument, including empty values. */
7+
public final class ContractProbe {
8+
private ContractProbe() { }
9+
10+
public static void main(String[] args) {
11+
System.out.println("argc:" + args.length);
12+
for (String arg : args) {
13+
System.out.println("arg:" + Base64.getEncoder().encodeToString(arg.getBytes(StandardCharsets.UTF_8)));
14+
}
15+
}
16+
}

0 commit comments

Comments
 (0)