aero-agent-roles 1.2.2 → 1.2.3

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 (27) hide show
  1. package/manifest.json +17 -3
  2. package/package.json +1 -1
  3. package/roles/software-product-assurance-engineer/ROLE.md +243 -0
  4. package/roles/software-product-assurance-engineer/SOURCES.md +40 -0
  5. package/roles/software-product-assurance-engineer/cli.py +542 -0
  6. package/roles/software-product-assurance-engineer/core/software_product_assurance_core.py +2108 -0
  7. package/roles/software-product-assurance-engineer/example/docs/EX-AUDIT-2026-01.md +7 -0
  8. package/roles/software-product-assurance-engineer/example/docs/EX-CSTD-001.md +7 -0
  9. package/roles/software-product-assurance-engineer/example/docs/EX-DSA-001.md +7 -0
  10. package/roles/software-product-assurance-engineer/example/docs/EX-DSTD-001.md +7 -0
  11. package/roles/software-product-assurance-engineer/example/docs/EX-RB-001.md +7 -0
  12. package/roles/software-product-assurance-engineer/example/docs/EX-SCMP-001.md +7 -0
  13. package/roles/software-product-assurance-engineer/example/docs/EX-SDP-001.md +7 -0
  14. package/roles/software-product-assurance-engineer/example/docs/EX-SPAP-001.md +7 -0
  15. package/roles/software-product-assurance-engineer/example/docs/EX-SPAR-2026-Q3.md +7 -0
  16. package/roles/software-product-assurance-engineer/example/docs/EX-SPR-LOG.md +7 -0
  17. package/roles/software-product-assurance-engineer/example/docs/EX-SRF-001.md +7 -0
  18. package/roles/software-product-assurance-engineer/example/docs/EX-SVALP-001.md +7 -0
  19. package/roles/software-product-assurance-engineer/example/docs/EX-SVERP-001.md +7 -0
  20. package/roles/software-product-assurance-engineer/example/docs/EX-TS-001.md +7 -0
  21. package/roles/software-product-assurance-engineer/example/evidence.csv +251 -0
  22. package/roles/software-product-assurance-engineer/example/metrics.csv +11 -0
  23. package/roles/software-product-assurance-engineer/example/pdr-pack.csv +17 -0
  24. package/roles/software-product-assurance-engineer/templates/q80-compliance-matrix-template.md +572 -0
  25. package/roles/software-product-assurance-engineer/tests/test_role_software_product_assurance_engineer.py +202 -0
  26. package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_cli.py +271 -0
  27. package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_core.py +570 -0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """Role test: Software Product Assurance Engineer (ECSS-Q-ST-80C).
3
+
4
+ Verifies (offline, no network):
5
+ 1. Every skill bound in ROLE.md frontmatter RESOLVES to a real leaf in the
6
+ aero-agent-skills repo, and every bound leaf ships a logic file the
7
+ role can dispatch.
8
+ 2. The workflow stage order is deterministic and complete.
9
+ 3. The filled deliverable template carries no blanks, the draft and
10
+ no-approval markers, ends at the stop line, and is byte-identical to a
11
+ fresh build of the bundled worked example (no stale template).
12
+ 4. The role ships the 100% anatomy (core engine, cli, SOURCES, example),
13
+ spells out its abbreviations, carries no machine paths, and the author
14
+ is ashfordeOU.
15
+ """
16
+ import os
17
+ import re
18
+ import sys
19
+ import unittest
20
+
21
+ ROLES_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
22
+ os.path.abspath(__file__))))) # repo root (4 up from tests/)
23
+ # Bound-skill resolution is a cross-repo dev check: it runs when
24
+ # AEROSKILLS_DEV points at an aero-agent-skills checkout, and skips
25
+ # otherwise (role-lint resolves the same list against the pinned ledger).
26
+ AEROSKILLS = os.environ.get("AEROSKILLS_DEV", "")
27
+ HAS_SKILLS = bool(AEROSKILLS) and os.path.isdir(os.path.join(AEROSKILLS,
28
+ "skills"))
29
+ ROLE_DIR = os.path.join(ROLES_REPO, "roles",
30
+ "software-product-assurance-engineer")
31
+ TEMPLATE = os.path.join(ROLE_DIR, "templates",
32
+ "q80-compliance-matrix-template.md")
33
+ sys.path.insert(0, os.path.join(ROLE_DIR, "core"))
34
+
35
+ EXPECTED_BOUND = [
36
+ "space-systems/ecss/q80-software-criticality-tailoring",
37
+ "space-systems/ecss/q80-compliance-matrix",
38
+ "space-systems/ecss/q80-milestone-assurance-evidence",
39
+ "space-systems/ecss/q80-software-product-assurance-plan",
40
+ "space-systems/ecss/q80-software-process-assurance",
41
+ "space-systems/ecss/q80-software-product-quality-metrics",
42
+ "space-systems/ecss/software-engineering",
43
+ "space-systems/ecss/software-verification",
44
+ ]
45
+
46
+ EXPECTED_STAGES = [
47
+ "1. Category", "2. Tailoring", "3. Evidence index",
48
+ "4. Compliance matrix", "5. Document trace", "6. Gap routing",
49
+ "7. Milestone evidence", "8. Milestone report skeleton",
50
+ "9. Product metrics", "10. Engineering context", "11. Stop gate",
51
+ ]
52
+
53
+ ABBREVIATIONS = {
54
+ "PA": "product assurance",
55
+ "ECSS": "European Cooperation for Space Standardization",
56
+ "SRR": "system requirements review",
57
+ "PDR": "preliminary design review",
58
+ "CDR": "critical design review",
59
+ "QR": "qualification review",
60
+ "AR": "acceptance review",
61
+ "TRR": "test readiness review",
62
+ "ORR": "operational readiness review",
63
+ "SPAP": "software product assurance plan",
64
+ "SPAMR": "software product assurance milestone report",
65
+ "CSV": "comma-separated values",
66
+ }
67
+
68
+
69
+ def read_role_md():
70
+ with open(os.path.join(ROLE_DIR, "ROLE.md")) as f:
71
+ return f.read()
72
+
73
+
74
+ def body_of(text):
75
+ """The ROLE.md body with line wraps folded to single spaces."""
76
+ return re.sub(r"\s+", " ", text.split("\n---\n", 1)[1])
77
+
78
+
79
+ class TestSoftwareProductAssuranceRole(unittest.TestCase):
80
+
81
+ def test_bound_skills_resolve_in_aeroskills(self):
82
+ if not HAS_SKILLS:
83
+ self.skipTest("Aero Agent Skills checkout not present "
84
+ "(cross-repo dev check)")
85
+ for leaf in EXPECTED_BOUND:
86
+ sk = os.path.join(AEROSKILLS, "skills", leaf, "SKILL.md")
87
+ self.assertTrue(os.path.exists(sk),
88
+ "bound skill not found: %s" % leaf)
89
+ scripts = os.path.join(AEROSKILLS, "skills", leaf, "scripts")
90
+ logic = [f for f in os.listdir(scripts)
91
+ if f.endswith("_logic.py") and not f.startswith("test_")]
92
+ self.assertTrue(logic, "bound leaf has no logic: %s" % leaf)
93
+
94
+ def test_bound_skills_are_exactly_the_frontmatter_list(self):
95
+ fm = read_role_md().split("\n---\n", 1)[0]
96
+ block = fm.split("skills_bound:", 1)[1].split("\ntools_allowed:")[0]
97
+ listed = re.findall(r"^\s+-\s+(\S+)\s*$", block, re.M)
98
+ self.assertEqual(listed, EXPECTED_BOUND)
99
+
100
+ def test_workflow_stages_deterministic(self):
101
+ role = read_role_md()
102
+ wf = role.split("## Workflow", 1)[1].split("\n## ", 1)[0]
103
+ idx = [wf.find("| " + stage + " |") for stage in EXPECTED_STAGES]
104
+ self.assertTrue(all(i >= 0 for i in idx),
105
+ "a stage is missing: %s" % idx)
106
+ self.assertEqual(idx, sorted(idx), "stage order not deterministic")
107
+
108
+ def test_every_bound_leaf_has_a_stage(self):
109
+ wf = read_role_md().split("## Workflow", 1)[1].split("\n## ", 1)[0]
110
+ for leaf in EXPECTED_BOUND:
111
+ self.assertIn(leaf.rsplit("/", 1)[-1], wf,
112
+ "no workflow stage loads %s" % leaf)
113
+
114
+ def test_deliverable_template_complete(self):
115
+ with open(TEMPLATE) as f:
116
+ text = f.read()
117
+ for n in range(1, 12):
118
+ self.assertTrue(re.search(r"^## %d\. " % n, text, re.M),
119
+ "section %d missing" % n)
120
+ self.assertEqual(len(re.findall(r"___|TODO|TBD", text)), 0,
121
+ "template must be a FILLED deliverable (0 blanks)")
122
+ low = text.lower()
123
+ for marker in ("draft", "not an approval", "not a certification",
124
+ "not a statement of compliance", "ecss-q-st-80c rev.2"):
125
+ self.assertIn(marker, low)
126
+ self.assertEqual(text.rstrip().splitlines()[-1],
127
+ "STOP: human sign-off required before submission.")
128
+
129
+ def test_template_is_a_fresh_build_of_the_example(self):
130
+ import software_product_assurance_core as core
131
+ with open(TEMPLATE) as f:
132
+ self.assertEqual(f.read(), core.example_report_markdown(),
133
+ "template is stale: regenerate it with "
134
+ "python3 cli.py build --no-dispatch > "
135
+ "templates/q80-compliance-matrix-template.md")
136
+
137
+ def test_forbidden_lines_present(self):
138
+ role = read_role_md()
139
+ for phrase in ["mark a compliance matrix approved or signed",
140
+ "issue a statement of compliance",
141
+ "sign_off_required: true",
142
+ "reproduce proprietary standard text"]:
143
+ self.assertIn(phrase, role, "missing boundary: %s" % phrase)
144
+
145
+ def test_abbreviations_spelled_out_on_first_use(self):
146
+ body = body_of(read_role_md())
147
+ for abbr, words in ABBREVIATIONS.items():
148
+ # a standard's designation (ECSS-Q-ST-80C) is a name, not a use
149
+ first = re.search(r"\b%s\b(?!-)" % abbr, body)
150
+ self.assertIsNotNone(first, "%s not used" % abbr)
151
+ spelled = body.lower().find(words.lower())
152
+ self.assertGreaterEqual(spelled, 0, "%s never spelled out" % abbr)
153
+ self.assertLess(spelled, first.start(),
154
+ "%s used before it is spelled out" % abbr)
155
+
156
+ def test_sources_register_exists(self):
157
+ with open(os.path.join(ROLE_DIR, "SOURCES.md")) as f:
158
+ text = f.read()
159
+ self.assertIn("ECSS-Q-ST-80C Rev.2", text)
160
+ self.assertIn("paraphrased", text)
161
+
162
+ def test_core_engine_and_example_exist(self):
163
+ for f in ["core/software_product_assurance_core.py", "cli.py",
164
+ "templates", "example/evidence.csv", "example/docs",
165
+ "example/pdr-pack.csv", "example/metrics.csv"]:
166
+ self.assertTrue(os.path.exists(os.path.join(ROLE_DIR, f)),
167
+ "%s missing" % f)
168
+
169
+ def test_example_files_are_marked(self):
170
+ docs = os.path.join(ROLE_DIR, "example", "docs")
171
+ for name in os.listdir(docs):
172
+ self.assertTrue(name.startswith("EX-"), name)
173
+ with open(os.path.join(docs, name)) as f:
174
+ self.assertIn("EXAMPLE STUB", f.read())
175
+
176
+ def test_no_machine_paths_shipped(self):
177
+ # built from pieces so this file does not match its own pattern
178
+ pat = re.compile("|".join(["/" + "Users/", "/" + "home/[a-z]",
179
+ "/" + "Volumes/"]))
180
+ for root, _dirs, files in os.walk(ROLE_DIR):
181
+ if "__pycache__" in root:
182
+ continue
183
+ for name in files:
184
+ if name.endswith((".md", ".csv", ".py")):
185
+ with open(os.path.join(root, name)) as f:
186
+ self.assertIsNone(pat.search(f.read()),
187
+ "machine path in %s" % name)
188
+
189
+ def test_author_ashfordeou(self):
190
+ self.assertIn("author: ashfordeOU", read_role_md())
191
+
192
+ def test_no_approval_claims_in_template(self):
193
+ with open(TEMPLATE) as f:
194
+ low = f.read().lower()
195
+ for phrase in ["signed-off-by", "status: approved", "status: signed",
196
+ "is compliant with ecss"]:
197
+ self.assertNotIn(phrase, low,
198
+ "template must not claim approval: %s" % phrase)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ unittest.main()
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env python3
2
+ """CLI test: software-product-assurance-engineer build / check end to end.
3
+
4
+ Runs cli.py as a user would (offline, no network): the bundled example,
5
+ a real-project run from a temporary evidence file and document folder,
6
+ the input errors, the evidence bundle and profile header, the check of a
7
+ draft and of a human-signed matrix, and the cross-check against the bound
8
+ q80 leaves (dispatched when AEROSKILLS_DEV points at the skills, and shown
9
+ to catch a disagreement; otherwise the not-dispatched message must say
10
+ how to point AEROSKILLS_DEV at an installed aero-agent-skills package).
11
+ """
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import unittest
19
+
20
+ ROLE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
+ CLI = os.path.join(ROLE_DIR, "cli.py")
22
+ REPO_ROOT = os.path.dirname(os.path.dirname(ROLE_DIR))
23
+ PROFILE = os.path.join(REPO_ROOT, "profiles", "example-airframer.json")
24
+ SKILLS = os.environ.get("AEROSKILLS_DEV", "")
25
+ HAS_Q80 = bool(SKILLS) and os.path.isdir(os.path.join(
26
+ SKILLS, "skills", "space-systems", "ecss", "q80-compliance-matrix"))
27
+ STOP = "STOP: human sign-off required before submission."
28
+
29
+ EVIDENCE = (
30
+ "clause,document,section,status,justification\n"
31
+ "5.3.1,PLAN-001,section 6,compliant,\n"
32
+ "5.2.3,PLAN-001,section 4,partially-compliant,audits start at CDR\n"
33
+ "6.2.6.5,REPORT-009,section 3,compliant,\n"
34
+ "7.1.1,,,compliant,in the specification\n")
35
+
36
+
37
+ def run(args, env_skills=None, cwd=None):
38
+ env = dict(os.environ)
39
+ if env_skills is not None:
40
+ env["AEROSKILLS_DEV"] = env_skills
41
+ return subprocess.run([sys.executable, CLI] + args, capture_output=True,
42
+ text=True, env=env, cwd=cwd)
43
+
44
+
45
+ class CliCase(unittest.TestCase):
46
+
47
+ def setUp(self):
48
+ self.tmp = tempfile.mkdtemp()
49
+
50
+ def tearDown(self):
51
+ shutil.rmtree(self.tmp, ignore_errors=True)
52
+
53
+ def path(self, *p):
54
+ return os.path.join(self.tmp, *p)
55
+
56
+ def write(self, name, text):
57
+ with open(self.path(name), "w") as f:
58
+ f.write(text)
59
+ return self.path(name)
60
+
61
+
62
+ class TestBuildExample(CliCase):
63
+
64
+ def test_example_builds_markdown_and_csv(self):
65
+ r = run(["build", "--out", self.path("out", "matrix.md")],
66
+ env_skills="/nonexistent")
67
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
68
+ self.assertTrue(os.path.exists(self.path("out", "matrix.csv")))
69
+ self.assertIn("worked example", r.stdout)
70
+ self.assertEqual(r.stdout.rstrip().splitlines()[-1], STOP)
71
+ with open(self.path("out", "matrix.md")) as f:
72
+ self.assertEqual(f.read().rstrip().splitlines()[-1], STOP)
73
+
74
+ def test_check_passes_on_a_fresh_build(self):
75
+ run(["build", "--out", self.path("m.md")], env_skills="/nonexistent")
76
+ r = run(["check", "--file", self.path("m.md")])
77
+ self.assertEqual(r.returncode, 0, r.stdout)
78
+ self.assertIn("SIGN-OFF: none recorded", r.stdout)
79
+ self.assertIn("RESULT: PASS", r.stdout)
80
+
81
+ def test_stdout_mode_prints_the_matrix(self):
82
+ r = run(["build", "--no-dispatch"])
83
+ self.assertEqual(r.returncode, 0, r.stderr)
84
+ self.assertTrue(r.stdout.startswith("# Software Product Assurance "
85
+ "Compliance Matrix"))
86
+
87
+ def test_bundle_records_honest_provenance(self):
88
+ r = run(["build", "--out", self.path("m.md"), "--bundle"],
89
+ env_skills="/nonexistent")
90
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
91
+ with open(self.path("evidence", "provenance.json")) as f:
92
+ prov = json.load(f)
93
+ self.assertFalse(prov["cross_checked"])
94
+ self.assertEqual(prov["skills"], [])
95
+ self.assertIn("AEROSKILLS_DEV", prov["skills_not_dispatched_reason"])
96
+ with open(self.path("evidence", "model.json")) as f:
97
+ model = json.load(f)
98
+ self.assertEqual(model["status"], "DRAFT")
99
+ self.assertTrue(model["requires_human_sign_off"])
100
+
101
+ def test_profile_header(self):
102
+ if not os.path.exists(PROFILE):
103
+ self.skipTest("profiles/example-airframer.json not present")
104
+ r = run(["build", "--out", self.path("m.md"), "--profile", PROFILE],
105
+ env_skills="/nonexistent")
106
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
107
+ with open(self.path("m.md")) as f:
108
+ self.assertTrue(f.read().startswith("## Program context"))
109
+
110
+
111
+ class TestBuildProject(CliCase):
112
+
113
+ def test_real_project_with_trace(self):
114
+ ev = self.write("ev.csv", EVIDENCE)
115
+ os.makedirs(self.path("docs", "plans"))
116
+ self.write(os.path.join("docs", "plans", "PLAN-001.docx"), "x")
117
+ r = run(["build", "--out", self.path("m.md"), "--evidence", ev,
118
+ "--category", "C", "--docs", self.path("docs"),
119
+ "--review", "CDR", "--project", "Test craft"],
120
+ env_skills="/nonexistent")
121
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
122
+ self.assertIn("1 missing (REPORT-009)", r.stdout)
123
+ with open(self.path("m.md")) as f:
124
+ md = f.read()
125
+ self.assertIn("| REPORT-009 | 6.2.6.5 |", md)
126
+ self.assertIn("Project: Test craft", md)
127
+ self.assertIn("Evidence index: ev.csv (4 rows)", md)
128
+ self.assertIn("Document folder: docs/ (1 files)", md)
129
+ self.assertNotIn(self.tmp, md, "absolute path leaked into the matrix")
130
+ self.assertEqual(run(["check", "--file", self.path("m.md")])
131
+ .returncode, 0)
132
+
133
+ def test_severity_derives_the_category(self):
134
+ ev = self.write("ev.csv", EVIDENCE)
135
+ r = run(["build", "--out", self.path("m.md"), "--evidence", ev,
136
+ "--severity", "II", "--provision", "operational-procedure"],
137
+ env_skills="/nonexistent")
138
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
139
+ self.assertIn("software category C (derived from severity II)",
140
+ r.stdout)
141
+ self.assertIn("document trace: not run", r.stdout)
142
+
143
+ def test_customer_clause_list(self):
144
+ ev = self.write("ev.csv", EVIDENCE)
145
+ cl = self.write("list.csv", "clause\n5.3.1\n5.2.3\n")
146
+ r = run(["build", "--out", self.path("m.md"), "--evidence", ev,
147
+ "--category", "B", "--clauses", cl],
148
+ env_skills="/nonexistent")
149
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
150
+ self.assertIn("Coverage: 2 clauses", r.stdout)
151
+
152
+ def test_missing_category_is_an_input_error(self):
153
+ ev = self.write("ev.csv", EVIDENCE)
154
+ r = run(["build", "--out", self.path("m.md"), "--evidence", ev])
155
+ self.assertEqual(r.returncode, 2)
156
+ self.assertIn("--category", r.stderr)
157
+
158
+ def test_bad_status_is_an_input_error(self):
159
+ ev = self.write("ev.csv", "clause,status\n5.3.1,mostly\n")
160
+ r = run(["build", "--out", self.path("m.md"), "--evidence", ev,
161
+ "--category", "B"])
162
+ self.assertEqual(r.returncode, 2)
163
+ self.assertIn("unknown compliance status", r.stderr)
164
+
165
+ def test_missing_files_are_input_errors(self):
166
+ r = run(["build", "--evidence", self.path("none.csv"),
167
+ "--category", "B"])
168
+ self.assertEqual(r.returncode, 2)
169
+ ev = self.write("ev.csv", EVIDENCE)
170
+ r = run(["build", "--evidence", ev, "--category", "B", "--docs",
171
+ self.path("no-docs")])
172
+ self.assertEqual(r.returncode, 2)
173
+ r = run(["build", "--evidence", ev, "--category", "B", "--pack", ev])
174
+ self.assertEqual(r.returncode, 2)
175
+ self.assertIn("--review", r.stderr)
176
+
177
+
178
+ class TestCheck(CliCase):
179
+
180
+ def built(self):
181
+ run(["build", "--out", self.path("m.md")], env_skills="/nonexistent")
182
+ with open(self.path("m.md")) as f:
183
+ return f.read()
184
+
185
+ def test_human_signed_matrix_passes(self):
186
+ md = self.built()
187
+ gaps = [l for l in md.splitlines() if l.startswith("Open gaps: ")][0]
188
+ n = gaps.split()[2].rstrip(".")
189
+ self.write("m.md", md + "Signed-off-by: A. Person\n"
190
+ "Sign-off-role: software PA manager\n"
191
+ "Sign-off-date: 2026-10-01\nSign-off-decision: approved\n"
192
+ "Accepted-open-gaps: %s\n" % n)
193
+ r = run(["check", "--file", self.path("m.md")])
194
+ self.assertEqual(r.returncode, 0, r.stdout)
195
+ self.assertIn("SIGN-OFF: approved by A. Person", r.stdout)
196
+
197
+ def test_signature_without_gap_acceptance_fails(self):
198
+ md = self.built()
199
+ self.write("m.md", md + "Signed-off-by: A. Person\n"
200
+ "Sign-off-role: software PA manager\n"
201
+ "Sign-off-date: 2026-10-01\nSign-off-decision: approved\n")
202
+ r = run(["check", "--file", self.path("m.md")])
203
+ self.assertEqual(r.returncode, 1)
204
+ self.assertIn("sign-off problem", r.stdout)
205
+
206
+ def test_tampered_matrix_fails(self):
207
+ md = self.built().replace(STOP, "")
208
+ self.write("m.md", md)
209
+ r = run(["check", "--file", self.path("m.md")])
210
+ self.assertEqual(r.returncode, 1)
211
+ self.assertIn("FAIL sign_off_honest", r.stdout)
212
+
213
+ def test_missing_file(self):
214
+ self.assertEqual(run(["check", "--file", self.path("x.md")])
215
+ .returncode, 1)
216
+
217
+
218
+ class TestCrossCheck(CliCase):
219
+
220
+ def test_not_dispatched_says_how_to_point_at_the_skills(self):
221
+ r = run(["build", "--out", self.path("m.md")], env_skills="")
222
+ self.assertEqual(r.returncode, 0)
223
+ self.assertIn("not dispatched (AEROSKILLS_DEV is not set)", r.stdout)
224
+ self.assertIn('export AEROSKILLS_DEV="$(npm root)/aero-agent-skills"',
225
+ r.stdout)
226
+
227
+ def test_required_cross_check_fails_loud_without_skills(self):
228
+ r = run(["build", "--out", self.path("m.md"),
229
+ "--require-cross-check"], env_skills="/nonexistent")
230
+ self.assertEqual(r.returncode, 2)
231
+ self.assertIn("npm install aero-agent-skills", r.stderr)
232
+ self.assertFalse(os.path.exists(self.path("m.md")))
233
+
234
+ def test_dispatch_agrees_with_the_bound_leaves(self):
235
+ if not HAS_Q80:
236
+ self.skipTest("AEROSKILLS_DEV with the q80 leaves not present")
237
+ r = run(["build", "--out", self.path("m.md"), "--bundle",
238
+ "--require-cross-check"])
239
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
240
+ with open(self.path("evidence", "provenance.json")) as f:
241
+ prov = json.load(f)
242
+ self.assertTrue(prov["cross_checked"])
243
+ leaves = set(row["leaf"].rsplit("/", 1)[-1] for row in prov["skills"])
244
+ for leaf in ("q80-compliance-matrix",
245
+ "q80-software-criticality-tailoring",
246
+ "q80-milestone-assurance-evidence",
247
+ "q80-software-product-assurance-plan",
248
+ "q80-software-product-quality-metrics"):
249
+ self.assertIn(leaf, leaves)
250
+
251
+ def test_dispatch_catches_a_disagreement(self):
252
+ if not HAS_Q80:
253
+ self.skipTest("AEROSKILLS_DEV with the q80 leaves not present")
254
+ sys.path.insert(0, ROLE_DIR)
255
+ import cli # noqa: E402
256
+ core = cli.core
257
+ item = core.example_item()
258
+ model = core.build_report(item)
259
+ _root, mods, _reason = cli.skills_status()
260
+ clean = cli.dispatch_rows(item, model, mods)
261
+ self.assertTrue(clean and all(r["agrees"] for r in clean))
262
+ model["metrics"]["verdict"] = "pass" # a wrong engine answer
263
+ model["tailoring"]["totals"]["reduced"] += 1
264
+ bad = [r for r in cli.dispatch_rows(item, model, mods)
265
+ if not r["agrees"]]
266
+ self.assertEqual(sorted(r["function"] for r in bad),
267
+ ["evaluate_metrics", "tailoring_matrix"])
268
+
269
+
270
+ if __name__ == "__main__":
271
+ unittest.main()