mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
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:
parent
be1b92b624
commit
b0d71418fa
17 changed files with 1885 additions and 39 deletions
246
.github/scripts/generation_contracts.py
vendored
Normal file
246
.github/scripts/generation_contracts.py
vendored
Normal 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
|
||||
307
.github/scripts/test_generation_contracts.py
vendored
Normal file
307
.github/scripts/test_generation_contracts.py
vendored
Normal 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)
|
||||
66
.github/scripts/validate_frontmatter.py
vendored
66
.github/scripts/validate_frontmatter.py
vendored
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
5
.github/workflows/validate-frontmatter.yml
vendored
5
.github/workflows/validate-frontmatter.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue