rainskills 0.1.0-rc.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/LICENSE +201 -0
- package/README.md +374 -0
- package/bin/rainskills.js +71 -0
- package/install.sh +1919 -0
- package/package.json +55 -0
- package/rainbond-app-assistant/SKILL.md +1319 -0
- package/rainbond-app-assistant/evals/01-linked-topology-missing.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/01-linked-topology-missing.response.md +53 -0
- package/rainbond-app-assistant/evals/02-linked-topology-building.expected.yaml +19 -0
- package/rainbond-app-assistant/evals/02-linked-topology-building.response.md +53 -0
- package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.expected.yaml +19 -0
- package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.response.md +53 -0
- package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.response.md +58 -0
- package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.response.md +80 -0
- package/rainbond-app-assistant/evals/06-manual-validation-stop.expected.yaml +16 -0
- package/rainbond-app-assistant/evals/06-manual-validation-stop.response.md +58 -0
- package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.expected.yaml +15 -0
- package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.response.md +53 -0
- package/rainbond-app-assistant/evals/08-code-build-handoff-stop.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/08-code-build-handoff-stop.response.md +56 -0
- package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.expected.yaml +22 -0
- package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.response.md +56 -0
- package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.response.md +45 -0
- package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.expected.yaml +28 -0
- package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.response.md +64 -0
- package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.expected.yaml +24 -0
- package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.response.md +45 -0
- package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.expected.yaml +28 -0
- package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.response.md +53 -0
- package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.expected.yaml +22 -0
- package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.response.md +53 -0
- package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.expected.yaml +23 -0
- package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.response.md +53 -0
- package/rainbond-app-assistant/schemas/app-assistant-result.schema.yaml +279 -0
- package/rainbond-app-assistant/scripts/run_app_assistant_evals.py +80 -0
- package/rainbond-app-assistant/scripts/validate_app_assistant_output.py +935 -0
- package/rainbond-app-version-assistant/SKILL.md +444 -0
- package/rainbond-delivery-verifier/SKILL.md +372 -0
- package/rainbond-delivery-verifier/evals/01-delivered-verified.expected.yaml +19 -0
- package/rainbond-delivery-verifier/evals/01-delivered-verified.response.md +31 -0
- package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.expected.yaml +19 -0
- package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.response.md +31 -0
- package/rainbond-delivery-verifier/evals/03-partially-delivered-building.expected.yaml +20 -0
- package/rainbond-delivery-verifier/evals/03-partially-delivered-building.response.md +31 -0
- package/rainbond-delivery-verifier/evals/04-blocked-capacity.expected.yaml +18 -0
- package/rainbond-delivery-verifier/evals/04-blocked-capacity.response.md +31 -0
- package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.expected.yaml +20 -0
- package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.response.md +31 -0
- package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.expected.yaml +19 -0
- package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.response.md +31 -0
- package/rainbond-delivery-verifier/schemas/delivery-verification-result.schema.yaml +84 -0
- package/rainbond-delivery-verifier/scripts/run_delivery_verifier_evals.py +78 -0
- package/rainbond-delivery-verifier/scripts/validate_delivery_verifier_output.py +728 -0
- package/rainbond-env-sync/SKILL.md +496 -0
- package/rainbond-fullstack-bootstrap/SKILL.md +262 -0
- package/rainbond-fullstack-bootstrap/evals/01-reuse-only.expected.yaml +27 -0
- package/rainbond-fullstack-bootstrap/evals/01-reuse-only.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/01-reuse-only.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.expected.yaml +27 -0
- package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.response.md +44 -0
- package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.expected.yaml +26 -0
- package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/04-template-skip.expected.yaml +31 -0
- package/rainbond-fullstack-bootstrap/evals/04-template-skip.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/04-template-skip.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.expected.yaml +33 -0
- package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.expected.yaml +23 -0
- package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.response.md +39 -0
- package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.expected.yaml +29 -0
- package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.expected.yaml +30 -0
- package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.expected.yaml +32 -0
- package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.response.md +41 -0
- package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.expected.yaml +34 -0
- package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.expected.yaml +46 -0
- package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.response.md +48 -0
- package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.expected.yaml +43 -0
- package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.response.md +63 -0
- package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.expected.yaml +47 -0
- package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.response.md +60 -0
- package/rainbond-fullstack-bootstrap/modules/10-context-loading.md +157 -0
- package/rainbond-fullstack-bootstrap/modules/20-scope-and-boundaries.md +105 -0
- package/rainbond-fullstack-bootstrap/modules/30-creation-rules.md +337 -0
- package/rainbond-fullstack-bootstrap/modules/40-source-and-package-rules.md +245 -0
- package/rainbond-fullstack-bootstrap/modules/50-workflow-and-convergence.md +198 -0
- package/rainbond-fullstack-bootstrap/modules/60-verification-and-handoffs.md +143 -0
- package/rainbond-fullstack-bootstrap/modules/70-output-contract.md +152 -0
- package/rainbond-fullstack-bootstrap/references/manifest-v1-reference.md +150 -0
- package/rainbond-fullstack-bootstrap/references/quick-reference.md +101 -0
- package/rainbond-fullstack-bootstrap/references/source-build-parameter-guide.md +264 -0
- package/rainbond-fullstack-bootstrap/references/sql-init-recipe.md +83 -0
- package/rainbond-fullstack-bootstrap/schemas/bootstrap-result.schema.yaml +217 -0
- package/rainbond-fullstack-bootstrap/scripts/run_bootstrap_evals.py +74 -0
- package/rainbond-fullstack-bootstrap/scripts/validate_bootstrap_output.py +615 -0
- package/rainbond-fullstack-troubleshooter/SKILL.md +870 -0
- package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.expected.yaml +29 -0
- package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.response.md +55 -0
- package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.expected.yaml +20 -0
- package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.response.md +53 -0
- package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.expected.yaml +21 -0
- package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.response.md +59 -0
- package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.expected.yaml +19 -0
- package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.response.md +58 -0
- package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.expected.yaml +18 -0
- package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.response.md +59 -0
- package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.expected.yaml +21 -0
- package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.response.md +56 -0
- package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.expected.yaml +20 -0
- package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.response.md +57 -0
- package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.expected.yaml +21 -0
- package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.response.md +55 -0
- package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.expected.yaml +28 -0
- package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.response.md +53 -0
- package/rainbond-fullstack-troubleshooter/schemas/troubleshoot-result.schema.yaml +217 -0
- package/rainbond-fullstack-troubleshooter/scripts/run_troubleshooter_evals.py +80 -0
- package/rainbond-fullstack-troubleshooter/scripts/validate_troubleshoot_output.py +789 -0
- package/rainbond-project-init/SKILL.md +1001 -0
- package/rainbond-template-installer/SKILL.md +410 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Deterministic validator for app-assistant orchestration-level structured output."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
REQUIRED_SECTIONS = [
|
|
18
|
+
"### Project State",
|
|
19
|
+
"### Actions Performed",
|
|
20
|
+
"### Current Health",
|
|
21
|
+
"### Blocking Issue",
|
|
22
|
+
"### Next Step",
|
|
23
|
+
"### Structured Output",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
SECRET_KEYWORDS = (
|
|
27
|
+
"password",
|
|
28
|
+
"secret",
|
|
29
|
+
"token",
|
|
30
|
+
"api_key",
|
|
31
|
+
"apikey",
|
|
32
|
+
"private_key",
|
|
33
|
+
"privatekey",
|
|
34
|
+
"certificate",
|
|
35
|
+
"cert",
|
|
36
|
+
"authorization",
|
|
37
|
+
"cookie",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
MASKED_VALUES = {"***", "[masked]", "<masked>", "redacted", "<redacted>"}
|
|
41
|
+
|
|
42
|
+
FORBIDDEN_FALLBACK_PATTERNS = (
|
|
43
|
+
r"\bfallback(?:ed)?\s+to\s+(?:package|image|template)\b",
|
|
44
|
+
r"\bswitched\s+to\s+(?:package|image|template)\b",
|
|
45
|
+
r"\bdefault(?:ed)?\s+to\s+(?:package|image|template)\b",
|
|
46
|
+
r"\bused\s+(?:package|image|template)\s+fallback\b",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
FORBIDDEN_CODE_HANDOFF_ACTION_PATTERNS = (
|
|
50
|
+
r"\bgo\s+test\b",
|
|
51
|
+
r"\bgo\s+build\b",
|
|
52
|
+
r"\bgo\s+vet\b",
|
|
53
|
+
r"\bnpm\s+(?:test|run|install)\b",
|
|
54
|
+
r"\byarn\s+(?:test|build|install)\b",
|
|
55
|
+
r"\bpnpm\s+(?:test|build|install)\b",
|
|
56
|
+
r"\b(?:ran|run|executed|started|used)\s+docker\s+(?:build|buildx|push|tag|login)\b",
|
|
57
|
+
r"\bopen\s+-a\s+orbstack\b",
|
|
58
|
+
r"\b(?:started|launched|opened)\s+orbstack\b",
|
|
59
|
+
r"\bgit\s+(?:commit|push)\b",
|
|
60
|
+
r"\bcommitted\b",
|
|
61
|
+
r"\bpushed\b",
|
|
62
|
+
r"\bmodified\s+source\b",
|
|
63
|
+
r"\bedited\s+source\b",
|
|
64
|
+
r"\bcode edit(?:s)?\b",
|
|
65
|
+
r"\blocal test(?:s)?\b",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Canonical next_action vocabulary.
|
|
70
|
+
#
|
|
71
|
+
# 修改需同步: this list is the validator-side mirror of the "Canonical next_action
|
|
72
|
+
# vocabulary" table in SKILL.md (### Structured contract mode). Editing one without
|
|
73
|
+
# the other breaks the contract. Fixed phrases must match verbatim (after whitespace
|
|
74
|
+
# normalization); template phrases match by their leading + trailing literal segments
|
|
75
|
+
# (the text before the first <slot> and after the last <slot>), so the <...> slot can
|
|
76
|
+
# be filled with any value or, where the table allows, omitted.
|
|
77
|
+
CANONICAL_NEXT_ACTIONS = (
|
|
78
|
+
"stop",
|
|
79
|
+
"run bootstrap",
|
|
80
|
+
"run troubleshooter",
|
|
81
|
+
"run troubleshooter on the same source path",
|
|
82
|
+
"run delivery verifier",
|
|
83
|
+
"fix cluster capacity first",
|
|
84
|
+
"handoff to code/build agent",
|
|
85
|
+
"stop and validate URL manually",
|
|
86
|
+
"stop and ask the user to choose the team/app identity",
|
|
87
|
+
"stop and ask the user to provide a descriptor or template",
|
|
88
|
+
"build the linked source app on the user-provided GitHub URL",
|
|
89
|
+
"configure ports and envs on the known service_alias from the create return",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Template phrases keyed by their literal prefix (before the first <slot>) and
|
|
93
|
+
# literal suffix (after the last <slot>). A candidate matches when it starts with the
|
|
94
|
+
# prefix and ends with the suffix and is at least as long as prefix+suffix combined.
|
|
95
|
+
CANONICAL_NEXT_ACTION_TEMPLATES = (
|
|
96
|
+
{
|
|
97
|
+
"template": "stop after reporting testing app verification for <app>",
|
|
98
|
+
"prefix": "stop after reporting testing app verification for ",
|
|
99
|
+
"suffix": "",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"template": "delete the abandoned half-installed template app <app> before building the source path",
|
|
103
|
+
"prefix": "delete the abandoned half-installed template app",
|
|
104
|
+
"suffix": "before building the source path",
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ValidationFailure(Exception):
|
|
110
|
+
"""Raised when validation cannot continue."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> int:
|
|
114
|
+
parser = build_parser()
|
|
115
|
+
args = parser.parse_args()
|
|
116
|
+
|
|
117
|
+
response_path = args.response.resolve()
|
|
118
|
+
schema_path = args.schema.resolve()
|
|
119
|
+
expected_path = args.expected.resolve() if args.expected else None
|
|
120
|
+
|
|
121
|
+
errors = validate_response_file(
|
|
122
|
+
response_path=response_path,
|
|
123
|
+
schema_path=schema_path,
|
|
124
|
+
expected_path=expected_path,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if errors:
|
|
128
|
+
print(f"FAIL {response_path}")
|
|
129
|
+
for error in errors:
|
|
130
|
+
print(f" - {error}")
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
print(f"PASS {response_path}")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
138
|
+
parser = argparse.ArgumentParser(
|
|
139
|
+
description="Validate an app-assistant markdown reply against AppAssistantResult."
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument("response", type=Path, help="Path to the markdown response file.")
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--schema",
|
|
144
|
+
type=Path,
|
|
145
|
+
default=Path(__file__).resolve().parents[1] / "schemas" / "app-assistant-result.schema.yaml",
|
|
146
|
+
help="Path to app-assistant-result.schema.yaml.",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"--expected",
|
|
150
|
+
type=Path,
|
|
151
|
+
default=None,
|
|
152
|
+
help="Optional fixture-aware assertion file.",
|
|
153
|
+
)
|
|
154
|
+
return parser
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_response_file(
|
|
158
|
+
response_path: Path,
|
|
159
|
+
schema_path: Path,
|
|
160
|
+
expected_path: Path | None = None,
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
response_text = response_path.read_text(encoding="utf-8")
|
|
163
|
+
schema = load_yaml(schema_path)
|
|
164
|
+
expected = load_yaml(expected_path) if expected_path else None
|
|
165
|
+
|
|
166
|
+
errors: list[str] = []
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
sections = parse_required_sections(response_text)
|
|
170
|
+
except ValidationFailure as exc:
|
|
171
|
+
return [str(exc)]
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
structured_yaml = extract_structured_yaml_block(sections["### Structured Output"])
|
|
175
|
+
except ValidationFailure as exc:
|
|
176
|
+
return [str(exc)]
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
payload = yaml.safe_load(structured_yaml)
|
|
180
|
+
except yaml.YAMLError as exc:
|
|
181
|
+
return [f"structured YAML did not parse: {exc}"]
|
|
182
|
+
|
|
183
|
+
if payload is None:
|
|
184
|
+
return ["structured YAML parsed to null; expected an AppAssistantResult object"]
|
|
185
|
+
|
|
186
|
+
errors.extend(check_for_secret_leaks(response_text, structured_yaml, payload))
|
|
187
|
+
errors.extend(validate_schema(payload, schema))
|
|
188
|
+
|
|
189
|
+
if not errors:
|
|
190
|
+
errors.extend(validate_cross_field_rules(sections, payload))
|
|
191
|
+
errors.extend(validate_prose_consistency(sections, payload))
|
|
192
|
+
|
|
193
|
+
if expected_path and not errors:
|
|
194
|
+
errors.extend(validate_expected_fixture(sections, payload, expected or {}))
|
|
195
|
+
|
|
196
|
+
return errors
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_yaml(path: Path | None) -> Any:
|
|
200
|
+
if path is None:
|
|
201
|
+
return None
|
|
202
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
203
|
+
return yaml.safe_load(handle)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_required_sections(response_text: str) -> dict[str, str]:
|
|
207
|
+
heading_matches = list(re.finditer(r"(?m)^### [^\n]+$", response_text))
|
|
208
|
+
headings = [match.group(0).strip() for match in heading_matches]
|
|
209
|
+
|
|
210
|
+
if headings != REQUIRED_SECTIONS:
|
|
211
|
+
raise ValidationFailure(
|
|
212
|
+
"reply headings must be exactly and only: "
|
|
213
|
+
+ ", ".join(REQUIRED_SECTIONS)
|
|
214
|
+
+ f"; got: {headings}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
sections: dict[str, str] = {}
|
|
218
|
+
for index, match in enumerate(heading_matches):
|
|
219
|
+
start = match.end()
|
|
220
|
+
end = heading_matches[index + 1].start() if index + 1 < len(heading_matches) else len(response_text)
|
|
221
|
+
sections[match.group(0).strip()] = response_text[start:end].strip()
|
|
222
|
+
|
|
223
|
+
return sections
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def extract_structured_yaml_block(structured_section: str) -> str:
|
|
227
|
+
matches = list(re.finditer(r"(?s)```yaml\s*\n(.*?)\n```", structured_section))
|
|
228
|
+
if len(matches) != 1:
|
|
229
|
+
raise ValidationFailure("### Structured Output must contain exactly one fenced ```yaml block")
|
|
230
|
+
|
|
231
|
+
if re.sub(r"(?s)```yaml\s*\n.*?\n```", "", structured_section).strip():
|
|
232
|
+
raise ValidationFailure("### Structured Output must contain only the fenced yaml block")
|
|
233
|
+
|
|
234
|
+
return matches[0].group(1)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def validate_schema(instance: Any, schema: dict[str, Any]) -> list[str]:
|
|
238
|
+
errors: list[str] = []
|
|
239
|
+
_validate_schema_node(instance, schema, "$", schema, errors)
|
|
240
|
+
return errors
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_schema_node(
|
|
244
|
+
instance: Any,
|
|
245
|
+
schema: dict[str, Any],
|
|
246
|
+
path: str,
|
|
247
|
+
root_schema: dict[str, Any],
|
|
248
|
+
errors: list[str],
|
|
249
|
+
) -> None:
|
|
250
|
+
if "$ref" in schema:
|
|
251
|
+
resolved = resolve_ref(schema["$ref"], root_schema)
|
|
252
|
+
_validate_schema_node(instance, resolved, path, root_schema, errors)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
if "anyOf" in schema:
|
|
256
|
+
option_errors: list[list[str]] = []
|
|
257
|
+
for option in schema["anyOf"]:
|
|
258
|
+
nested_errors: list[str] = []
|
|
259
|
+
_validate_schema_node(instance, option, path, root_schema, nested_errors)
|
|
260
|
+
if not nested_errors:
|
|
261
|
+
break
|
|
262
|
+
option_errors.append(nested_errors)
|
|
263
|
+
else:
|
|
264
|
+
joined = "; ".join(" / ".join(option) for option in option_errors)
|
|
265
|
+
errors.append(f"{path}: did not match any allowed schema option ({joined})")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if "enum" in schema and instance not in schema["enum"]:
|
|
269
|
+
errors.append(f"{path}: expected one of {schema['enum']}, got {instance!r}")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
if "const" in schema and instance != schema["const"]:
|
|
273
|
+
errors.append(f"{path}: expected constant {schema['const']!r}, got {instance!r}")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if "type" in schema and not matches_type(instance, schema["type"]):
|
|
277
|
+
errors.append(f"{path}: expected type {schema['type']!r}, got {type_name(instance)}")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
if isinstance(instance, str) and "minLength" in schema and len(instance) < schema["minLength"]:
|
|
281
|
+
errors.append(f"{path}: expected string length >= {schema['minLength']}, got {len(instance)}")
|
|
282
|
+
|
|
283
|
+
if isinstance(instance, list) and "minItems" in schema and len(instance) < schema["minItems"]:
|
|
284
|
+
errors.append(f"{path}: expected at least {schema['minItems']} item(s), got {len(instance)}")
|
|
285
|
+
|
|
286
|
+
schema_type = normalized_type(schema.get("type"), instance)
|
|
287
|
+
if schema_type == "object":
|
|
288
|
+
validate_object(instance, schema, path, root_schema, errors)
|
|
289
|
+
elif schema_type == "array":
|
|
290
|
+
validate_array(instance, schema, path, root_schema, errors)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def resolve_ref(ref: str, root_schema: dict[str, Any]) -> dict[str, Any]:
|
|
294
|
+
if not ref.startswith("#/"):
|
|
295
|
+
raise ValidationFailure(f"unsupported schema ref: {ref}")
|
|
296
|
+
|
|
297
|
+
node: Any = root_schema
|
|
298
|
+
for part in ref[2:].split("/"):
|
|
299
|
+
node = node[part]
|
|
300
|
+
if not isinstance(node, dict):
|
|
301
|
+
raise ValidationFailure(f"schema ref {ref} did not resolve to an object node")
|
|
302
|
+
return node
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def matches_type(instance: Any, expected_type: str | list[str]) -> bool:
|
|
306
|
+
if isinstance(expected_type, list):
|
|
307
|
+
return any(matches_type(instance, option) for option in expected_type)
|
|
308
|
+
|
|
309
|
+
if expected_type == "object":
|
|
310
|
+
return isinstance(instance, dict)
|
|
311
|
+
if expected_type == "array":
|
|
312
|
+
return isinstance(instance, list)
|
|
313
|
+
if expected_type == "string":
|
|
314
|
+
return isinstance(instance, str)
|
|
315
|
+
if expected_type == "null":
|
|
316
|
+
return instance is None
|
|
317
|
+
if expected_type == "boolean":
|
|
318
|
+
return isinstance(instance, bool)
|
|
319
|
+
if expected_type == "integer":
|
|
320
|
+
return isinstance(instance, int) and not isinstance(instance, bool)
|
|
321
|
+
if expected_type == "number":
|
|
322
|
+
return isinstance(instance, (int, float)) and not isinstance(instance, bool)
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def type_name(instance: Any) -> str:
|
|
327
|
+
if instance is None:
|
|
328
|
+
return "null"
|
|
329
|
+
if isinstance(instance, bool):
|
|
330
|
+
return "boolean"
|
|
331
|
+
if isinstance(instance, dict):
|
|
332
|
+
return "object"
|
|
333
|
+
if isinstance(instance, list):
|
|
334
|
+
return "array"
|
|
335
|
+
if isinstance(instance, str):
|
|
336
|
+
return "string"
|
|
337
|
+
if isinstance(instance, int):
|
|
338
|
+
return "integer"
|
|
339
|
+
if isinstance(instance, float):
|
|
340
|
+
return "number"
|
|
341
|
+
return type(instance).__name__
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def normalized_type(expected_type: Any, instance: Any) -> str | None:
|
|
345
|
+
if isinstance(expected_type, str):
|
|
346
|
+
return expected_type
|
|
347
|
+
if isinstance(expected_type, list):
|
|
348
|
+
for option in expected_type:
|
|
349
|
+
if matches_type(instance, option):
|
|
350
|
+
return option
|
|
351
|
+
if isinstance(instance, dict):
|
|
352
|
+
return "object"
|
|
353
|
+
if isinstance(instance, list):
|
|
354
|
+
return "array"
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def validate_object(
|
|
359
|
+
instance: Any,
|
|
360
|
+
schema: dict[str, Any],
|
|
361
|
+
path: str,
|
|
362
|
+
root_schema: dict[str, Any],
|
|
363
|
+
errors: list[str],
|
|
364
|
+
) -> None:
|
|
365
|
+
if not isinstance(instance, dict):
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
required = schema.get("required", [])
|
|
369
|
+
for key in required:
|
|
370
|
+
if key not in instance:
|
|
371
|
+
errors.append(f"{path}: missing required property {key!r}")
|
|
372
|
+
|
|
373
|
+
properties = schema.get("properties", {})
|
|
374
|
+
for key, subschema in properties.items():
|
|
375
|
+
if key in instance:
|
|
376
|
+
_validate_schema_node(instance[key], subschema, f"{path}.{key}", root_schema, errors)
|
|
377
|
+
|
|
378
|
+
allowed = set(properties.keys())
|
|
379
|
+
extras = [key for key in instance.keys() if key not in allowed]
|
|
380
|
+
additional = schema.get("additionalProperties", True)
|
|
381
|
+
|
|
382
|
+
if additional is False and extras:
|
|
383
|
+
errors.append(f"{path}: unexpected properties {extras}")
|
|
384
|
+
elif isinstance(additional, dict):
|
|
385
|
+
for key in extras:
|
|
386
|
+
_validate_schema_node(instance[key], additional, f"{path}.{key}", root_schema, errors)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def validate_array(
|
|
390
|
+
instance: Any,
|
|
391
|
+
schema: dict[str, Any],
|
|
392
|
+
path: str,
|
|
393
|
+
root_schema: dict[str, Any],
|
|
394
|
+
errors: list[str],
|
|
395
|
+
) -> None:
|
|
396
|
+
if not isinstance(instance, list):
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
item_schema = schema.get("items")
|
|
400
|
+
if item_schema:
|
|
401
|
+
for index, item in enumerate(instance):
|
|
402
|
+
_validate_schema_node(item, item_schema, f"{path}[{index}]", root_schema, errors)
|
|
403
|
+
|
|
404
|
+
if schema.get("uniqueItems"):
|
|
405
|
+
seen: set[str] = set()
|
|
406
|
+
duplicates: list[Any] = []
|
|
407
|
+
for item in instance:
|
|
408
|
+
fingerprint = json.dumps(item, ensure_ascii=False, sort_keys=True)
|
|
409
|
+
if fingerprint in seen:
|
|
410
|
+
duplicates.append(item)
|
|
411
|
+
seen.add(fingerprint)
|
|
412
|
+
if duplicates:
|
|
413
|
+
errors.append(f"{path}: duplicate array values {duplicates!r}")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def validate_cross_field_rules(sections: dict[str, str], payload: dict[str, Any]) -> list[str]:
|
|
417
|
+
errors: list[str] = []
|
|
418
|
+
result = payload.get("AppAssistantResult", {})
|
|
419
|
+
|
|
420
|
+
request_intent = result.get("request_intent")
|
|
421
|
+
execution_path = result.get("execution_path") or {}
|
|
422
|
+
runtime = result.get("runtime_state") or {}
|
|
423
|
+
delivery = result.get("delivery_state")
|
|
424
|
+
promotion = result.get("promotion_result")
|
|
425
|
+
actions = result.get("actions_performed", [])
|
|
426
|
+
next_action = normalize_space(result.get("next_action"))
|
|
427
|
+
orchestration_state = normalize_space(result.get("orchestration_state"))
|
|
428
|
+
|
|
429
|
+
errors.extend(validate_next_action_vocabulary(next_action))
|
|
430
|
+
|
|
431
|
+
phase = normalize_space(runtime.get("phase"))
|
|
432
|
+
combined_text = "\n".join(
|
|
433
|
+
[
|
|
434
|
+
sections["### Actions Performed"],
|
|
435
|
+
sections["### Current Health"],
|
|
436
|
+
sections["### Blocking Issue"],
|
|
437
|
+
sections["### Next Step"],
|
|
438
|
+
"\n".join(str(action.get("details", "")) for action in actions if isinstance(action, dict)),
|
|
439
|
+
]
|
|
440
|
+
).lower()
|
|
441
|
+
|
|
442
|
+
if delivery is not None and not has_executed_skill(actions, "rainbond-delivery-verifier"):
|
|
443
|
+
errors.append("delivery_state can be non-null only after rainbond-delivery-verifier has run")
|
|
444
|
+
|
|
445
|
+
if promotion is not None:
|
|
446
|
+
if request_intent != "dev_to_test_promotion":
|
|
447
|
+
errors.append("promotion_result can be non-null only when request_intent=dev_to_test_promotion")
|
|
448
|
+
if not isinstance(delivery, dict) or delivery.get("status") != "delivered":
|
|
449
|
+
errors.append("promotion_result can be non-null only after source delivery_state.status=delivered")
|
|
450
|
+
if not has_executed_skill(actions, "rainbond-app-version-assistant"):
|
|
451
|
+
errors.append("promotion_result requires rainbond-app-version-assistant to have run")
|
|
452
|
+
|
|
453
|
+
if isinstance(delivery, dict) and delivery.get("status") == "delivered-but-needs-manual-validation":
|
|
454
|
+
if promotion is not None:
|
|
455
|
+
errors.append("delivered-but-needs-manual-validation must not enter promotion_result")
|
|
456
|
+
if points_to_promotion(next_action):
|
|
457
|
+
errors.append("delivered-but-needs-manual-validation must stop before promotion")
|
|
458
|
+
if has_executed_skill(actions, "rainbond-app-version-assistant"):
|
|
459
|
+
errors.append("delivered-but-needs-manual-validation must not run rainbond-app-version-assistant")
|
|
460
|
+
|
|
461
|
+
if phase == "capacity_blocked":
|
|
462
|
+
if points_to_delivery_verification(next_action):
|
|
463
|
+
errors.append("capacity_blocked must not point next_action to delivery verification")
|
|
464
|
+
if isinstance(delivery, dict) and delivery.get("status") in {
|
|
465
|
+
"delivered",
|
|
466
|
+
"delivered-but-needs-manual-validation",
|
|
467
|
+
}:
|
|
468
|
+
errors.append("capacity_blocked is incompatible with a delivered delivery_state")
|
|
469
|
+
|
|
470
|
+
if phase == "code_or_build_handoff_needed" or points_to_code_handoff(next_action):
|
|
471
|
+
errors.extend(validate_code_or_build_boundary(actions, sections))
|
|
472
|
+
|
|
473
|
+
if execution_path.get("requested_kind") == "source":
|
|
474
|
+
if execution_path.get("resolved_kind") != "source" and not is_identity_ambiguous(
|
|
475
|
+
orchestration_state,
|
|
476
|
+
next_action,
|
|
477
|
+
):
|
|
478
|
+
errors.append("source-backed orchestration must not silently fallback to package/image/template")
|
|
479
|
+
if has_executed_skill(actions, "rainbond-template-installer"):
|
|
480
|
+
errors.append("source-backed orchestration must not execute rainbond-template-installer silently")
|
|
481
|
+
for pattern in FORBIDDEN_FALLBACK_PATTERNS:
|
|
482
|
+
if re.search(pattern, combined_text):
|
|
483
|
+
errors.append("source-backed orchestration must not silently fallback to package/image/template")
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
if isinstance(delivery, dict) and delivery.get("status") == "delivered" and request_intent == "source_app_delivery":
|
|
487
|
+
if promotion is not None:
|
|
488
|
+
errors.append("source_app_delivery runs must stop without promotion_result after delivered")
|
|
489
|
+
if "stop" not in next_action.lower():
|
|
490
|
+
errors.append("delivered source_app_delivery runs must stop instead of continuing")
|
|
491
|
+
|
|
492
|
+
if is_identity_ambiguous(orchestration_state, next_action):
|
|
493
|
+
if delivery is not None:
|
|
494
|
+
errors.append("identity-ambiguous runs must not emit delivery_state")
|
|
495
|
+
if promotion is not None:
|
|
496
|
+
errors.append("identity-ambiguous runs must not emit promotion_result")
|
|
497
|
+
if runtime not in ({}, None):
|
|
498
|
+
phase_value = runtime.get("phase")
|
|
499
|
+
if phase_value is not None:
|
|
500
|
+
errors.append("identity-ambiguous runs must stop before emitting canonical runtime_state.phase")
|
|
501
|
+
if not asks_user_to_choose_identity(next_action):
|
|
502
|
+
errors.append("identity-ambiguous runs must stop and ask the user to choose the team/app identity")
|
|
503
|
+
for blocked_skill in (
|
|
504
|
+
"rainbond-fullstack-bootstrap",
|
|
505
|
+
"rainbond-fullstack-troubleshooter",
|
|
506
|
+
"rainbond-delivery-verifier",
|
|
507
|
+
"rainbond-app-version-assistant",
|
|
508
|
+
):
|
|
509
|
+
if has_executed_skill(actions, blocked_skill):
|
|
510
|
+
errors.append(f"identity-ambiguous runs must stop before executing {blocked_skill}")
|
|
511
|
+
|
|
512
|
+
if isinstance(delivery, dict):
|
|
513
|
+
errors.extend(validate_delivery_summary(delivery))
|
|
514
|
+
|
|
515
|
+
if isinstance(promotion, dict):
|
|
516
|
+
testing_delivery = promotion.get("testing_delivery_state")
|
|
517
|
+
if isinstance(testing_delivery, dict):
|
|
518
|
+
errors.extend(validate_delivery_summary(testing_delivery, path_prefix="promotion_result.testing_delivery_state"))
|
|
519
|
+
|
|
520
|
+
return errors
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def validate_next_action_vocabulary(next_action: str) -> list[str]:
|
|
524
|
+
"""next_action must come from the canonical vocabulary (see SKILL.md).
|
|
525
|
+
|
|
526
|
+
Fixed phrases match verbatim after whitespace normalization. Template phrases
|
|
527
|
+
match by their literal prefix + suffix so the <slot> can carry any value.
|
|
528
|
+
"""
|
|
529
|
+
candidate = normalize_space(next_action)
|
|
530
|
+
if not candidate:
|
|
531
|
+
# Empty / missing next_action is already caught by the schema (non_empty_string).
|
|
532
|
+
return []
|
|
533
|
+
|
|
534
|
+
if candidate in CANONICAL_NEXT_ACTIONS:
|
|
535
|
+
return []
|
|
536
|
+
|
|
537
|
+
for template in CANONICAL_NEXT_ACTION_TEMPLATES:
|
|
538
|
+
if matches_next_action_template(candidate, template):
|
|
539
|
+
return []
|
|
540
|
+
|
|
541
|
+
return [
|
|
542
|
+
"next_action must be drawn from the canonical vocabulary in SKILL.md "
|
|
543
|
+
f"(fixed phrase verbatim, or a template with its slot filled); got {next_action!r}"
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def matches_next_action_template(candidate: str, template: dict[str, str]) -> bool:
|
|
548
|
+
prefix = normalize_space(template["prefix"])
|
|
549
|
+
suffix = normalize_space(template["suffix"])
|
|
550
|
+
lowered = candidate.lower()
|
|
551
|
+
|
|
552
|
+
if prefix and not lowered.startswith(prefix.lower()):
|
|
553
|
+
return False
|
|
554
|
+
if suffix and not lowered.endswith(suffix.lower()):
|
|
555
|
+
return False
|
|
556
|
+
if len(candidate) < len(prefix) + len(suffix):
|
|
557
|
+
return False
|
|
558
|
+
return True
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def validate_delivery_summary(summary: dict[str, Any], path_prefix: str = "delivery_state") -> list[str]:
|
|
562
|
+
errors: list[str] = []
|
|
563
|
+
status = summary.get("status")
|
|
564
|
+
preferred_access_url = summary.get("preferred_access_url")
|
|
565
|
+
verification_mode = summary.get("verification_mode")
|
|
566
|
+
blocker = summary.get("blocker")
|
|
567
|
+
next_action = summary.get("verifier_next_action")
|
|
568
|
+
|
|
569
|
+
if status == "delivered":
|
|
570
|
+
if not preferred_access_url:
|
|
571
|
+
errors.append(f"{path_prefix}.status=delivered requires preferred_access_url")
|
|
572
|
+
if verification_mode != "verified":
|
|
573
|
+
errors.append(f"{path_prefix}.status=delivered requires verification_mode=verified")
|
|
574
|
+
if blocker is not None:
|
|
575
|
+
errors.append(f"{path_prefix}.status=delivered requires blocker=null")
|
|
576
|
+
if next_action != "stop":
|
|
577
|
+
errors.append(f"{path_prefix}.status=delivered requires verifier_next_action=stop")
|
|
578
|
+
|
|
579
|
+
if status == "delivered-but-needs-manual-validation":
|
|
580
|
+
if not preferred_access_url:
|
|
581
|
+
errors.append(
|
|
582
|
+
f"{path_prefix}.status=delivered-but-needs-manual-validation requires preferred_access_url"
|
|
583
|
+
)
|
|
584
|
+
if verification_mode not in {"manual_validation_needed", "inferred"}:
|
|
585
|
+
errors.append(
|
|
586
|
+
f"{path_prefix}.status=delivered-but-needs-manual-validation requires "
|
|
587
|
+
"verification_mode=inferred or manual_validation_needed"
|
|
588
|
+
)
|
|
589
|
+
if blocker is not None:
|
|
590
|
+
errors.append(f"{path_prefix}.status=delivered-but-needs-manual-validation requires blocker=null")
|
|
591
|
+
if next_action != "manual_url_validation":
|
|
592
|
+
errors.append(
|
|
593
|
+
f"{path_prefix}.status=delivered-but-needs-manual-validation requires "
|
|
594
|
+
"verifier_next_action=manual_url_validation"
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if status == "blocked" and blocker is None:
|
|
598
|
+
errors.append(f"{path_prefix}.status=blocked requires blocker")
|
|
599
|
+
|
|
600
|
+
if status == "partially-delivered" and next_action == "stop":
|
|
601
|
+
errors.append(f"{path_prefix}.status=partially-delivered cannot use verifier_next_action=stop")
|
|
602
|
+
|
|
603
|
+
return errors
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def validate_code_or_build_boundary(actions: list[dict[str, Any]], sections: dict[str, str]) -> list[str]:
|
|
607
|
+
errors: list[str] = []
|
|
608
|
+
action_text = "\n".join(str(action.get("details", "")) for action in actions if isinstance(action, dict)).lower()
|
|
609
|
+
action_text += "\n" + sections["### Actions Performed"].lower()
|
|
610
|
+
|
|
611
|
+
for pattern in FORBIDDEN_CODE_HANDOFF_ACTION_PATTERNS:
|
|
612
|
+
if re.search(pattern, action_text):
|
|
613
|
+
errors.append(
|
|
614
|
+
"code_or_build_handoff_needed must not include code edits, local tests, commit, or push"
|
|
615
|
+
)
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
return errors
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def validate_prose_consistency(sections: dict[str, str], payload: dict[str, Any]) -> list[str]:
|
|
622
|
+
errors: list[str] = []
|
|
623
|
+
result = payload["AppAssistantResult"]
|
|
624
|
+
|
|
625
|
+
project = result["project"]
|
|
626
|
+
environment = result["environment"]
|
|
627
|
+
runtime = result.get("runtime_state") or {}
|
|
628
|
+
delivery = result.get("delivery_state")
|
|
629
|
+
promotion = result.get("promotion_result")
|
|
630
|
+
actions = result.get("actions_performed", [])
|
|
631
|
+
|
|
632
|
+
project_state = sections["### Project State"]
|
|
633
|
+
actions_section = sections["### Actions Performed"]
|
|
634
|
+
current_health = sections["### Current Health"]
|
|
635
|
+
blocking_issue = sections["### Blocking Issue"]
|
|
636
|
+
next_step = sections["### Next Step"]
|
|
637
|
+
|
|
638
|
+
if not contains_normalized(project_state, result["orchestration_state"]):
|
|
639
|
+
errors.append("Project State prose must mention orchestration_state")
|
|
640
|
+
|
|
641
|
+
if not contains_normalized(project_state, environment["name"]):
|
|
642
|
+
errors.append("Project State prose must mention environment.name")
|
|
643
|
+
|
|
644
|
+
for identity_value in project["identity"].values():
|
|
645
|
+
if identity_value is None:
|
|
646
|
+
continue
|
|
647
|
+
if not contains_normalized(project_state, identity_value):
|
|
648
|
+
errors.append(f"Project State prose must mention identity value {identity_value!r}")
|
|
649
|
+
|
|
650
|
+
if runtime and runtime.get("phase") is not None and not contains_normalized(current_health, runtime["phase"]):
|
|
651
|
+
errors.append("Current Health prose must mention runtime_state.phase")
|
|
652
|
+
|
|
653
|
+
if isinstance(delivery, dict) and not (
|
|
654
|
+
contains_normalized(current_health, delivery["status"])
|
|
655
|
+
or contains_normalized(blocking_issue, delivery["status"])
|
|
656
|
+
):
|
|
657
|
+
errors.append("Current Health or Blocking Issue prose must mention delivery_state.status")
|
|
658
|
+
|
|
659
|
+
blocker_text = None
|
|
660
|
+
if runtime:
|
|
661
|
+
blocker_text = runtime.get("blocker")
|
|
662
|
+
if not blocker_text and isinstance(delivery, dict):
|
|
663
|
+
blocker_text = delivery.get("blocker")
|
|
664
|
+
if blocker_text and not contains_normalized(blocking_issue, blocker_text):
|
|
665
|
+
errors.append("Blocking Issue prose must align with the structured blocker summary")
|
|
666
|
+
|
|
667
|
+
if not contains_normalized(next_step, result["next_action"]):
|
|
668
|
+
errors.append("Next Step prose must match next_action")
|
|
669
|
+
|
|
670
|
+
for action in actions:
|
|
671
|
+
skill = action.get("skill")
|
|
672
|
+
if not skill:
|
|
673
|
+
continue
|
|
674
|
+
if skill == "inspection-only":
|
|
675
|
+
continue
|
|
676
|
+
if not contains_normalized(actions_section, skill):
|
|
677
|
+
errors.append(f"Actions Performed prose must mention skill {skill!r}")
|
|
678
|
+
|
|
679
|
+
if promotion is not None:
|
|
680
|
+
testing_app = promotion.get("testing_app", {})
|
|
681
|
+
testing_app_name = testing_app.get("app_name")
|
|
682
|
+
if testing_app_name and not (
|
|
683
|
+
contains_normalized(next_step, testing_app_name) or contains_normalized(current_health, testing_app_name)
|
|
684
|
+
):
|
|
685
|
+
errors.append("prose must mention testing_app.app_name when promotion_result is present")
|
|
686
|
+
|
|
687
|
+
return errors
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def normalize_space(text: Any) -> str:
|
|
691
|
+
if text is None:
|
|
692
|
+
return ""
|
|
693
|
+
return " ".join(str(text).split())
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def normalize_for_match(text: Any) -> str:
|
|
697
|
+
normalized = normalize_space(text).lower()
|
|
698
|
+
normalized = normalized.replace("_", " ").replace("-", " ")
|
|
699
|
+
return " ".join(normalized.split())
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def contains_normalized(haystack: Any, needle: Any) -> bool:
|
|
703
|
+
if needle is None:
|
|
704
|
+
return True
|
|
705
|
+
return normalize_for_match(needle) in normalize_for_match(haystack)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def has_executed_skill(actions: list[dict[str, Any]], skill_name: str) -> bool:
|
|
709
|
+
executed_statuses = {"completed", "complete", "succeeded", "success", "finished", "stopped"}
|
|
710
|
+
for action in actions:
|
|
711
|
+
if not isinstance(action, dict):
|
|
712
|
+
continue
|
|
713
|
+
if action.get("skill") != skill_name:
|
|
714
|
+
continue
|
|
715
|
+
status = normalize_for_match(action.get("status"))
|
|
716
|
+
if status in executed_statuses:
|
|
717
|
+
return True
|
|
718
|
+
return False
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def points_to_delivery_verification(next_action: str) -> bool:
|
|
722
|
+
lowered = next_action.lower()
|
|
723
|
+
return "delivery verifier" in lowered or "delivery verification" in lowered
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def points_to_promotion(next_action: str) -> bool:
|
|
727
|
+
lowered = next_action.lower()
|
|
728
|
+
return any(
|
|
729
|
+
needle in lowered
|
|
730
|
+
for needle in [
|
|
731
|
+
"promotion",
|
|
732
|
+
"snapshot",
|
|
733
|
+
"testing app",
|
|
734
|
+
"version-assistant",
|
|
735
|
+
"version assistant",
|
|
736
|
+
"dev-to-test",
|
|
737
|
+
]
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def points_to_code_handoff(next_action: str) -> bool:
|
|
742
|
+
lowered = next_action.lower()
|
|
743
|
+
return "code/build" in lowered or "code build" in lowered or "handoff" in lowered
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def is_identity_ambiguous(orchestration_state: str, next_action: str) -> bool:
|
|
747
|
+
combined = f"{orchestration_state} {next_action}".lower()
|
|
748
|
+
return "ambiguous" in combined or "choose the team/app identity" in combined
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def asks_user_to_choose_identity(next_action: str) -> bool:
|
|
752
|
+
lowered = next_action.lower()
|
|
753
|
+
return "ask the user" in lowered and "identity" in lowered or "choose the team/app identity" in lowered
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def validate_expected_fixture(
|
|
757
|
+
sections: dict[str, str],
|
|
758
|
+
payload: dict[str, Any],
|
|
759
|
+
expected: dict[str, Any],
|
|
760
|
+
) -> list[str]:
|
|
761
|
+
errors: list[str] = []
|
|
762
|
+
assertions = expected.get("assert", {})
|
|
763
|
+
|
|
764
|
+
for path, expected_value in assertions.get("equal", {}).items():
|
|
765
|
+
actual = get_path(payload, path)
|
|
766
|
+
if actual != expected_value:
|
|
767
|
+
errors.append(f"{path}: expected {expected_value!r}, got {actual!r}")
|
|
768
|
+
|
|
769
|
+
for path, expected_value in assertions.get("contains", {}).items():
|
|
770
|
+
actual = get_path(payload, path)
|
|
771
|
+
errors.extend(assert_contains(path, actual, expected_value))
|
|
772
|
+
|
|
773
|
+
for path, forbidden_value in assertions.get("excludes", {}).items():
|
|
774
|
+
actual = get_path(payload, path)
|
|
775
|
+
errors.extend(assert_excludes(path, actual, forbidden_value))
|
|
776
|
+
|
|
777
|
+
prose_body = "\n\n".join(
|
|
778
|
+
[
|
|
779
|
+
sections["### Project State"],
|
|
780
|
+
sections["### Actions Performed"],
|
|
781
|
+
sections["### Current Health"],
|
|
782
|
+
sections["### Blocking Issue"],
|
|
783
|
+
sections["### Next Step"],
|
|
784
|
+
]
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
for needle in assertions.get("prose_contains", []):
|
|
788
|
+
if needle not in prose_body:
|
|
789
|
+
errors.append(f"prose is missing required text: {needle!r}")
|
|
790
|
+
|
|
791
|
+
for needle in assertions.get("prose_not_contains", []):
|
|
792
|
+
if needle in prose_body:
|
|
793
|
+
errors.append(f"prose contains forbidden text: {needle!r}")
|
|
794
|
+
|
|
795
|
+
return errors
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def get_path(payload: Any, dotted_path: str) -> Any:
|
|
799
|
+
current = payload
|
|
800
|
+
for token in dotted_path.split("."):
|
|
801
|
+
if isinstance(current, dict) and token in current:
|
|
802
|
+
current = current[token]
|
|
803
|
+
continue
|
|
804
|
+
raise ValidationFailure(f"missing fixture assertion path: {dotted_path}")
|
|
805
|
+
return current
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def assert_contains(path: str, actual: Any, expected: Any) -> list[str]:
|
|
809
|
+
if isinstance(actual, list):
|
|
810
|
+
missing = [item for item in expected if item not in actual]
|
|
811
|
+
if missing:
|
|
812
|
+
return [f"{path}: missing expected list items {missing!r}"]
|
|
813
|
+
return []
|
|
814
|
+
|
|
815
|
+
if isinstance(actual, dict):
|
|
816
|
+
missing_keys = [key for key in expected if key not in actual]
|
|
817
|
+
if missing_keys:
|
|
818
|
+
return [f"{path}: missing expected mapping keys {missing_keys!r}"]
|
|
819
|
+
mismatches = [
|
|
820
|
+
f"{key!r}: expected {value!r}, got {actual[key]!r}"
|
|
821
|
+
for key, value in expected.items()
|
|
822
|
+
if actual[key] != value
|
|
823
|
+
]
|
|
824
|
+
if mismatches:
|
|
825
|
+
return [f"{path}: mapping mismatches: {', '.join(mismatches)}"]
|
|
826
|
+
return []
|
|
827
|
+
|
|
828
|
+
if isinstance(actual, str):
|
|
829
|
+
if str(expected) not in actual:
|
|
830
|
+
return [f"{path}: expected substring {expected!r} not found"]
|
|
831
|
+
return []
|
|
832
|
+
|
|
833
|
+
return [f"{path}: contains assertion is unsupported for type {type_name(actual)}"]
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def assert_excludes(path: str, actual: Any, forbidden: Any) -> list[str]:
|
|
837
|
+
if isinstance(actual, list):
|
|
838
|
+
present = [item for item in forbidden if item in actual]
|
|
839
|
+
if present:
|
|
840
|
+
return [f"{path}: contains forbidden list items {present!r}"]
|
|
841
|
+
return []
|
|
842
|
+
|
|
843
|
+
if isinstance(actual, dict):
|
|
844
|
+
collisions = []
|
|
845
|
+
for key, value in forbidden.items():
|
|
846
|
+
if key in actual and actual[key] == value:
|
|
847
|
+
collisions.append((key, value))
|
|
848
|
+
if collisions:
|
|
849
|
+
return [f"{path}: contains forbidden mapping entries {collisions!r}"]
|
|
850
|
+
return []
|
|
851
|
+
|
|
852
|
+
if isinstance(actual, str):
|
|
853
|
+
if str(forbidden) in actual:
|
|
854
|
+
return [f"{path}: contains forbidden substring {forbidden!r}"]
|
|
855
|
+
return []
|
|
856
|
+
|
|
857
|
+
return [f"{path}: excludes assertion is unsupported for type {type_name(actual)}"]
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def check_for_secret_leaks(response_text: str, structured_yaml: str, payload: Any) -> list[str]:
|
|
861
|
+
errors = check_secret_assignments(response_text, context="reply")
|
|
862
|
+
errors.extend(check_secret_assignments(structured_yaml, context="structured output"))
|
|
863
|
+
errors.extend(check_secret_keys(payload, "$"))
|
|
864
|
+
return errors
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def check_secret_assignments(text: str, context: str) -> list[str]:
|
|
868
|
+
errors: list[str] = []
|
|
869
|
+
|
|
870
|
+
for line in text.splitlines():
|
|
871
|
+
stripped = line.strip()
|
|
872
|
+
if not stripped:
|
|
873
|
+
continue
|
|
874
|
+
|
|
875
|
+
keyword_match = re.search(
|
|
876
|
+
r"(?i)\b(password|secret|token|api[_-]?key|private[_-]?key|certificate|authorization|cookie)\b"
|
|
877
|
+
r"[^\n:=]{0,32}[:=]\s*(.+)$",
|
|
878
|
+
stripped,
|
|
879
|
+
)
|
|
880
|
+
if keyword_match:
|
|
881
|
+
value = keyword_match.group(2).strip().strip("'\"")
|
|
882
|
+
lowered = value.lower()
|
|
883
|
+
if lowered in {"null", "true", "false", "***", "[masked]", "<masked>", "redacted", "<redacted>"}:
|
|
884
|
+
continue
|
|
885
|
+
if value.startswith("$"):
|
|
886
|
+
continue
|
|
887
|
+
errors.append(f"{context} appears to contain unmasked secret material: {stripped!r}")
|
|
888
|
+
continue
|
|
889
|
+
|
|
890
|
+
env_secret_match = re.search(
|
|
891
|
+
r"(?i)\b[A-Z0-9_]*(PASSWORD|SECRET|TOKEN|API(?:_|-)?KEY|PRIVATE(?:_|-)?KEY|CERT(?:IFICATE)?|COOKIE)"
|
|
892
|
+
r"[A-Z0-9_]*\b\s*[:=]\s*(.+)$",
|
|
893
|
+
stripped,
|
|
894
|
+
)
|
|
895
|
+
if env_secret_match:
|
|
896
|
+
value = env_secret_match.group(2).strip().strip("'\"")
|
|
897
|
+
lowered = value.lower()
|
|
898
|
+
if lowered in {"null", "true", "false", "***", "[masked]", "<masked>", "redacted", "<redacted>"}:
|
|
899
|
+
continue
|
|
900
|
+
if value.startswith("$"):
|
|
901
|
+
continue
|
|
902
|
+
errors.append(f"{context} appears to contain unmasked secret env material: {stripped!r}")
|
|
903
|
+
continue
|
|
904
|
+
|
|
905
|
+
auth_match = re.search(r"(?i)\bauthorization\b\s*:\s*(bearer|grjwt)\s+(\S+)", stripped)
|
|
906
|
+
if auth_match:
|
|
907
|
+
value = auth_match.group(2).strip().strip("'\"")
|
|
908
|
+
if not value.startswith("$"):
|
|
909
|
+
errors.append(f"{context} appears to contain an authorization credential: {stripped!r}")
|
|
910
|
+
|
|
911
|
+
return errors
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def check_secret_keys(node: Any, path: str) -> list[str]:
|
|
915
|
+
errors: list[str] = []
|
|
916
|
+
|
|
917
|
+
if isinstance(node, dict):
|
|
918
|
+
for key, value in node.items():
|
|
919
|
+
key_path = f"{path}.{key}"
|
|
920
|
+
lowered_key = key.lower().replace("-", "_")
|
|
921
|
+
if any(keyword in lowered_key for keyword in SECRET_KEYWORDS):
|
|
922
|
+
if isinstance(value, str):
|
|
923
|
+
lowered_value = value.strip().lower()
|
|
924
|
+
if lowered_value not in {masked.lower() for masked in MASKED_VALUES} and not value.startswith("$"):
|
|
925
|
+
errors.append(f"{key_path} appears to expose secret plaintext")
|
|
926
|
+
errors.extend(check_secret_keys(value, key_path))
|
|
927
|
+
elif isinstance(node, list):
|
|
928
|
+
for index, value in enumerate(node):
|
|
929
|
+
errors.extend(check_secret_keys(value, f"{path}[{index}]"))
|
|
930
|
+
|
|
931
|
+
return errors
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
if __name__ == "__main__":
|
|
935
|
+
sys.exit(main())
|