Add AL code generation skill contracts

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd6344b4-eafd-4a58-a1c9-5a2d96fa2938
This commit is contained in:
Jesper Schulz-Wedde 2026-07-14 13:43:31 +02:00
parent be1b92b624
commit b0d71418fa
17 changed files with 1885 additions and 39 deletions

246
.github/scripts/generation_contracts.py vendored Normal file
View file

@ -0,0 +1,246 @@
"""Strict JSON Schema and semantic validation for AL generation contracts."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator, FormatChecker
MAX_REQUIREMENT_BYTES = 1_048_576
MAX_ARTIFACT_BYTES = 262_144
MAX_TOTAL_ARTIFACT_BYTES = 4_194_304
class DuplicateKeyError(ValueError):
pass
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise DuplicateKeyError(f"duplicate JSON key: {key}")
result[key] = value
return result
def load_bounded_json(path: Path, max_bytes: int = MAX_REQUIREMENT_BYTES) -> Any:
raw = path.read_bytes()
if len(raw) > max_bytes:
raise ValueError(f"JSON file exceeds {max_bytes} bytes")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("JSON file is not UTF-8") from exc
try:
return json.loads(text, object_pairs_hook=_reject_duplicate_keys)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
raise ValueError(f"malformed JSON: {exc}") from exc
def load_schema(path: Path) -> dict[str, Any]:
schema = load_bounded_json(path, max_bytes=2_097_152)
if not isinstance(schema, dict):
raise ValueError(f"schema must be a JSON object: {path}")
Draft202012Validator.check_schema(schema)
return schema
def schema_errors(schema: dict[str, Any], instance: Any) -> list[str]:
validator = Draft202012Validator(schema, format_checker=FormatChecker())
return [error.message for error in sorted(validator.iter_errors(instance), key=lambda e: list(e.path))]
def _canonical_path_error(value: Any, *, allow_dot: bool = False, require_al: bool = False) -> str | None:
if not isinstance(value, str) or not value:
return "must be a non-empty string"
if allow_dot and value == ".":
return None
if value.startswith("/") or "\\" in value or (len(value) >= 2 and value[1] == ":"):
return "must be a project-relative forward-slash path"
if value.endswith("/") or "//" in value:
return "must be canonical without empty path segments"
parts = value.split("/")
if not parts or any(part in {"", ".", ".."} for part in parts):
return "must not contain dot or traversal segments"
if any(part == ".git" for part in parts):
return "must not address .git"
if require_al and not value.endswith(".al"):
return "must end with case-sensitive .al"
return None
def _is_under(path: str, root: str) -> bool:
if root == ".":
return True
return path.startswith(f"{root}/")
def _in_id_ranges(object_id: int, ranges: list[dict[str, int]]) -> bool:
return any(item["from"] <= object_id <= item["to"] for item in ranges)
def validate_requirement_semantics(
document: dict[str, Any],
*,
existing_paths: set[str] | None = None,
) -> list[str]:
errors: list[str] = []
app_root = document.get("app-root")
if error := _canonical_path_error(app_root, allow_dot=True):
errors.append(f"app-root {error}")
return errors
requested = document.get("requested-artifacts", [])
allowlist = document.get("related-file-allowlist", [])
requested_paths = [item.get("path") for item in requested if isinstance(item, dict)]
if len(requested_paths) != len(set(requested_paths)):
errors.append("requested artifact paths must be unique")
if len(allowlist) != len(set(allowlist)):
errors.append("related-file-allowlist paths must be unique")
for path in requested_paths:
if error := _canonical_path_error(path, require_al=True):
errors.append(f"requested artifact path {path!r} {error}")
elif not _is_under(path, app_root):
errors.append(f"requested artifact path {path!r} is outside app-root {app_root!r}")
if existing_paths is not None and path in existing_paths:
errors.append(f"requested artifact path {path!r} already exists")
for path in allowlist:
if error := _canonical_path_error(path):
errors.append(f"allowlist path {path!r} {error}")
ranges = document.get("target", {}).get("app", {}).get("id-ranges", [])
ordered = sorted(ranges, key=lambda item: (item.get("from", 0), item.get("to", 0)))
previous_to = 0
for item in ordered:
start, end = item.get("from"), item.get("to")
if not isinstance(start, int) or not isinstance(end, int):
continue
if start > end:
errors.append(f"ID range {start}-{end} is inverted")
if start <= previous_to:
errors.append(f"ID range {start}-{end} overlaps another range")
previous_to = max(previous_to, end)
object_ids = [item.get("object-id") for item in requested if isinstance(item, dict) and "object-id" in item]
if len(object_ids) != len(set(object_ids)):
errors.append("requested object IDs must be unique")
for object_id in object_ids:
if isinstance(object_id, int) and not _in_id_ranges(object_id, ranges):
errors.append(f"requested object ID {object_id} is outside target id-ranges")
dependencies = document.get("target", {}).get("app", {}).get("dependencies", [])
dependency_ids = [item.get("id") for item in dependencies if isinstance(item, dict)]
if len(dependency_ids) != len(set(dependency_ids)):
errors.append("dependency IDs must be unique")
return errors
def validate_report_semantics(
document: dict[str, Any],
*,
requirement: dict[str, Any] | None = None,
) -> list[str]:
errors: list[str] = []
artifacts = document.get("artifacts", [])
omitted = document.get("omitted-guidance", [])
summary = document.get("summary", {})
coverage = summary.get("coverage", {})
paths = [artifact.get("path") for artifact in artifacts if isinstance(artifact, dict)]
ids = [artifact.get("object-id") for artifact in artifacts if isinstance(artifact, dict)]
if len(paths) != len(set(paths)):
errors.append("artifact paths must be unique")
if len(ids) != len(set(ids)):
errors.append("artifact object IDs must be unique")
content_sizes: list[int] = []
for artifact in artifacts:
if not isinstance(artifact, dict):
continue
path = artifact.get("path")
if error := _canonical_path_error(path, require_al=True):
errors.append(f"artifact path {path!r} {error}")
content = artifact.get("content")
if isinstance(content, str):
size = len(content.encode("utf-8"))
content_sizes.append(size)
if size > MAX_ARTIFACT_BYTES:
errors.append(f"artifact {path!r} exceeds {MAX_ARTIFACT_BYTES} UTF-8 bytes")
total_size = sum(content_sizes)
if total_size > MAX_TOTAL_ARTIFACT_BYTES:
errors.append(f"artifact content exceeds {MAX_TOTAL_ARTIFACT_BYTES} total UTF-8 bytes")
if summary.get("artifact-count") != len(artifacts):
errors.append("summary.artifact-count does not match artifacts")
if summary.get("total-content-bytes") != total_size:
errors.append("summary.total-content-bytes does not match UTF-8 artifact content")
if coverage.get("omitted-count") != len(omitted):
errors.append("summary.coverage.omitted-count does not match omitted-guidance")
if coverage.get("opened-article-count", 0) > coverage.get("worklist-count", 0):
errors.append("opened-article-count exceeds worklist-count")
if coverage.get("worklist-count", 0) > coverage.get("relevant-count", 0):
errors.append("worklist-count exceeds relevant-count")
if coverage.get("relevant-count", 0) > coverage.get("candidate-count", 0):
errors.append("relevant-count exceeds candidate-count")
if coverage.get("opened-article-count") != coverage.get("worklist-count"):
errors.append("opened-article-count must equal worklist-count")
if coverage.get("relevant-count") != coverage.get("worklist-count", 0) + coverage.get("omitted-count", 0):
errors.append("relevant-count must equal worklist-count plus omitted-count")
if omitted and document.get("outcome") != "partial":
errors.append("any omitted guidance requires outcome partial")
revision = document.get("knowledge-revision", {}).get("commit-sha")
for artifact in artifacts:
if not isinstance(artifact, dict):
continue
for key in ("article-references", "good-sample-references"):
for reference in artifact.get(key, []):
if reference.get("sha") != revision:
errors.append(f"{key} reference SHA must equal knowledge-revision.commit-sha")
for item in document.get("applied-guidance", []) + omitted:
if item.get("reference", {}).get("sha") != revision:
errors.append("guidance reference SHA must equal knowledge-revision.commit-sha")
for item in document.get("suppressed", []):
if item.get("reference", {}).get("sha") != revision:
errors.append("suppressed reference SHA must equal knowledge-revision.commit-sha")
if item.get("superseded-by") and item["superseded-by"].get("sha") != revision:
errors.append("superseding reference SHA must equal knowledge-revision.commit-sha")
if requirement is not None:
app_root = requirement.get("app-root", "")
requested = {
item["path"]: item
for item in requirement.get("requested-artifacts", [])
if isinstance(item, dict) and "path" in item
}
ranges = requirement.get("target", {}).get("app", {}).get("id-ranges", [])
for artifact in artifacts:
path = artifact.get("path")
request = requested.get(path)
if not _is_under(path, app_root):
errors.append(f"artifact path {path!r} is outside app-root {app_root!r}")
if request is None:
errors.append(f"artifact path {path!r} was not requested")
elif artifact.get("object-type") != request.get("object-type"):
errors.append(f"artifact {path!r} object type differs from request")
elif artifact.get("object-name") != request.get("object-name"):
errors.append(f"artifact {path!r} object name differs from request")
object_id = artifact.get("object-id")
if request is not None and request.get("object-id") is not None and object_id != request["object-id"]:
errors.append(f"artifact {path!r} object ID differs from request")
if isinstance(object_id, int) and not _in_id_ranges(object_id, ranges):
errors.append(f"artifact object ID {object_id} is outside target id-ranges")
artifact_path_set = set(paths)
for item in document.get("applied-guidance", []):
for path in item.get("artifact-paths", []):
if path not in artifact_path_set:
errors.append(f"applied-guidance references absent artifact {path!r}")
return errors

View file

@ -0,0 +1,307 @@
#!/usr/bin/env python3
from __future__ import annotations
import copy
import json
import tempfile
import unittest
from pathlib import Path
import yaml
from generation_contracts import (
MAX_ARTIFACT_BYTES,
load_bounded_json,
load_schema,
schema_errors,
validate_report_semantics,
validate_requirement_semantics,
)
ROOT = Path(__file__).resolve().parents[2]
REQUIREMENT_SCHEMA = load_schema(ROOT / "schemas/requirement-spec-v1.schema.json")
REPORT_SCHEMA = load_schema(ROOT / "schemas/generated-files-report-v1.schema.json")
VALID_REQUIREMENT = load_bounded_json(ROOT / "schemas/examples/requirement-spec-v1.example.json")
VALID_REPORT = load_bounded_json(ROOT / "schemas/examples/generated-files-report-v1.example.json")
REVIEW_INPUTS = {"pr-diff", "object-list", "file-path", "repository", "telemetry-query"}
def frontmatter(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
return yaml.safe_load(text.split("---", 2)[1])
def route(task: dict) -> dict:
inputs = set(task.get("inputs-available", []))
action = task.get("action")
has_generation = "requirement-spec" in inputs
has_review = bool(inputs & REVIEW_INPUTS)
accepted = {(item["kind"], item["version"]) for item in task.get("accepted-outputs", [])}
if has_generation and has_review and action is None:
return {"outcome": "failed", "outcome-reason": "ambiguous-action", "dispatch": []}
if action == "generate":
if not has_generation or ("generated-files-report", 1) not in accepted:
return {"outcome": "no-match", "dispatch": []}
return {
"outcome": "routed",
"dispatch": [{
"skill": {
"id": "al-code-generation",
"version": 1,
"path": "microsoft/skills/generate/al-code-generation.md",
},
"inputs": ["requirement-spec"],
"output": {"kind": "generated-files-report", "version": 1},
}],
}
if action == "review" or (action is None and has_review and not has_generation):
if accepted and ("findings-report", 1) not in accepted:
return {"outcome": "no-match", "dispatch": []}
return {
"outcome": "routed",
"dispatch": [{
"skill": {
"id": "al-code-review",
"version": 1,
"path": "microsoft/skills/review/al-code-review.md",
},
"inputs": sorted(inputs & {"pr-diff", "file-path"}),
"output": {"kind": "findings-report", "version": 1},
}],
}
return {"outcome": "failed", "outcome-reason": "explicit-generate-action-required", "dispatch": []}
class GenerationContractTests(unittest.TestCase):
def assert_requirement_invalid(self, document: dict) -> None:
self.assertTrue(
schema_errors(REQUIREMENT_SCHEMA, document) or validate_requirement_semantics(document),
"expected invalid requirement",
)
def assert_report_invalid(self, document: dict) -> None:
self.assertTrue(
schema_errors(REPORT_SCHEMA, document)
or validate_report_semantics(document, requirement=VALID_REQUIREMENT),
"expected invalid report",
)
def test_published_examples_are_strict_and_semantically_valid(self) -> None:
self.assertEqual([], schema_errors(REQUIREMENT_SCHEMA, VALID_REQUIREMENT))
self.assertEqual([], validate_requirement_semantics(VALID_REQUIREMENT))
self.assertEqual([], schema_errors(REPORT_SCHEMA, VALID_REPORT))
self.assertEqual([], validate_report_semantics(VALID_REPORT, requirement=VALID_REQUIREMENT))
def test_malformed_and_duplicate_key_json_fail(self) -> None:
with tempfile.TemporaryDirectory() as directory:
malformed = Path(directory) / "malformed.json"
malformed.write_text('{"schema-version":', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "malformed JSON"):
load_bounded_json(malformed)
duplicate = Path(directory) / "duplicate.json"
duplicate.write_text('{"schema-version":1,"schema-version":1}', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "duplicate JSON key"):
load_bounded_json(duplicate)
oversized = Path(directory) / "oversized.json"
oversized.write_bytes(b" " * 1_048_577)
with self.assertRaisesRegex(ValueError, "exceeds 1048576 bytes"):
load_bounded_json(oversized)
invalid_utf8 = Path(directory) / "invalid-utf8.json"
invalid_utf8.write_bytes(b"\xff")
with self.assertRaisesRegex(ValueError, "not UTF-8"):
load_bounded_json(invalid_utf8)
def test_requirement_unknown_version_and_unbounded_artifacts_fail(self) -> None:
unknown = copy.deepcopy(VALID_REQUIREMENT)
unknown["schema-version"] = 2
self.assert_requirement_invalid(unknown)
unbounded = copy.deepcopy(VALID_REQUIREMENT)
unbounded["requested-artifacts"] *= 65
self.assert_requirement_invalid(unbounded)
def test_unsafe_paths_fail(self) -> None:
for path in (
"/tmp/Object.al",
"C:/Object.al",
"../Object.al",
"src/../Object.al",
"src/SampleApp/./Object.al",
".git/Object.al",
"src\\Object.al",
"outside/Object.al",
):
with self.subTest(path=path):
document = copy.deepcopy(VALID_REQUIREMENT)
document["requested-artifacts"][0]["path"] = path
self.assert_requirement_invalid(document)
def test_duplicate_overwrite_delete_and_rename_requests_fail(self) -> None:
duplicate = copy.deepcopy(VALID_REQUIREMENT)
duplicate["requested-artifacts"].append(copy.deepcopy(duplicate["requested-artifacts"][0]))
self.assert_requirement_invalid(duplicate)
for operation in ("overwrite", "delete", "rename"):
with self.subTest(operation=operation):
document = copy.deepcopy(VALID_REQUIREMENT)
document["requested-artifacts"][0]["operation"] = operation
self.assert_requirement_invalid(document)
existing = {"src/SampleApp/Setup/CQSampleSetup.Table.al"}
self.assertTrue(validate_requirement_semantics(VALID_REQUIREMENT, existing_paths=existing))
aliased = copy.deepcopy(VALID_REQUIREMENT)
aliased["requested-artifacts"][0]["path"] = "src/SampleApp/./Setup/CQSampleSetup.Table.al"
self.assert_requirement_invalid(aliased)
self.assertTrue(validate_requirement_semantics(aliased, existing_paths=existing))
def test_id_range_failures(self) -> None:
outside = copy.deepcopy(VALID_REQUIREMENT)
outside["requested-artifacts"][0]["object-id"] = 80000
self.assert_requirement_invalid(outside)
inverted = copy.deepcopy(VALID_REQUIREMENT)
inverted["target"]["app"]["id-ranges"][0] = {"from": 70049, "to": 70000}
self.assert_requirement_invalid(inverted)
overlapping = copy.deepcopy(VALID_REQUIREMENT)
overlapping["target"]["app"]["id-ranges"].append({"from": 70025, "to": 70075})
self.assert_requirement_invalid(overlapping)
def test_report_duplicate_overwrite_findings_id_range_and_size_fail(self) -> None:
duplicate = copy.deepcopy(VALID_REPORT)
duplicate["artifacts"].append(copy.deepcopy(duplicate["artifacts"][0]))
duplicate["summary"]["artifact-count"] = 2
duplicate["summary"]["total-content-bytes"] *= 2
self.assert_report_invalid(duplicate)
overwrite = copy.deepcopy(VALID_REPORT)
overwrite["artifacts"][0]["operation"] = "overwrite"
self.assert_report_invalid(overwrite)
findings = copy.deepcopy(VALID_REPORT)
findings["findings"] = []
self.assert_report_invalid(findings)
outside_id = copy.deepcopy(VALID_REPORT)
outside_id["artifacts"][0]["object-id"] = 80000
self.assert_report_invalid(outside_id)
changed_id = copy.deepcopy(VALID_REPORT)
changed_id["artifacts"][0]["object-id"] = 70001
self.assert_report_invalid(changed_id)
changed_name = copy.deepcopy(VALID_REPORT)
changed_name["artifacts"][0]["object-name"] = "CQ Renamed Setup"
self.assert_report_invalid(changed_name)
oversized = copy.deepcopy(VALID_REPORT)
oversized["artifacts"][0]["content"] = "é" * (MAX_ARTIFACT_BYTES // 2 + 1)
oversized["summary"]["total-content-bytes"] = len(oversized["artifacts"][0]["content"].encode("utf-8"))
self.assert_report_invalid(oversized)
def test_any_guidance_omission_forces_partial(self) -> None:
report = copy.deepcopy(VALID_REPORT)
report["omitted-guidance"] = [{
"reference": {
"path": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md",
"sha": report["knowledge-revision"]["commit-sha"],
},
"reason": "context-budget",
"detail": "Ranked after the configured context budget.",
}]
report["summary"]["coverage"]["omitted-count"] = 1
self.assert_report_invalid(report)
report["outcome"] = "partial"
report["summary"]["coverage"]["relevant-count"] = 2
self.assertEqual([], schema_errors(REPORT_SCHEMA, report))
self.assertEqual([], validate_report_semantics(report, requirement=VALID_REQUIREMENT))
def test_coverage_cannot_hide_unopened_relevant_guidance(self) -> None:
report = copy.deepcopy(VALID_REPORT)
report["summary"]["coverage"]["relevant-count"] = 2
report["summary"]["coverage"]["worklist-count"] = 2
self.assert_report_invalid(report)
def test_immutable_revision_can_identify_a_fork(self) -> None:
report = copy.deepcopy(VALID_REPORT)
report["knowledge-revision"]["repository"] = "https://github.com/contoso/BCQuality"
self.assertEqual([], schema_errors(REPORT_SCHEMA, report))
self.assertEqual([], validate_report_semantics(report, requirement=VALID_REQUIREMENT))
def test_deterministic_entry_routing_matrix(self) -> None:
cases = {
"generation-only": (
{
"action": "generate",
"inputs-available": ["requirement-spec"],
"accepted-outputs": [{"kind": "generated-files-report", "version": 1}],
},
"al-code-generation",
),
"legacy-review-only": (
{"inputs-available": ["pr-diff"]},
"al-code-review",
),
"both-explicit-generate": (
{
"action": "generate",
"inputs-available": ["pr-diff", "requirement-spec"],
"accepted-outputs": [{"kind": "generated-files-report", "version": 1}],
},
"al-code-generation",
),
"both-explicit-review": (
{
"action": "review",
"inputs-available": ["pr-diff", "requirement-spec"],
"accepted-outputs": [{"kind": "findings-report", "version": 1}],
},
"al-code-review",
),
}
for name, (task, expected) in cases.items():
with self.subTest(name=name):
result = route(task)
self.assertEqual("routed", result["outcome"])
self.assertEqual(expected, result["dispatch"][0]["skill"]["id"])
self.assertIn("output", result["dispatch"][0])
ambiguous = route({"inputs-available": ["pr-diff", "requirement-spec"]})
self.assertEqual("failed", ambiguous["outcome"])
self.assertEqual("ambiguous-action", ambiguous["outcome-reason"])
wrong_version = route({
"action": "generate",
"inputs-available": ["requirement-spec"],
"accepted-outputs": [{"kind": "generated-files-report", "version": 2}],
})
self.assertEqual("no-match", wrong_version["outcome"])
def test_current_non_capability_consumers_remain_generation_ineligible(self) -> None:
result = route({"inputs-available": ["repository"]})
self.assertEqual("al-code-review", result["dispatch"][0]["skill"]["id"])
no_action = route({
"inputs-available": ["requirement-spec"],
"accepted-outputs": [{"kind": "generated-files-report", "version": 1}],
})
self.assertEqual("failed", no_action["outcome"])
def test_generation_skill_shape_and_internal_references(self) -> None:
skill_path = ROOT / "microsoft/skills/generate/al-code-generation.md"
metadata = frontmatter(skill_path)
self.assertEqual("al-code-generation", metadata["id"])
self.assertEqual(["requirement-spec"], metadata["inputs"])
self.assertEqual(["generated-files-report"], metadata["outputs"])
self.assertEqual(1, metadata["output-version"])
for relative in (
"schemas/requirement-spec-v1.schema.json",
"schemas/generated-files-report-v1.schema.json",
"schemas/examples/requirement-spec-v1.example.json",
"schemas/examples/generated-files-report-v1.example.json",
"microsoft/knowledge/appsource/object-affixes-prevent-collisions.md",
"microsoft/knowledge/appsource/object-affixes-prevent-collisions.good.al",
):
self.assertTrue((ROOT / relative).is_file(), relative)
def test_plugin_bridges_are_intent_isolated(self) -> None:
review = (ROOT / "skills/bcquality-al-review/SKILL.md").read_text(encoding="utf-8")
generate = (ROOT / "skills/bcquality-al-generate/SKILL.md").read_text(encoding="utf-8")
self.assertIn("Do **not** use this skill to *generate* AL code", review)
self.assertNotIn("action: generate", review)
self.assertIn("action: generate", generate)
self.assertIn("generated-files-report", generate)
self.assertNotIn("findings-report", generate)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -21,6 +21,14 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from generation_contracts import (
load_bounded_json,
load_schema,
schema_errors,
validate_report_semantics,
validate_requirement_semantics,
)
try:
import yaml
except ImportError:
@ -39,14 +47,16 @@ ACTION_SKILL_REQUIRED_KEYS = {
}
ACTION_SKILL_OPTIONAL_KEYS = {
"bc-version", "technologies", "countries", "application-area", "sub-skills",
"output-version",
}
META_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"}
ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"}
STANDARD_INPUTS = {
"pr-diff", "object-list", "file-path", "repository", "telemetry-query",
"requirement-spec",
}
ALLOWED_OUTPUTS = {"findings-report"}
ALLOWED_OUTPUTS = {"findings-report", "generated-files-report"}
VALID_SAMPLE_KINDS = {"good", "bad"}
ACTION_SKILL_SECTIONS = ["Source", "Relevance", "Worklist", "Action", "Output"]
@ -331,9 +341,30 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None:
if not is_non_empty_list_of_str(out):
report.error(path, "R18", "outputs must be a non-empty list of strings", 1)
else:
if len(out) != 1:
report.error(path, "R18", "outputs must contain exactly one output kind", 1)
bad = [x for x in out if x not in ALLOWED_OUTPUTS]
if bad:
report.error(path, "R18", f"outputs contains non-allowed values {bad}; currently only {sorted(ALLOWED_OUTPUTS)} is defined", 1)
report.error(path, "R18", f"outputs contains non-allowed values {bad}; allowed values are {sorted(ALLOWED_OUTPUTS)}", 1)
if "output-version" in fm:
output_version = fm["output-version"]
if not isinstance(output_version, int) or isinstance(output_version, bool) or output_version <= 0:
report.error(path, "R18", "output-version must be a positive integer", 1)
# R27 generation skills have one stable, non-composed contract shape.
if fm.get("outputs") == ["generated-files-report"]:
rel = path.as_posix()
expected_suffix = "microsoft/skills/generate/al-code-generation.md"
if not rel.endswith(expected_suffix):
report.error(path, "R27", f"generated-files-report is reserved for {expected_suffix}", 1)
if fm.get("id") != "al-code-generation" or fm.get("version") != 1:
report.error(path, "R27", "generation skill must have id al-code-generation and version 1", 1)
if fm.get("inputs") != ["requirement-spec"]:
report.error(path, "R27", "generation skill must accept only [requirement-spec]", 1)
if fm.get("output-version") != 1:
report.error(path, "R27", "generation skill must declare output-version: 1", 1)
if "sub-skills" in fm:
report.error(path, "R27", "generation skill must be a leaf without sub-skills", 1)
# R19 optional filter dimensions, if present
if "bc-version" in fm:
@ -611,6 +642,37 @@ def run(root: Path) -> Report:
for path, fm in action_skill_fms:
validate_sub_skills_registry(path, fm, root, report)
# Fifth pass: R28 published generation schemas and examples are strict and coherent.
requirement_schema_path = root / "schemas" / "requirement-spec-v1.schema.json"
report_schema_path = root / "schemas" / "generated-files-report-v1.schema.json"
requirement_example_path = root / "schemas" / "examples" / "requirement-spec-v1.example.json"
report_example_path = root / "schemas" / "examples" / "generated-files-report-v1.example.json"
contract_paths = (
requirement_schema_path,
report_schema_path,
requirement_example_path,
report_example_path,
)
missing_contracts = [path for path in contract_paths if not path.is_file()]
for path in missing_contracts:
report.error(path, "R28", "required generation contract file is missing")
if not missing_contracts:
try:
requirement_schema = load_schema(requirement_schema_path)
generated_report_schema = load_schema(report_schema_path)
requirement_example = load_bounded_json(requirement_example_path)
generated_report_example = load_bounded_json(report_example_path)
for message in schema_errors(requirement_schema, requirement_example):
report.error(requirement_example_path, "R28", message)
for message in validate_requirement_semantics(requirement_example):
report.error(requirement_example_path, "R28", message)
for message in schema_errors(generated_report_schema, generated_report_example):
report.error(report_example_path, "R28", message)
for message in validate_report_semantics(generated_report_example, requirement=requirement_example):
report.error(report_example_path, "R28", message)
except (OSError, ValueError) as exc:
report.error(requirement_schema_path, "R28", f"generation contract validation failed: {exc}")
return report

View file

@ -19,7 +19,10 @@ jobs:
python-version: "3.12"
- name: Install dependencies
run: pip install pyyaml
run: pip install pyyaml jsonschema
- name: Run validator
run: python .github/scripts/validate_frontmatter.py --root .
- name: Test generation contracts and routing
run: python .github/scripts/test_generation_contracts.py