@tyroneross/build-loop 0.36.0 → 0.43.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/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/build-loop-surface.mdc +12 -11
- package/.cursor/rules/skill-index.mdc +33 -0
- package/AGENTS.md +213 -34
- package/README.md +99 -31
- package/agents/advisor.md +4 -4
- package/agents/alignment-checker.md +2 -2
- package/agents/architecture-scout.md +4 -4
- package/agents/build-orchestrator.md +38 -36
- package/agents/database-assessor.md +11 -5
- package/agents/design-contract-specialist.md +8 -8
- package/agents/fact-checker.md +13 -3
- package/agents/fix-critique.md +2 -2
- package/agents/independent-auditor.md +60 -7
- package/agents/leak-scanner.md +82 -0
- package/agents/overfitting-reviewer.md +2 -2
- package/agents/plan-critic.md +1 -1
- package/agents/promotion-reviewer.md +5 -5
- package/agents/retrospective-synthesizer.md +138 -35
- package/agents/scope-auditor.md +82 -11
- package/agents/security-reviewer.md +56 -2
- package/agents/self-improvement-architect.md +17 -3
- package/agents/transcript-pattern-miner.md +5 -5
- package/agents/ui-validator.md +1 -1
- package/bin/build-loop-debugger.js +143 -0
- package/bin/build-loop-install.js +1 -4
- package/bin/build-loop-load-probe.js +345 -0
- package/codex-skills/build-loop/SKILL.md +28 -6
- package/commands/feedback.md +37 -0
- package/dist/src/interactive-verifier.d.ts +1 -14
- package/dist/src/interactive-verifier.d.ts.map +1 -1
- package/dist/src/interactive-verifier.js +6 -113
- package/dist/src/interactive-verifier.js.map +1 -1
- package/dist/src/quality.d.ts +5 -0
- package/dist/src/quality.d.ts.map +1 -0
- package/dist/src/quality.js +81 -0
- package/dist/src/quality.js.map +1 -0
- package/dist/src/storage.d.ts.map +1 -1
- package/dist/src/storage.js +37 -3
- package/dist/src/storage.js.map +1 -1
- package/docs/agent-surface-policy.md +35 -31
- package/docs/memory-setup.md +19 -0
- package/hooks/git/pre-push +65 -4
- package/hooks/hooks.json +95 -38
- package/hooks/pre-commit +20 -1
- package/hooks/pre-edit-rally-point.sh +10 -3
- package/hooks/session-start-codex-hook-trust.sh +30 -0
- package/hooks/session-start-git-hooks.sh +3 -1
- package/hooks/session-start-rally-point.sh +52 -4
- package/hooks/session-start-worktree-gc.sh +47 -94
- package/hooks/stop-transcript-sweep.sh +173 -0
- package/hooks/test_closeout.sh +14 -2
- package/package.json +8 -7
- package/scripts/README.md +1 -1
- package/scripts/_paths.py +65 -0
- package/scripts/groundwork_exchange.py +1012 -0
- package/scripts/install_memory.py +33 -1
- package/scripts/lessons_index/ingest.py +13 -2
- package/scripts/lessons_index/query.py +36 -13
- package/scripts/memory_context/__init__.py +108 -14
- package/scripts/memory_graph/__init__.py +5 -1
- package/scripts/project_resolver.py +42 -36
- package/scripts/sync_plugin_cache.py +37 -2
- package/skills/agent-rally-point/SKILL.md +46 -0
- package/skills/api-registry-bridge/SKILL.md +1 -1
- package/skills/architecture/dead/SKILL.md +1 -1
- package/skills/architecture/impact/SKILL.md +1 -1
- package/skills/architecture/review/SKILL.md +1 -1
- package/skills/architecture/rules/SKILL.md +3 -3
- package/skills/architecture/scan/SKILL.md +1 -1
- package/skills/architecture/trace/SKILL.md +1 -1
- package/skills/attribution-standard/SKILL.md +6 -6
- package/skills/auto-decision-capture/SKILL.md +31 -2
- package/skills/auto-finding-capture/SKILL.md +28 -1
- package/skills/build-loop/SKILL.md +131 -23
- package/skills/build-loop/fallbacks.md +16 -21
- package/skills/build-loop/phases/ui-validation.md +2 -2
- package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
- package/skills/build-loop/references/apple-native-planning.md +1 -1
- package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
- package/skills/build-loop/references/autonomy-dashboard.md +115 -0
- package/skills/build-loop/references/capability-routing.md +24 -2
- package/skills/build-loop/references/coordination.md +24 -6
- package/skills/build-loop/references/experiment-results-template.md +15 -3
- package/skills/build-loop/references/leadership.md +1 -1
- package/skills/build-loop/references/memory.md +14 -3
- package/skills/build-loop/references/modular-systems-pack.md +8 -0
- package/skills/build-loop/references/output-style.md +86 -0
- package/skills/build-loop/references/phase-1-assess.md +102 -2
- package/skills/build-loop/references/phase-2-plan.md +9 -1
- package/skills/build-loop/references/phase-3-execute.md +5 -2
- package/skills/build-loop/references/phase-4-review.md +85 -8
- package/skills/build-loop/references/phase-5-iterate.md +76 -8
- package/skills/build-loop/references/phase-6-learn.md +10 -17
- package/skills/build-loop/references/privileged-request-broker.md +254 -0
- package/skills/build-loop/references/resource-aware-execution.md +183 -0
- package/skills/build-loop/references/self-recursive-dev.md +2 -2
- package/skills/build-loop/references/status-output-format.md +207 -0
- package/skills/build-loop/references/verify-dispatch.md +56 -2
- package/skills/building-with-deepagents/SKILL.md +1 -1
- package/skills/claim-scope/SKILL.md +185 -0
- package/skills/color-engine/SKILL.md +103 -0
- package/skills/color-engine/_core.py +464 -0
- package/skills/color-engine/color_engine.py +175 -0
- package/skills/cost-rca/SKILL.md +61 -0
- package/skills/data-plane-worktrees/SKILL.md +139 -0
- package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
- package/skills/database-practice/SKILL.md +200 -0
- package/skills/database-practice/references/diagnostic-queries.sql +126 -0
- package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
- package/skills/database-practice/scripts/db_table_map.py +1244 -0
- package/skills/database-practice/scripts/test_db_table_map.py +514 -0
- package/skills/debug-loop/SKILL.md +36 -6
- package/skills/debugging-memory/SKILL.md +32 -430
- package/skills/debugging-memory/references/pattern-extraction.md +4 -4
- package/skills/debugging-memory/references/search.md +32 -120
- package/skills/debugging-memory/references/store.md +32 -126
- package/skills/debugging-memory/references/subagent-integration.md +1 -1
- package/skills/decision-queue/SKILL.md +251 -0
- package/skills/decision-queue/assets/template.html +1242 -0
- package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
- package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
- package/skills/defenseclaw-bridge/SKILL.md +2 -2
- package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
- package/skills/drain-proposals/SKILL.md +53 -0
- package/skills/focused-loop-builder/SKILL.md +31 -0
- package/skills/focused-loop-builder/references/spec-format.md +27 -0
- package/skills/handoff/SKILL.md +169 -8
- package/skills/ibr-bridge/SKILL.md +4 -1
- package/skills/knowledge/SKILL.md +26 -14
- package/skills/knowledge/references/review-mode.md +2 -3
- package/skills/knowledge/templates/madr-minimal.md +1 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/model-bakeoff/SKILL.md +48 -10
- package/skills/model-tiering/SKILL.md +92 -31
- package/skills/native-ax-driver/SKILL.md +38 -5
- package/skills/native-ax-driver/scripts/native_driver.py +278 -22
- package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
- package/skills/optimize/SKILL.md +1 -1
- package/skills/plugin-builder/SKILL.md +48 -1
- package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
- package/skills/plugin-builder/references/distribution.md +13 -2
- package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
- package/skills/plugin-tests/SKILL.md +2 -2
- package/skills/recursive-retrospective/SKILL.md +1 -1
- package/skills/repo-closeout/SKILL.md +17 -0
- package/skills/repo-closeout/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/SKILL.md +179 -0
- package/skills/repo-maintenance/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
- package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
- package/skills/repo-maintenance/references/safety-protocol.md +106 -0
- package/skills/repo-maintenance/references/stack-profiles.md +138 -0
- package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
- package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
- package/skills/repository-intelligence/SKILL.md +189 -0
- package/skills/repository-intelligence/agents/openai.yaml +4 -0
- package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
- package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
- package/skills/research/SKILL.md +12 -2
- package/skills/root-cause-analysis/SKILL.md +1 -1
- package/skills/runtime-parity-verification/SKILL.md +36 -1
- package/skills/security-methodology/SKILL.md +23 -10
- package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
- package/skills/security-methodology/references/cross-source-matrix.md +1 -1
- package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
- package/skills/security-scan/SKILL.md +55 -15
- package/skills/self-improve/SKILL.md +70 -50
- package/skills/silent-assumptions/SKILL.md +341 -0
- package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
- package/skills/spec-writing/SKILL.md +128 -24
- package/skills/spec-writing/scripts/check_checklist.py +114 -15
- package/skills/ui-design/SKILL.md +6 -4
- package/skills/ui-design/references/color-engine.md +132 -0
- package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
- package/skills/ui-design/references/ui-guidance-sources.md +1 -1
- package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
- package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
- package/plugin-artifacts/codex/AGENTS.md +0 -560
- package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
- package/plugin-artifacts/codex/LICENSE +0 -202
- package/plugin-artifacts/codex/README.md +0 -313
- package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
- package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
- package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
- package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
- package/plugin-artifacts/codex/references/backlog-system.md +0 -285
- package/plugin-artifacts/codex/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
- package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
- package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
- package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
- package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
- package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/references/leadership.md +0 -72
- package/plugin-artifacts/codex/references/memory-systems.md +0 -261
- package/plugin-artifacts/codex/references/memory.md +0 -313
- package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
- package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
- package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
- package/plugin-artifacts/codex/references/self-review.md +0 -234
- package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
- package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
- package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
- package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
- package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
- package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
- package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
- package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
- package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
- package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
- package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
- package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
- package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
- package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
- package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
- package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
- package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
- package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
- package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
- package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
- package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
- package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
- package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
- package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
- package/scripts/build_codex_plugin_artifact.py +0 -321
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate Groundwork build requests and emit Build Loop implementation maps.
|
|
3
|
+
|
|
4
|
+
The adapter is intentionally stdlib-only. Groundwork remains authoritative for
|
|
5
|
+
desired state and convergence; this script validates the immutable request,
|
|
6
|
+
verifies repository-local delivery evidence, and returns a digest-bound map.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, NoReturn
|
|
24
|
+
from urllib.parse import parse_qsl, unquote, urlsplit
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
BUILD_REQUEST_CONTRACT = "groundwork.build-request/v1"
|
|
28
|
+
IMPLEMENTATION_MAP_CONTRACT = "build-loop.implementation-map/v1"
|
|
29
|
+
CONVERGENCE_CONTRACT = "groundwork.convergence/v1"
|
|
30
|
+
DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|
31
|
+
COMMIT_RE = re.compile(r"^[0-9a-fA-F]{7,64}$")
|
|
32
|
+
ARCHITECTURE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
|
33
|
+
KINDS = {"task", "component", "contract", "requirement"}
|
|
34
|
+
STATUSES = {"not-started", "implemented", "verified", "blocked", "manual", "diverged"}
|
|
35
|
+
EVIDENCE_KINDS = {"test", "runtime", "inspection"}
|
|
36
|
+
EVIDENCE_OUTCOMES = {"passed", "failed", "blocked", "manual"}
|
|
37
|
+
IMPACTS = {"none", "low", "medium", "high", "blocking"}
|
|
38
|
+
PLATFORMS = {"web", "vite-spa", "ios", "macos", "watchos", "tvos", "visionos", "android", "claude-plugin", "agent-system", "api", "service", "other"}
|
|
39
|
+
SURFACE_ROLES = {"primary", "companion", "admin", "extension", "service"}
|
|
40
|
+
PROVENANCE = {"observed", "decided", "assumed", "derived"}
|
|
41
|
+
PRIVATE_SEGMENTS = {".git", ".ssh", ".aws", ".gnupg", ".env", "secrets", "credentials"}
|
|
42
|
+
PRIVATE_NAME_RE = re.compile(r"^(?:credentials(?:\.[^.]+)?|secrets?(?:\.[^.]+)?|id_(?:rsa|dsa|ecdsa|ed25519))$", re.I)
|
|
43
|
+
URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s\"'<>]+")
|
|
44
|
+
PRIVATE_PATH_RE = re.compile(r"(?:^|[\s\"'(])(?:/(?:Users|home|root|var/folders)/[^\s\"')]+|[A-Za-z]:\\Users\\[^\s\"')]+)", re.I)
|
|
45
|
+
DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$")
|
|
46
|
+
SECRET_ASSIGNMENT_RE = re.compile(r"\b(?:api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|client[-_ ]?secret|password|passwd|private[-_ ]?key|secret|credential|authorization)\b\s*(?:=|:|\bis\b)", re.I)
|
|
47
|
+
SECRET_VALUE_RES = (
|
|
48
|
+
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.I),
|
|
49
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
50
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
|
51
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
|
|
52
|
+
re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{12,}\b"),
|
|
53
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{12,}\b"),
|
|
54
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ExchangeError(ValueError):
|
|
59
|
+
"""Fail-closed contract or evidence validation error."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fail(message: str) -> NoReturn:
|
|
63
|
+
raise ExchangeError(message)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _js_number(value: int | float) -> str:
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return "true" if value else "false"
|
|
69
|
+
if isinstance(value, int):
|
|
70
|
+
if abs(value) <= 9_007_199_254_740_991:
|
|
71
|
+
return str(value)
|
|
72
|
+
try:
|
|
73
|
+
value = float(value)
|
|
74
|
+
except OverflowError:
|
|
75
|
+
_fail("JSON numbers must be finite")
|
|
76
|
+
if not math.isfinite(value):
|
|
77
|
+
_fail("JSON numbers must be finite")
|
|
78
|
+
if value == 0:
|
|
79
|
+
return "0"
|
|
80
|
+
absolute = abs(value)
|
|
81
|
+
shortest = repr(value).lower()
|
|
82
|
+
if 1e-6 <= absolute < 1e21:
|
|
83
|
+
if "e" in shortest:
|
|
84
|
+
return format(Decimal(shortest), "f")
|
|
85
|
+
if shortest.endswith(".0"):
|
|
86
|
+
return shortest[:-2]
|
|
87
|
+
return shortest
|
|
88
|
+
if "e" not in shortest:
|
|
89
|
+
coefficient, exponent = f"{value:.15e}".split("e")
|
|
90
|
+
coefficient = coefficient.rstrip("0").rstrip(".")
|
|
91
|
+
else:
|
|
92
|
+
coefficient, exponent = shortest.split("e")
|
|
93
|
+
exponent_number = int(exponent)
|
|
94
|
+
sign = "+" if exponent_number >= 0 else "-"
|
|
95
|
+
return f"{coefficient}e{sign}{abs(exponent_number)}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _quote_json_string(value: str) -> str:
|
|
99
|
+
quoted = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
100
|
+
return re.sub(
|
|
101
|
+
r"[\ud800-\udfff]",
|
|
102
|
+
lambda match: f"\\u{ord(match.group(0)):04x}",
|
|
103
|
+
quoted,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _utf16_sort_key(value: str) -> bytes:
|
|
108
|
+
return value.encode("utf-16-be", errors="surrogatepass")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_json(value: Any) -> str:
|
|
112
|
+
"""Match Groundwork's recursively sorted compact UTF-8 JSON projection."""
|
|
113
|
+
if value is None:
|
|
114
|
+
return "null"
|
|
115
|
+
if value is True:
|
|
116
|
+
return "true"
|
|
117
|
+
if value is False:
|
|
118
|
+
return "false"
|
|
119
|
+
if isinstance(value, (int, float)):
|
|
120
|
+
return _js_number(value)
|
|
121
|
+
if isinstance(value, str):
|
|
122
|
+
return _quote_json_string(value)
|
|
123
|
+
if isinstance(value, list):
|
|
124
|
+
return "[" + ",".join(normalize_json(item) for item in value) + "]"
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
if not all(isinstance(key, str) for key in value):
|
|
127
|
+
_fail("JSON object keys must be strings")
|
|
128
|
+
return "{" + ",".join(
|
|
129
|
+
f"{_quote_json_string(key)}:{normalize_json(value[key])}"
|
|
130
|
+
for key in sorted(value, key=_utf16_sort_key)
|
|
131
|
+
) + "}"
|
|
132
|
+
_fail(f"unsupported JSON value: {type(value).__name__}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def digest_normalized(value: Any) -> str:
|
|
136
|
+
return "sha256:" + hashlib.sha256(normalize_json(value).encode("utf-8")).hexdigest()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def digest_file(path: Path) -> str:
|
|
140
|
+
digest = hashlib.sha256()
|
|
141
|
+
with path.open("rb") as handle:
|
|
142
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
143
|
+
digest.update(chunk)
|
|
144
|
+
return "sha256:" + digest.hexdigest()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_json(path: Path) -> Any:
|
|
148
|
+
try:
|
|
149
|
+
return json.loads(path.read_text(encoding="utf-8"), parse_constant=lambda token: _fail(f"non-finite number {token}"))
|
|
150
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
151
|
+
_fail(f"cannot read JSON {path}: {exc}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _require_object(value: Any, label: str) -> dict[str, Any]:
|
|
155
|
+
if not isinstance(value, dict):
|
|
156
|
+
_fail(f"{label} must be an object")
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _strict_keys(value: dict[str, Any], required: set[str], optional: set[str], label: str) -> None:
|
|
161
|
+
missing = sorted(required - value.keys())
|
|
162
|
+
extra = sorted(value.keys() - required - optional)
|
|
163
|
+
if missing:
|
|
164
|
+
_fail(f"{label} missing required fields: {', '.join(missing)}")
|
|
165
|
+
if extra:
|
|
166
|
+
_fail(f"{label} contains unsupported fields: {', '.join(extra)}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _nonempty(value: Any, label: str) -> str:
|
|
170
|
+
if not isinstance(value, str) or not value:
|
|
171
|
+
_fail(f"{label} must be a non-empty string")
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _list(value: Any, label: str) -> list[Any]:
|
|
176
|
+
if not isinstance(value, list):
|
|
177
|
+
_fail(f"{label} must be an array")
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _digest(value: Any, label: str) -> str:
|
|
182
|
+
text = _nonempty(value, label)
|
|
183
|
+
if not DIGEST_RE.fullmatch(text):
|
|
184
|
+
_fail(f"{label} must be a lowercase sha256 digest")
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _timestamp(value: Any, label: str) -> datetime:
|
|
189
|
+
text = _nonempty(value, label)
|
|
190
|
+
if not DATETIME_RE.fullmatch(text):
|
|
191
|
+
_fail(f"{label} must use RFC 3339 date-time syntax with seconds and an offset")
|
|
192
|
+
try:
|
|
193
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
_fail(f"{label} must be an ISO-8601 timestamp: {exc}")
|
|
196
|
+
if parsed.tzinfo is None:
|
|
197
|
+
_fail(f"{label} must include a timezone offset")
|
|
198
|
+
return parsed.astimezone(timezone.utc)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _unique(values: list[str], label: str) -> None:
|
|
202
|
+
if len(values) != len(set(values)):
|
|
203
|
+
_fail(f"{label} contains duplicate values")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _enum(value: Any, allowed: set[str], label: str) -> str:
|
|
207
|
+
if not isinstance(value, str) or value not in allowed:
|
|
208
|
+
_fail(f"{label} is unsupported")
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _architecture_id(value: Any, label: str) -> str:
|
|
213
|
+
text = _nonempty(value, label)
|
|
214
|
+
if not ARCHITECTURE_ID_RE.fullmatch(text):
|
|
215
|
+
_fail(f"{label} must use Groundwork architecture ID syntax")
|
|
216
|
+
return text
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _safe_repo_path(value: Any, label: str) -> str:
|
|
220
|
+
text = _nonempty(value, label)
|
|
221
|
+
if "\0" in text or text.startswith(("/", "~", "\\\\")) or re.match(r"^[A-Za-z]:[\\/]", text):
|
|
222
|
+
_fail(f"{label} must be repository-relative")
|
|
223
|
+
if "\\" in text or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", text):
|
|
224
|
+
_fail(f"{label} must use a repository-relative forward-slash path")
|
|
225
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", text):
|
|
226
|
+
_fail(f"{label} contains invalid percent encoding")
|
|
227
|
+
try:
|
|
228
|
+
decoded = unquote(text, errors="strict")
|
|
229
|
+
except UnicodeDecodeError:
|
|
230
|
+
_fail(f"{label} contains invalid UTF-8 percent encoding")
|
|
231
|
+
segments = decoded.split("/")
|
|
232
|
+
if any(not segment or segment in {".", ".."} for segment in segments):
|
|
233
|
+
_fail(f"{label} contains an unsafe path segment")
|
|
234
|
+
for segment in segments:
|
|
235
|
+
lower = segment.lower()
|
|
236
|
+
if lower in PRIVATE_SEGMENTS or (lower.startswith(".env.") and lower != ".env.example") or PRIVATE_NAME_RE.fullmatch(segment):
|
|
237
|
+
_fail(f"{label} points to a private or credential-bearing location")
|
|
238
|
+
return text
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _owned_files(raw: Any, label: str) -> list[str]:
|
|
242
|
+
values = [
|
|
243
|
+
_safe_repo_path(item, f"{label}[{index}]")
|
|
244
|
+
for index, item in enumerate(_list(raw, label))
|
|
245
|
+
]
|
|
246
|
+
if not values:
|
|
247
|
+
_fail(f"{label} must contain at least 1 value(s)")
|
|
248
|
+
_unique(values, label)
|
|
249
|
+
return values
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _safe_text(value: str, label: str) -> None:
|
|
253
|
+
if PRIVATE_PATH_RE.search(value):
|
|
254
|
+
_fail(f"{label} contains an absolute private path")
|
|
255
|
+
for candidate in URL_RE.findall(value):
|
|
256
|
+
cleaned = candidate.rstrip("),.;!?")
|
|
257
|
+
try:
|
|
258
|
+
parsed = urlsplit(cleaned)
|
|
259
|
+
except ValueError:
|
|
260
|
+
_fail(f"{label} contains a malformed URL")
|
|
261
|
+
if parsed.username or parsed.password:
|
|
262
|
+
_fail(f"{label} contains a credential-bearing URL")
|
|
263
|
+
for key, _ in parse_qsl(parsed.query, keep_blank_values=True):
|
|
264
|
+
if re.search(r"(?:token|secret|password|passwd|api[-_]?key|credential|signature|access[-_]?key)", key, re.I):
|
|
265
|
+
_fail(f"{label} contains a credential-bearing URL")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _safe_manual_text(value: str, label: str) -> None:
|
|
269
|
+
_safe_text(value, label)
|
|
270
|
+
if SECRET_ASSIGNMENT_RE.search(value) or any(pattern.search(value) for pattern in SECRET_VALUE_RES):
|
|
271
|
+
_fail(f"{label} contains a secret or credential assignment")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _string_list(raw: Any, label: str, *, minimum: int = 0, unique: bool = False) -> list[str]:
|
|
275
|
+
values = [_nonempty(item, label) for item in _list(raw, label)]
|
|
276
|
+
if len(values) < minimum:
|
|
277
|
+
_fail(f"{label} must contain at least {minimum} value(s)")
|
|
278
|
+
if unique:
|
|
279
|
+
_unique(values, label)
|
|
280
|
+
return values
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _validate_qualified_ref(raw: Any, label: str) -> dict[str, Any]:
|
|
284
|
+
value = _require_object(raw, label)
|
|
285
|
+
_strict_keys(value, {"specId", "kind", "id"}, set(), label)
|
|
286
|
+
_architecture_id(value["specId"], f"{label}.specId")
|
|
287
|
+
_architecture_id(value["id"], f"{label}.id")
|
|
288
|
+
_enum(value["kind"], {"component", "contract", "feature", "screen", "element", "requirement", "flow", "task"}, f"{label}.kind")
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _validate_architecture(
|
|
293
|
+
architecture: dict[str, Any],
|
|
294
|
+
*,
|
|
295
|
+
spec_id: str,
|
|
296
|
+
local_refs: set[str],
|
|
297
|
+
screen_states: dict[str, set[str]],
|
|
298
|
+
) -> tuple[set[str], set[str], set[str]]:
|
|
299
|
+
refs: list[tuple[dict[str, Any], str, str | None]] = []
|
|
300
|
+
component_ids: list[str] = []
|
|
301
|
+
for index, raw in enumerate(architecture["components"]):
|
|
302
|
+
label = f"architecture.components[{index}]"
|
|
303
|
+
item = _require_object(raw, label)
|
|
304
|
+
_strict_keys(item, {"id", "name", "kind", "featureIds", "owner"}, {"description", "provenance", "ownedFiles"}, label)
|
|
305
|
+
component_ids.append(_architecture_id(item["id"], f"{label}.id"))
|
|
306
|
+
_nonempty(item["name"], f"{label}.name")
|
|
307
|
+
_nonempty(item["owner"], f"{label}.owner")
|
|
308
|
+
_enum(item["kind"], {"ui", "service", "data", "integration", "agent", "library", "platform"}, f"{label}.kind")
|
|
309
|
+
for feature_index, feature_id in enumerate(_string_list(item["featureIds"], f"{label}.featureIds", unique=True)):
|
|
310
|
+
_architecture_id(feature_id, f"{label}.featureIds[{feature_index}]")
|
|
311
|
+
refs.append((
|
|
312
|
+
{"specId": spec_id, "kind": "feature", "id": feature_id},
|
|
313
|
+
f"{label}.featureIds[{feature_index}]",
|
|
314
|
+
"feature",
|
|
315
|
+
))
|
|
316
|
+
if "description" in item and not isinstance(item["description"], str):
|
|
317
|
+
_fail(f"{label}.description must be a string")
|
|
318
|
+
if "ownedFiles" in item:
|
|
319
|
+
_owned_files(item["ownedFiles"], f"{label}.ownedFiles")
|
|
320
|
+
if "provenance" in item:
|
|
321
|
+
_enum(item["provenance"], PROVENANCE, f"{label}.provenance")
|
|
322
|
+
_unique(component_ids, "architecture component ids")
|
|
323
|
+
|
|
324
|
+
contract_ids: list[str] = []
|
|
325
|
+
for index, raw in enumerate(architecture["contracts"]):
|
|
326
|
+
label = f"architecture.contracts[{index}]"
|
|
327
|
+
item = _require_object(raw, label)
|
|
328
|
+
_strict_keys(item, {"id", "name", "provider", "consumers", "ports", "transport", "failureModes", "securityNotes"}, {"provenance", "ownedFiles"}, label)
|
|
329
|
+
contract_ids.append(_architecture_id(item["id"], f"{label}.id"))
|
|
330
|
+
_nonempty(item["name"], f"{label}.name")
|
|
331
|
+
refs.append((_validate_qualified_ref(item["provider"], f"{label}.provider"), f"{label}.provider", "component"))
|
|
332
|
+
consumers = _list(item["consumers"], f"{label}.consumers")
|
|
333
|
+
if not consumers:
|
|
334
|
+
_fail(f"{label}.consumers must not be empty")
|
|
335
|
+
consumer_ids: list[str] = []
|
|
336
|
+
for ref_index, ref in enumerate(consumers):
|
|
337
|
+
parsed_ref = _validate_qualified_ref(ref, f"{label}.consumers[{ref_index}]")
|
|
338
|
+
refs.append((parsed_ref, f"{label}.consumers[{ref_index}]", "component"))
|
|
339
|
+
consumer_ids.append(f"{parsed_ref['specId']}:{parsed_ref['kind']}:{parsed_ref['id']}")
|
|
340
|
+
_unique(consumer_ids, f"{label}.consumers")
|
|
341
|
+
ports = _list(item["ports"], f"{label}.ports")
|
|
342
|
+
if not ports:
|
|
343
|
+
_fail(f"{label}.ports must not be empty")
|
|
344
|
+
port_ids: list[str] = []
|
|
345
|
+
for port_index, raw_port in enumerate(ports):
|
|
346
|
+
port_label = f"{label}.ports[{port_index}]"
|
|
347
|
+
port = _require_object(raw_port, port_label)
|
|
348
|
+
_strict_keys(port, {"id", "name", "type", "direction"}, {"required"}, port_label)
|
|
349
|
+
port_ids.append(_architecture_id(port["id"], f"{port_label}.id"))
|
|
350
|
+
_nonempty(port["name"], f"{port_label}.name")
|
|
351
|
+
_nonempty(port["type"], f"{port_label}.type")
|
|
352
|
+
_enum(port["direction"], {"input", "output", "bidirectional"}, f"{port_label}.direction")
|
|
353
|
+
if "required" in port and not isinstance(port["required"], bool):
|
|
354
|
+
_fail(f"{port_label}.required must be boolean")
|
|
355
|
+
_unique(port_ids, f"{label} port ids")
|
|
356
|
+
_nonempty(item["transport"], f"{label}.transport")
|
|
357
|
+
_string_list(item["failureModes"], f"{label}.failureModes", minimum=1)
|
|
358
|
+
_string_list(item["securityNotes"], f"{label}.securityNotes", minimum=1)
|
|
359
|
+
if "ownedFiles" in item:
|
|
360
|
+
_owned_files(item["ownedFiles"], f"{label}.ownedFiles")
|
|
361
|
+
if "provenance" in item:
|
|
362
|
+
_enum(item["provenance"], PROVENANCE, f"{label}.provenance")
|
|
363
|
+
_unique(contract_ids, "architecture contract ids")
|
|
364
|
+
|
|
365
|
+
relationship_ids: list[str] = []
|
|
366
|
+
for index, raw in enumerate(architecture["relationships"]):
|
|
367
|
+
label = f"architecture.relationships[{index}]"
|
|
368
|
+
item = _require_object(raw, label)
|
|
369
|
+
_strict_keys(item, {"id", "from", "to", "direction", "criticality", "optional", "rationale"}, {"contractRef", "provenance"}, label)
|
|
370
|
+
relationship_ids.append(_architecture_id(item["id"], f"{label}.id"))
|
|
371
|
+
refs.append((_validate_qualified_ref(item["from"], f"{label}.from"), f"{label}.from", "component"))
|
|
372
|
+
refs.append((_validate_qualified_ref(item["to"], f"{label}.to"), f"{label}.to", "component"))
|
|
373
|
+
if "contractRef" in item:
|
|
374
|
+
refs.append((_validate_qualified_ref(item["contractRef"], f"{label}.contractRef"), f"{label}.contractRef", "contract"))
|
|
375
|
+
_enum(item["direction"], {"unidirectional", "bidirectional", "event"}, f"{label}.direction")
|
|
376
|
+
_enum(item["criticality"], {"hard", "soft", "informational"}, f"{label}.criticality")
|
|
377
|
+
if not isinstance(item["optional"], bool):
|
|
378
|
+
_fail(f"{label}.optional must be boolean")
|
|
379
|
+
_nonempty(item["rationale"], f"{label}.rationale")
|
|
380
|
+
if "provenance" in item:
|
|
381
|
+
_enum(item["provenance"], PROVENANCE, f"{label}.provenance")
|
|
382
|
+
_unique(relationship_ids, "architecture relationship ids")
|
|
383
|
+
|
|
384
|
+
flow_ids: list[str] = []
|
|
385
|
+
for index, raw in enumerate(architecture["flows"]):
|
|
386
|
+
label = f"architecture.flows[{index}]"
|
|
387
|
+
item = _require_object(raw, label)
|
|
388
|
+
_strict_keys(item, {"id", "name", "trigger", "exchanges"}, {"provenance"}, label)
|
|
389
|
+
flow_ids.append(_architecture_id(item["id"], f"{label}.id"))
|
|
390
|
+
_nonempty(item["name"], f"{label}.name")
|
|
391
|
+
_nonempty(item["trigger"], f"{label}.trigger")
|
|
392
|
+
exchanges = _list(item["exchanges"], f"{label}.exchanges")
|
|
393
|
+
if not exchanges:
|
|
394
|
+
_fail(f"{label}.exchanges must not be empty")
|
|
395
|
+
exchange_ids: list[str] = []
|
|
396
|
+
orders: list[int] = []
|
|
397
|
+
for exchange_index, raw_exchange in enumerate(exchanges):
|
|
398
|
+
exchange_label = f"{label}.exchanges[{exchange_index}]"
|
|
399
|
+
exchange = _require_object(raw_exchange, exchange_label)
|
|
400
|
+
_strict_keys(
|
|
401
|
+
exchange,
|
|
402
|
+
{"id", "order", "from", "to", "contractRef", "inputRefs", "outputRefs", "failurePaths"},
|
|
403
|
+
{"stateRefs"},
|
|
404
|
+
exchange_label,
|
|
405
|
+
)
|
|
406
|
+
exchange_ids.append(_architecture_id(exchange["id"], f"{exchange_label}.id"))
|
|
407
|
+
if not isinstance(exchange["order"], int) or isinstance(exchange["order"], bool) or exchange["order"] < 1:
|
|
408
|
+
_fail(f"{exchange_label}.order must be a positive integer")
|
|
409
|
+
orders.append(exchange["order"])
|
|
410
|
+
for key, expected_kind in (("from", "component"), ("to", "component"), ("contractRef", "contract")):
|
|
411
|
+
refs.append((
|
|
412
|
+
_validate_qualified_ref(exchange[key], f"{exchange_label}.{key}"),
|
|
413
|
+
f"{exchange_label}.{key}",
|
|
414
|
+
expected_kind,
|
|
415
|
+
))
|
|
416
|
+
for key in ("inputRefs", "outputRefs"):
|
|
417
|
+
for ref_index, ref in enumerate(_list(exchange[key], f"{exchange_label}.{key}")):
|
|
418
|
+
ref_label = f"{exchange_label}.{key}[{ref_index}]"
|
|
419
|
+
refs.append((_validate_qualified_ref(ref, ref_label), ref_label, None))
|
|
420
|
+
if "stateRefs" in exchange:
|
|
421
|
+
for state_ref_index, raw_state_ref in enumerate(
|
|
422
|
+
_list(exchange["stateRefs"], f"{exchange_label}.stateRefs")
|
|
423
|
+
):
|
|
424
|
+
state_ref_label = f"{exchange_label}.stateRefs[{state_ref_index}]"
|
|
425
|
+
state_ref = _require_object(raw_state_ref, state_ref_label)
|
|
426
|
+
_strict_keys(state_ref, {"screenId", "state"}, set(), state_ref_label)
|
|
427
|
+
screen_id = _architecture_id(state_ref["screenId"], f"{state_ref_label}.screenId")
|
|
428
|
+
state = _nonempty(state_ref["state"], f"{state_ref_label}.state")
|
|
429
|
+
states = screen_states.get(screen_id)
|
|
430
|
+
if states is None:
|
|
431
|
+
_fail(f"{state_ref_label}.screenId references unknown screen {screen_id}")
|
|
432
|
+
if state not in states:
|
|
433
|
+
_fail(f"{state_ref_label}.state references unknown state {state} on screen {screen_id}")
|
|
434
|
+
_string_list(exchange["failurePaths"], f"{exchange_label}.failurePaths", minimum=1)
|
|
435
|
+
_unique(exchange_ids, f"{label} exchange ids")
|
|
436
|
+
if len(orders) != len(set(orders)):
|
|
437
|
+
_fail(f"{label} exchange orders must be unique")
|
|
438
|
+
if "provenance" in item:
|
|
439
|
+
_enum(item["provenance"], PROVENANCE, f"{label}.provenance")
|
|
440
|
+
_unique(flow_ids, "architecture flow ids")
|
|
441
|
+
|
|
442
|
+
dependency_ids: list[str] = []
|
|
443
|
+
dependency_specs: list[str] = []
|
|
444
|
+
for index, raw in enumerate(architecture["specDependencies"]):
|
|
445
|
+
label = f"architecture.specDependencies[{index}]"
|
|
446
|
+
item = _require_object(raw, label)
|
|
447
|
+
_strict_keys(item, {"id", "specId", "location", "schemaVersion", "revision", "digest", "relationship"}, {"optional"}, label)
|
|
448
|
+
dependency_ids.append(_architecture_id(item["id"], f"{label}.id"))
|
|
449
|
+
dependency_specs.append(_architecture_id(item["specId"], f"{label}.specId"))
|
|
450
|
+
location = _require_object(item["location"], f"{label}.location")
|
|
451
|
+
if location.get("kind") == "local":
|
|
452
|
+
_strict_keys(location, {"kind", "path"}, set(), f"{label}.location")
|
|
453
|
+
_safe_repo_path(location["path"], f"{label}.location.path")
|
|
454
|
+
elif location.get("kind") == "uri":
|
|
455
|
+
_strict_keys(location, {"kind", "uri"}, set(), f"{label}.location")
|
|
456
|
+
uri = _nonempty(location["uri"], f"{label}.location.uri")
|
|
457
|
+
_safe_text(uri, f"{label}.location.uri")
|
|
458
|
+
if not URL_RE.fullmatch(uri):
|
|
459
|
+
_fail(f"{label}.location.uri must be an absolute URI")
|
|
460
|
+
else:
|
|
461
|
+
_fail(f"{label}.location.kind is unsupported")
|
|
462
|
+
if not isinstance(item["schemaVersion"], int) or isinstance(item["schemaVersion"], bool) or item["schemaVersion"] < 1:
|
|
463
|
+
_fail(f"{label}.schemaVersion must be a positive integer")
|
|
464
|
+
_nonempty(item["revision"], f"{label}.revision")
|
|
465
|
+
_digest(item["digest"], f"{label}.digest")
|
|
466
|
+
_enum(item["relationship"], {"uses", "extends", "implements", "companion"}, f"{label}.relationship")
|
|
467
|
+
if "optional" in item and not isinstance(item["optional"], bool):
|
|
468
|
+
_fail(f"{label}.optional must be boolean")
|
|
469
|
+
_unique(dependency_ids, "architecture dependency ids")
|
|
470
|
+
_unique(dependency_specs, "architecture dependency specIds")
|
|
471
|
+
known_local = set(local_refs)
|
|
472
|
+
known_local.update(f"{spec_id}:component:{item}" for item in component_ids)
|
|
473
|
+
known_local.update(f"{spec_id}:contract:{item}" for item in contract_ids)
|
|
474
|
+
known_local.update(f"{spec_id}:flow:{item}" for item in flow_ids)
|
|
475
|
+
dependency_spec_set = set(dependency_specs)
|
|
476
|
+
for ref, label, expected_kind in refs:
|
|
477
|
+
if expected_kind and ref["kind"] != expected_kind:
|
|
478
|
+
_fail(f"{label}.kind must be {expected_kind}")
|
|
479
|
+
identity = f"{ref['specId']}:{ref['kind']}:{ref['id']}"
|
|
480
|
+
if ref["specId"] == spec_id and identity not in known_local:
|
|
481
|
+
_fail(f"{label} references unknown local {ref['kind']} {ref['id']}")
|
|
482
|
+
if ref["specId"] != spec_id and ref["specId"] not in dependency_spec_set:
|
|
483
|
+
_fail(f"{label} has no declared Spec dependency for {ref['specId']}")
|
|
484
|
+
return set(component_ids), set(contract_ids), set(dependency_specs)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _request_projection(request: dict[str, Any]) -> dict[str, Any]:
|
|
488
|
+
return {key: value for key, value in request.items() if key != "requestDigest"}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _validate_platform_surfaces(raw: Any) -> list[dict[str, Any]]:
|
|
492
|
+
surfaces = _list(raw, "platformSurfaces")
|
|
493
|
+
if not surfaces:
|
|
494
|
+
_fail("platformSurfaces must not be empty")
|
|
495
|
+
ids: list[str] = []
|
|
496
|
+
for index, entry in enumerate(surfaces):
|
|
497
|
+
item = _require_object(entry, f"platformSurfaces[{index}]")
|
|
498
|
+
_strict_keys(item, {"id", "platform", "role", "name", "interactionModes", "featureIds"}, {"provenance"}, f"platformSurfaces[{index}]")
|
|
499
|
+
ids.append(_architecture_id(item["id"], f"platformSurfaces[{index}].id"))
|
|
500
|
+
_nonempty(item["name"], f"platformSurfaces[{index}].name")
|
|
501
|
+
if not isinstance(item["platform"], str) or not isinstance(item["role"], str) or item["platform"] not in PLATFORMS or item["role"] not in SURFACE_ROLES:
|
|
502
|
+
_fail(f"platformSurfaces[{index}] has an unsupported platform or role")
|
|
503
|
+
for key in ("interactionModes", "featureIds"):
|
|
504
|
+
values = [_nonempty(value, f"platformSurfaces[{index}].{key}") for value in _list(item[key], f"platformSurfaces[{index}].{key}")]
|
|
505
|
+
_unique(values, f"platformSurfaces[{index}].{key}")
|
|
506
|
+
if key == "featureIds":
|
|
507
|
+
for value_index, entry in enumerate(values):
|
|
508
|
+
_architecture_id(entry, f"platformSurfaces[{index}].featureIds[{value_index}]")
|
|
509
|
+
if "provenance" in item and (not isinstance(item["provenance"], str) or item["provenance"] not in PROVENANCE):
|
|
510
|
+
_fail(f"platformSurfaces[{index}].provenance is unsupported")
|
|
511
|
+
_unique(ids, "platform surface ids")
|
|
512
|
+
return surfaces
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _validate_object_ids(raw: Any, label: str, id_key: str = "id") -> set[str]:
|
|
516
|
+
ids: list[str] = []
|
|
517
|
+
for index, entry in enumerate(_list(raw, label)):
|
|
518
|
+
item = _require_object(entry, f"{label}[{index}]")
|
|
519
|
+
ids.append(_nonempty(item.get(id_key), f"{label}[{index}].{id_key}"))
|
|
520
|
+
_unique(ids, f"{label} ids")
|
|
521
|
+
return set(ids)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _canonical_local_refs(canonical: dict[str, Any], spec_id: str) -> set[str]:
|
|
525
|
+
refs: set[str] = set()
|
|
526
|
+
for field, kind in (("needs", "requirement"), ("features", "feature"), ("screens", "screen")):
|
|
527
|
+
if field not in canonical:
|
|
528
|
+
continue
|
|
529
|
+
for index, raw in enumerate(_list(canonical[field], f"canonical Spec.{field}")):
|
|
530
|
+
item = _require_object(raw, f"canonical Spec.{field}[{index}]")
|
|
531
|
+
item_id = _architecture_id(item.get("id"), f"canonical Spec.{field}[{index}].id")
|
|
532
|
+
refs.add(f"{spec_id}:{kind}:{item_id}")
|
|
533
|
+
if field == "screens":
|
|
534
|
+
for element_index, raw_element in enumerate(_list(item.get("elements", []), f"canonical Spec.{field}[{index}].elements")):
|
|
535
|
+
element = _require_object(raw_element, f"canonical Spec.{field}[{index}].elements[{element_index}]")
|
|
536
|
+
element_id = _architecture_id(element.get("id"), f"canonical Spec.{field}[{index}].elements[{element_index}].id")
|
|
537
|
+
refs.add(f"{spec_id}:element:{element_id}")
|
|
538
|
+
return refs
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _canonical_screen_states(canonical: dict[str, Any]) -> dict[str, set[str]]:
|
|
542
|
+
screen_states: dict[str, set[str]] = {}
|
|
543
|
+
for index, raw in enumerate(_list(canonical.get("screens", []), "canonical Spec.screens")):
|
|
544
|
+
label = f"canonical Spec.screens[{index}]"
|
|
545
|
+
screen = _require_object(raw, label)
|
|
546
|
+
screen_id = _architecture_id(screen.get("id"), f"{label}.id")
|
|
547
|
+
states = set(_string_list(screen.get("states", []), f"{label}.states"))
|
|
548
|
+
screen_states[screen_id] = states
|
|
549
|
+
return screen_states
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def validate_request(request: Any, canonical_spec: Any) -> dict[str, Any]:
|
|
553
|
+
value = _require_object(request, "build request")
|
|
554
|
+
_strict_keys(value, {
|
|
555
|
+
"contract", "runId", "specId", "specDigest", "taskDigest", "platformSurfaces",
|
|
556
|
+
"architecture", "tasks", "acceptanceCriteria", "manualActions", "returnVersions",
|
|
557
|
+
"requestDigest", "createdAt",
|
|
558
|
+
}, set(), "build request")
|
|
559
|
+
if value["contract"] != BUILD_REQUEST_CONTRACT:
|
|
560
|
+
_fail(f"unsupported build request contract: {value['contract']!r}")
|
|
561
|
+
_nonempty(value["runId"], "runId")
|
|
562
|
+
spec_id = _nonempty(value["specId"], "specId")
|
|
563
|
+
_digest(value["specDigest"], "specDigest")
|
|
564
|
+
_digest(value["taskDigest"], "taskDigest")
|
|
565
|
+
_digest(value["requestDigest"], "requestDigest")
|
|
566
|
+
_timestamp(value["createdAt"], "createdAt")
|
|
567
|
+
canonical = _require_object(canonical_spec, "canonical Spec")
|
|
568
|
+
if _nonempty(canonical.get("id"), "canonical Spec id") != spec_id:
|
|
569
|
+
_fail("specId does not match the canonical Spec id")
|
|
570
|
+
surfaces = _validate_platform_surfaces(value["platformSurfaces"])
|
|
571
|
+
architecture = _require_object(value["architecture"], "architecture")
|
|
572
|
+
_strict_keys(architecture, {"components", "contracts", "relationships", "flows", "specDependencies"}, set(), "architecture")
|
|
573
|
+
for key in ("components", "contracts", "relationships", "flows", "specDependencies"):
|
|
574
|
+
_list(architecture[key], f"architecture.{key}")
|
|
575
|
+
components, contracts, dependency_specs = _validate_architecture(
|
|
576
|
+
architecture,
|
|
577
|
+
spec_id=spec_id,
|
|
578
|
+
local_refs=_canonical_local_refs(canonical, spec_id),
|
|
579
|
+
screen_states=_canonical_screen_states(canonical),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if "platformSurfaces" not in canonical or normalize_json(surfaces) != normalize_json(canonical["platformSurfaces"]):
|
|
583
|
+
_fail("platformSurfaces do not match the canonical Spec")
|
|
584
|
+
if "architecture" not in canonical or normalize_json(architecture) != normalize_json(canonical["architecture"]):
|
|
585
|
+
_fail("architecture does not match the canonical Spec")
|
|
586
|
+
|
|
587
|
+
tasks = _list(value["tasks"], "tasks")
|
|
588
|
+
if not tasks:
|
|
589
|
+
_fail("tasks must not be empty")
|
|
590
|
+
task_ids: list[str] = []
|
|
591
|
+
acceptance_ids_list: list[str] = []
|
|
592
|
+
for index, raw in enumerate(_list(value["acceptanceCriteria"], "acceptanceCriteria")):
|
|
593
|
+
criterion = _require_object(raw, f"acceptanceCriteria[{index}]")
|
|
594
|
+
_strict_keys(criterion, {"id", "statement"}, {"testHint"}, f"acceptanceCriteria[{index}]")
|
|
595
|
+
acceptance_ids_list.append(_nonempty(criterion["id"], f"acceptanceCriteria[{index}].id"))
|
|
596
|
+
_safe_text(_nonempty(criterion["statement"], f"acceptanceCriteria[{index}].statement"), f"acceptanceCriteria[{index}].statement")
|
|
597
|
+
if "testHint" in criterion:
|
|
598
|
+
if not isinstance(criterion["testHint"], str):
|
|
599
|
+
_fail(f"acceptanceCriteria[{index}].testHint must be a string")
|
|
600
|
+
_safe_text(criterion["testHint"], f"acceptanceCriteria[{index}].testHint")
|
|
601
|
+
_unique(acceptance_ids_list, "acceptance criterion ids")
|
|
602
|
+
acceptance_ids = set(acceptance_ids_list)
|
|
603
|
+
|
|
604
|
+
manual_ids: list[str] = []
|
|
605
|
+
for index, raw in enumerate(_list(value["manualActions"], "manualActions")):
|
|
606
|
+
action = _require_object(raw, f"manualActions[{index}]")
|
|
607
|
+
_strict_keys(action, {"id", "location", "action", "requiredValueName", "destination", "verification"}, set(), f"manualActions[{index}]")
|
|
608
|
+
manual_id = _nonempty(action["id"], f"manualActions[{index}].id")
|
|
609
|
+
_safe_manual_text(manual_id, f"manualActions[{index}].id")
|
|
610
|
+
manual_ids.append(manual_id)
|
|
611
|
+
for key in ("location", "action", "requiredValueName", "destination", "verification"):
|
|
612
|
+
text = _nonempty(action[key], f"manualActions[{index}].{key}")
|
|
613
|
+
_safe_manual_text(text, f"manualActions[{index}].{key}")
|
|
614
|
+
_unique(manual_ids, "manual action ids")
|
|
615
|
+
for index, raw in enumerate(tasks):
|
|
616
|
+
task = _require_object(raw, f"tasks[{index}]")
|
|
617
|
+
_strict_keys(
|
|
618
|
+
task,
|
|
619
|
+
{"id", "title", "componentRefs", "contractRefs", "requirementIds", "dependsOn", "acceptanceCriterionIds"},
|
|
620
|
+
{"ownedFiles"},
|
|
621
|
+
f"tasks[{index}]",
|
|
622
|
+
)
|
|
623
|
+
task_ids.append(_nonempty(task["id"], f"tasks[{index}].id"))
|
|
624
|
+
_nonempty(task["title"], f"tasks[{index}].title")
|
|
625
|
+
if "ownedFiles" in task:
|
|
626
|
+
_owned_files(task["ownedFiles"], f"tasks[{index}].ownedFiles")
|
|
627
|
+
for list_key in ("requirementIds", "dependsOn", "acceptanceCriterionIds"):
|
|
628
|
+
entries = [_nonempty(item, f"tasks[{index}].{list_key}") for item in _list(task[list_key], f"tasks[{index}].{list_key}")]
|
|
629
|
+
_unique(entries, f"tasks[{index}].{list_key}")
|
|
630
|
+
for ref_key, expected_kind, local_ids in (("componentRefs", "component", components), ("contractRefs", "contract", contracts)):
|
|
631
|
+
identities: list[str] = []
|
|
632
|
+
for ref_index, raw_ref in enumerate(_list(task[ref_key], f"tasks[{index}].{ref_key}")):
|
|
633
|
+
ref = _require_object(raw_ref, f"tasks[{index}].{ref_key}[{ref_index}]")
|
|
634
|
+
_strict_keys(ref, {"specId", "kind", "id"}, set(), f"tasks[{index}].{ref_key}[{ref_index}]")
|
|
635
|
+
ref_spec = _nonempty(ref["specId"], "qualified ref specId")
|
|
636
|
+
ref_id = _nonempty(ref["id"], "qualified ref id")
|
|
637
|
+
if ref["kind"] != expected_kind:
|
|
638
|
+
_fail(f"tasks[{index}].{ref_key}[{ref_index}] must be a {expected_kind} reference")
|
|
639
|
+
if ref_spec == spec_id and ref_id not in local_ids:
|
|
640
|
+
_fail(f"tasks[{index}].{ref_key}[{ref_index}] references unknown local {expected_kind} {ref_id}")
|
|
641
|
+
if ref_spec != spec_id and ref_spec not in dependency_specs:
|
|
642
|
+
_fail(f"tasks[{index}].{ref_key}[{ref_index}] has no declared Spec dependency")
|
|
643
|
+
identities.append(f"{ref_spec}:{expected_kind}:{ref_id}")
|
|
644
|
+
_unique(identities, f"tasks[{index}].{ref_key}")
|
|
645
|
+
_unique(task_ids, "task ids")
|
|
646
|
+
task_id_set = set(task_ids)
|
|
647
|
+
requirement_ids = {item for task in tasks for item in task["requirementIds"]}
|
|
648
|
+
target_owners: dict[str, list[str]] = {}
|
|
649
|
+
for kind, targets in {
|
|
650
|
+
"task": task_id_set,
|
|
651
|
+
"component": components,
|
|
652
|
+
"contract": contracts,
|
|
653
|
+
"requirement": requirement_ids,
|
|
654
|
+
}.items():
|
|
655
|
+
for target in targets:
|
|
656
|
+
target_owners.setdefault(target, []).append(kind)
|
|
657
|
+
ambiguous_targets = {target: kinds for target, kinds in target_owners.items() if len(kinds) > 1}
|
|
658
|
+
if ambiguous_targets:
|
|
659
|
+
rendered = ", ".join(
|
|
660
|
+
f"{target} ({'/'.join(sorted(kinds))})"
|
|
661
|
+
for target, kinds in sorted(ambiguous_targets.items())
|
|
662
|
+
)
|
|
663
|
+
_fail(f"v1 target ids must be globally unique across mapping kinds: {rendered}")
|
|
664
|
+
task_positions = {task_id: index for index, task_id in enumerate(task_ids)}
|
|
665
|
+
for index, task in enumerate(tasks):
|
|
666
|
+
for dependency in task["dependsOn"]:
|
|
667
|
+
if dependency not in task_id_set:
|
|
668
|
+
_fail(f"tasks[{index}].dependsOn references unknown task {dependency}")
|
|
669
|
+
if task_positions[dependency] >= index:
|
|
670
|
+
_fail(f"tasks[{index}].dependsOn must reference an earlier task in the final order")
|
|
671
|
+
for criterion in task["acceptanceCriterionIds"]:
|
|
672
|
+
if criterion not in acceptance_ids:
|
|
673
|
+
_fail(f"tasks[{index}].acceptanceCriterionIds references unknown criterion {criterion}")
|
|
674
|
+
|
|
675
|
+
versions = _require_object(value["returnVersions"], "returnVersions")
|
|
676
|
+
_strict_keys(versions, {"implementationMap", "convergence"}, set(), "returnVersions")
|
|
677
|
+
implementation_versions = _list(versions["implementationMap"], "returnVersions.implementationMap")
|
|
678
|
+
convergence_versions = _list(versions["convergence"], "returnVersions.convergence")
|
|
679
|
+
if not implementation_versions or any(item != IMPLEMENTATION_MAP_CONTRACT for item in implementation_versions):
|
|
680
|
+
_fail("unsupported implementation-map return version")
|
|
681
|
+
if not convergence_versions or any(item != CONVERGENCE_CONTRACT for item in convergence_versions):
|
|
682
|
+
_fail("unsupported convergence return version")
|
|
683
|
+
if value["taskDigest"] != digest_normalized(tasks):
|
|
684
|
+
_fail("taskDigest does not match the final ordered task list")
|
|
685
|
+
if value["requestDigest"] != digest_normalized(_request_projection(value)):
|
|
686
|
+
_fail("requestDigest does not match its normalized self-digest projection")
|
|
687
|
+
if value["specDigest"] != digest_normalized(canonical_spec):
|
|
688
|
+
_fail("specDigest does not match the normalized canonical Spec")
|
|
689
|
+
return value
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _intended_targets(request: dict[str, Any]) -> dict[str, set[str]]:
|
|
693
|
+
return {
|
|
694
|
+
"task": {task["id"] for task in request["tasks"]},
|
|
695
|
+
"component": {item["id"] for item in request["architecture"]["components"]},
|
|
696
|
+
"contract": {item["id"] for item in request["architecture"]["contracts"]},
|
|
697
|
+
"requirement": {item for task in request["tasks"] for item in task["requirementIds"]},
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _resolve_repo_file(workdir: Path, raw: Any, label: str, *, allow_internal: bool = False) -> tuple[str, Path]:
|
|
702
|
+
if allow_internal:
|
|
703
|
+
text = _safe_repo_path(raw, label)
|
|
704
|
+
if not text.startswith(".build-loop/evidence/"):
|
|
705
|
+
_fail(f"{label} must be under .build-loop/evidence/")
|
|
706
|
+
else:
|
|
707
|
+
text = _safe_repo_path(raw, label)
|
|
708
|
+
root = workdir.resolve()
|
|
709
|
+
joined = root / text
|
|
710
|
+
cursor = root
|
|
711
|
+
for segment in text.split("/"):
|
|
712
|
+
cursor = cursor / segment
|
|
713
|
+
if cursor.is_symlink():
|
|
714
|
+
_fail(f"{label} must not traverse a symlink")
|
|
715
|
+
candidate = joined.resolve()
|
|
716
|
+
if candidate == root or root not in candidate.parents:
|
|
717
|
+
_fail(f"{label} escapes the repository")
|
|
718
|
+
if not candidate.is_file():
|
|
719
|
+
_fail(f"{label} must name an existing non-symlink file")
|
|
720
|
+
return text, candidate
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _verify_commit(workdir: Path, commit: str, label: str) -> str:
|
|
724
|
+
if not COMMIT_RE.fullmatch(commit):
|
|
725
|
+
_fail(f"{label} must be a 7-64 character hexadecimal commit")
|
|
726
|
+
result = subprocess.run(["git", "cat-file", "-e", f"{commit}^{{commit}}"], cwd=workdir, capture_output=True, text=True, check=False)
|
|
727
|
+
if result.returncode != 0:
|
|
728
|
+
_fail(f"{label} does not resolve to a repository commit")
|
|
729
|
+
ancestor = subprocess.run(["git", "merge-base", "--is-ancestor", commit, "HEAD"], cwd=workdir, capture_output=True, text=True, check=False)
|
|
730
|
+
if ancestor.returncode != 0:
|
|
731
|
+
_fail(f"{label} is not reachable from the current repository HEAD")
|
|
732
|
+
resolved = subprocess.run(["git", "rev-parse", f"{commit}^{{commit}}"], cwd=workdir, capture_output=True, text=True, check=False)
|
|
733
|
+
if resolved.returncode != 0:
|
|
734
|
+
_fail(f"{label} cannot be canonicalized")
|
|
735
|
+
return resolved.stdout.strip()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _verify_file_at_commit(workdir: Path, file_ref: str, commits: list[str], label: str) -> None:
|
|
739
|
+
current = (workdir / file_ref).read_bytes()
|
|
740
|
+
for commit in commits:
|
|
741
|
+
result = subprocess.run(["git", "show", f"{commit}:{file_ref}"], cwd=workdir, capture_output=True, check=False)
|
|
742
|
+
if result.returncode == 0 and result.stdout == current:
|
|
743
|
+
return
|
|
744
|
+
_fail(f"{label} is not byte-identical in any cited commit")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def build_implementation_map(
|
|
748
|
+
request: dict[str, Any],
|
|
749
|
+
draft_input: Any,
|
|
750
|
+
*,
|
|
751
|
+
workdir: Path,
|
|
752
|
+
producer_version: str,
|
|
753
|
+
producer_commit: str | None,
|
|
754
|
+
created_at: str,
|
|
755
|
+
verified_evidence_ids: set[str] | None = None,
|
|
756
|
+
) -> dict[str, Any]:
|
|
757
|
+
draft = _require_object(draft_input, "evidence draft")
|
|
758
|
+
_strict_keys(draft, {"mappings", "evidence", "deviations"}, set(), "evidence draft")
|
|
759
|
+
created = _timestamp(created_at, "createdAt")
|
|
760
|
+
if created < _timestamp(request["createdAt"], "request.createdAt"):
|
|
761
|
+
_fail("implementation map cannot predate the build request")
|
|
762
|
+
_nonempty(producer_version, "producer version")
|
|
763
|
+
trusted_evidence = verified_evidence_ids or set()
|
|
764
|
+
if producer_commit:
|
|
765
|
+
producer_root = Path(__file__).resolve().parent.parent
|
|
766
|
+
producer_commit = _verify_commit(producer_root, producer_commit, "producer commit")
|
|
767
|
+
_verify_file_at_commit(
|
|
768
|
+
producer_root,
|
|
769
|
+
"scripts/groundwork_exchange.py",
|
|
770
|
+
[producer_commit],
|
|
771
|
+
"producer adapter",
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
intended = _intended_targets(request)
|
|
775
|
+
owners: dict[str, list[str]] = {}
|
|
776
|
+
for kind, targets in intended.items():
|
|
777
|
+
for target in targets:
|
|
778
|
+
owners.setdefault(target, []).append(kind)
|
|
779
|
+
ambiguous = {target: kinds for target, kinds in owners.items() if len(kinds) > 1}
|
|
780
|
+
if ambiguous:
|
|
781
|
+
rendered = ", ".join(f"{target} ({'/'.join(sorted(kinds))})" for target, kinds in sorted(ambiguous.items()))
|
|
782
|
+
_fail(f"v1 target ids must be globally unique across mapping kinds: {rendered}")
|
|
783
|
+
|
|
784
|
+
evidence_output: list[dict[str, Any]] = []
|
|
785
|
+
evidence_by_id: dict[str, dict[str, Any]] = {}
|
|
786
|
+
for index, raw in enumerate(_list(draft["evidence"], "evidence")):
|
|
787
|
+
item = _require_object(raw, f"evidence[{index}]")
|
|
788
|
+
_strict_keys(item, {"id", "kind", "command", "outcome", "recordedAt"}, {"summary", "artifactPath"}, f"evidence[{index}]")
|
|
789
|
+
evidence_id = _nonempty(item["id"], f"evidence[{index}].id")
|
|
790
|
+
if evidence_id in evidence_by_id:
|
|
791
|
+
_fail(f"duplicate evidence id {evidence_id}")
|
|
792
|
+
_enum(item["kind"], EVIDENCE_KINDS, f"evidence[{index}].kind")
|
|
793
|
+
_enum(item["outcome"], EVIDENCE_OUTCOMES, f"evidence[{index}].outcome")
|
|
794
|
+
recorded = _timestamp(item["recordedAt"], f"evidence[{index}].recordedAt")
|
|
795
|
+
if recorded < _timestamp(request["createdAt"], "request.createdAt") or recorded > created:
|
|
796
|
+
_fail(f"evidence[{index}].recordedAt falls outside the request/map window")
|
|
797
|
+
command = _nonempty(item["command"], f"evidence[{index}].command")
|
|
798
|
+
_safe_manual_text(command, f"evidence[{index}].command")
|
|
799
|
+
output = {key: item[key] for key in ("id", "kind", "command", "outcome")}
|
|
800
|
+
if "summary" in item:
|
|
801
|
+
if not isinstance(item["summary"], str):
|
|
802
|
+
_fail(f"evidence[{index}].summary must be a string")
|
|
803
|
+
_safe_manual_text(item["summary"], f"evidence[{index}].summary")
|
|
804
|
+
output["summary"] = item["summary"]
|
|
805
|
+
if item["outcome"] == "passed" and "artifactPath" not in item:
|
|
806
|
+
_fail(f"evidence[{index}] passed evidence requires artifactPath")
|
|
807
|
+
if item["outcome"] == "passed" and evidence_id not in trusted_evidence:
|
|
808
|
+
_fail(f"evidence[{index}] passed outcome requires --verified-evidence-id from Review-B")
|
|
809
|
+
if "artifactPath" in item:
|
|
810
|
+
_, artifact = _resolve_repo_file(workdir, item["artifactPath"], f"evidence[{index}].artifactPath", allow_internal=True)
|
|
811
|
+
output["artifactDigest"] = digest_file(artifact)
|
|
812
|
+
output["recordedAt"] = item["recordedAt"]
|
|
813
|
+
evidence_output.append(output)
|
|
814
|
+
evidence_by_id[evidence_id] = output
|
|
815
|
+
missing_trusted = sorted(trusted_evidence - evidence_by_id.keys())
|
|
816
|
+
if missing_trusted:
|
|
817
|
+
_fail(f"verified evidence ids are missing from the draft: {', '.join(missing_trusted)}")
|
|
818
|
+
|
|
819
|
+
deviations_output: list[dict[str, Any]] = []
|
|
820
|
+
deviations_by_id: dict[str, dict[str, Any]] = {}
|
|
821
|
+
all_targets = set().union(*intended.values())
|
|
822
|
+
for index, raw in enumerate(_list(draft["deviations"], "deviations")):
|
|
823
|
+
item = _require_object(raw, f"deviations[{index}]")
|
|
824
|
+
_strict_keys(item, {"id", "targetId", "summary", "impact"}, set(), f"deviations[{index}]")
|
|
825
|
+
deviation_id = _nonempty(item["id"], f"deviations[{index}].id")
|
|
826
|
+
if deviation_id in deviations_by_id:
|
|
827
|
+
_fail(f"duplicate deviation id {deviation_id}")
|
|
828
|
+
target = _nonempty(item["targetId"], f"deviations[{index}].targetId")
|
|
829
|
+
if target not in all_targets:
|
|
830
|
+
_fail(f"deviations[{index}] targets unknown intent or has unsupported impact")
|
|
831
|
+
_enum(item["impact"], IMPACTS, f"deviations[{index}].impact")
|
|
832
|
+
_safe_manual_text(_nonempty(item["summary"], f"deviations[{index}].summary"), f"deviations[{index}].summary")
|
|
833
|
+
deviations_output.append(dict(item))
|
|
834
|
+
deviations_by_id[deviation_id] = item
|
|
835
|
+
|
|
836
|
+
mappings_output: list[dict[str, Any]] = []
|
|
837
|
+
mapping_ids: set[str] = set()
|
|
838
|
+
for index, raw in enumerate(_list(draft["mappings"], "mappings")):
|
|
839
|
+
item = _require_object(raw, f"mappings[{index}]")
|
|
840
|
+
_strict_keys(item, {"id", "kind", "targetId", "status", "fileRefs", "symbolRefs", "commitRefs", "testEvidenceIds", "runtimeEvidenceIds"}, {"deviationIds"}, f"mappings[{index}]")
|
|
841
|
+
mapping_id = _nonempty(item["id"], f"mappings[{index}].id")
|
|
842
|
+
if mapping_id in mapping_ids:
|
|
843
|
+
_fail(f"duplicate mapping id {mapping_id}")
|
|
844
|
+
mapping_ids.add(mapping_id)
|
|
845
|
+
kind = item["kind"]
|
|
846
|
+
target = _nonempty(item["targetId"], f"mappings[{index}].targetId")
|
|
847
|
+
status = item["status"]
|
|
848
|
+
kind = _enum(kind, KINDS, f"mappings[{index}].kind")
|
|
849
|
+
status = _enum(status, STATUSES, f"mappings[{index}].status")
|
|
850
|
+
if target not in intended[kind]:
|
|
851
|
+
_fail(f"mappings[{index}] has unsupported kind/status or unknown target")
|
|
852
|
+
file_refs = [_safe_repo_path(value, f"mappings[{index}].fileRefs") for value in _list(item["fileRefs"], f"mappings[{index}].fileRefs")]
|
|
853
|
+
_unique(file_refs, f"mappings[{index}].fileRefs")
|
|
854
|
+
for file_ref in file_refs:
|
|
855
|
+
_resolve_repo_file(workdir, file_ref, f"mappings[{index}].fileRefs")
|
|
856
|
+
symbols = [_nonempty(value, f"mappings[{index}].symbolRefs") for value in _list(item["symbolRefs"], f"mappings[{index}].symbolRefs")]
|
|
857
|
+
for symbol_index, symbol in enumerate(symbols):
|
|
858
|
+
_safe_manual_text(symbol, f"mappings[{index}].symbolRefs[{symbol_index}]")
|
|
859
|
+
commits = [_nonempty(value, f"mappings[{index}].commitRefs") for value in _list(item["commitRefs"], f"mappings[{index}].commitRefs")]
|
|
860
|
+
tests = [_nonempty(value, f"mappings[{index}].testEvidenceIds") for value in _list(item["testEvidenceIds"], f"mappings[{index}].testEvidenceIds")]
|
|
861
|
+
runtimes = [_nonempty(value, f"mappings[{index}].runtimeEvidenceIds") for value in _list(item["runtimeEvidenceIds"], f"mappings[{index}].runtimeEvidenceIds")]
|
|
862
|
+
deviations = [_nonempty(value, f"mappings[{index}].deviationIds") for value in _list(item.get("deviationIds", []), f"mappings[{index}].deviationIds")]
|
|
863
|
+
for values, label in ((symbols, "symbolRefs"), (commits, "commitRefs"), (tests, "testEvidenceIds"), (runtimes, "runtimeEvidenceIds"), (deviations, "deviationIds")):
|
|
864
|
+
_unique(values, f"mappings[{index}].{label}")
|
|
865
|
+
commits = [_verify_commit(workdir, commit, f"mappings[{index}].commitRefs") for commit in commits]
|
|
866
|
+
referenced: list[dict[str, Any]] = []
|
|
867
|
+
for evidence_id in tests:
|
|
868
|
+
evidence = evidence_by_id.get(evidence_id)
|
|
869
|
+
if not evidence or evidence["kind"] != "test":
|
|
870
|
+
_fail(f"mappings[{index}] references missing or non-test evidence {evidence_id}")
|
|
871
|
+
referenced.append(evidence)
|
|
872
|
+
for evidence_id in runtimes:
|
|
873
|
+
evidence = evidence_by_id.get(evidence_id)
|
|
874
|
+
if not evidence or evidence["kind"] != "runtime":
|
|
875
|
+
_fail(f"mappings[{index}] references missing or non-runtime evidence {evidence_id}")
|
|
876
|
+
referenced.append(evidence)
|
|
877
|
+
for deviation_id in deviations:
|
|
878
|
+
deviation = deviations_by_id.get(deviation_id)
|
|
879
|
+
if not deviation or deviation["targetId"] != target:
|
|
880
|
+
_fail(f"mappings[{index}] references an invalid deviation {deviation_id}")
|
|
881
|
+
if status in {"implemented", "verified"} and (not file_refs or not commits):
|
|
882
|
+
_fail(f"mappings[{index}] {status} requires both repository fileRefs and commitRefs")
|
|
883
|
+
for file_ref in file_refs:
|
|
884
|
+
_verify_file_at_commit(workdir, file_ref, commits, f"mappings[{index}].fileRefs {file_ref}")
|
|
885
|
+
if status == "verified" and (
|
|
886
|
+
not referenced or any(evidence["outcome"] != "passed" for evidence in referenced)
|
|
887
|
+
):
|
|
888
|
+
_fail(f"mappings[{index}] verified requires all referenced test and runtime evidence to pass")
|
|
889
|
+
if status == "implemented" and any(evidence["outcome"] == "passed" for evidence in referenced):
|
|
890
|
+
_fail(f"mappings[{index}] implemented contradicts passing verification evidence")
|
|
891
|
+
if status == "not-started" and (file_refs or symbols or commits or referenced or deviations):
|
|
892
|
+
_fail(f"mappings[{index}] not-started cannot carry implementation evidence or deviations")
|
|
893
|
+
if status == "diverged" and not deviations:
|
|
894
|
+
_fail(f"mappings[{index}] diverged requires a declared deviation")
|
|
895
|
+
output = dict(item)
|
|
896
|
+
output["commitRefs"] = commits
|
|
897
|
+
output["deviationIds"] = deviations
|
|
898
|
+
mappings_output.append(output)
|
|
899
|
+
|
|
900
|
+
producer: dict[str, Any] = {"name": "build-loop", "version": producer_version}
|
|
901
|
+
if producer_commit:
|
|
902
|
+
producer["commit"] = producer_commit
|
|
903
|
+
result: dict[str, Any] = {
|
|
904
|
+
"contract": IMPLEMENTATION_MAP_CONTRACT,
|
|
905
|
+
"runId": request["runId"],
|
|
906
|
+
"buildRequestDigest": request["requestDigest"],
|
|
907
|
+
"specDigest": request["specDigest"],
|
|
908
|
+
"taskDigest": request["taskDigest"],
|
|
909
|
+
"producer": producer,
|
|
910
|
+
"mappings": mappings_output,
|
|
911
|
+
"evidence": evidence_output,
|
|
912
|
+
"deviations": deviations_output,
|
|
913
|
+
"implementationMapDigest": "sha256:" + "0" * 64,
|
|
914
|
+
"createdAt": created_at,
|
|
915
|
+
}
|
|
916
|
+
result["implementationMapDigest"] = digest_normalized({key: value for key, value in result.items() if key != "implementationMapDigest"})
|
|
917
|
+
return result
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _atomic_write_json(path: Path, value: Any) -> None:
|
|
921
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
922
|
+
payload = json.dumps(value, ensure_ascii=False, indent=2) + "\n"
|
|
923
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
924
|
+
try:
|
|
925
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
926
|
+
handle.write(payload)
|
|
927
|
+
handle.flush()
|
|
928
|
+
os.fsync(handle.fileno())
|
|
929
|
+
os.replace(tmp_name, path)
|
|
930
|
+
finally:
|
|
931
|
+
if os.path.exists(tmp_name):
|
|
932
|
+
os.unlink(tmp_name)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _resolve_producer_version(value: str) -> str:
|
|
936
|
+
if value != "auto":
|
|
937
|
+
return _nonempty(value, "producer version")
|
|
938
|
+
root = Path(__file__).resolve().parent.parent
|
|
939
|
+
for relative in (Path(".claude-plugin/plugin.json"), Path(".codex-plugin/plugin.json"), Path("package.json")):
|
|
940
|
+
candidate = root / relative
|
|
941
|
+
if candidate.is_file():
|
|
942
|
+
parsed = _require_object(_load_json(candidate), str(relative))
|
|
943
|
+
version = parsed.get("version")
|
|
944
|
+
if isinstance(version, str) and version:
|
|
945
|
+
return version
|
|
946
|
+
_fail("producer version auto-discovery found no versioned plugin manifest")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _parser() -> argparse.ArgumentParser:
|
|
950
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
951
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
952
|
+
validate = sub.add_parser("validate-request", help="validate a Groundwork BuildRequest v1")
|
|
953
|
+
validate.add_argument("--request", required=True)
|
|
954
|
+
validate.add_argument("--spec", required=True)
|
|
955
|
+
validate.add_argument("--output", help="atomically write the validated request summary")
|
|
956
|
+
emit = sub.add_parser("emit-map", help="verify evidence and emit ImplementationMap v1")
|
|
957
|
+
emit.add_argument("--request", required=True)
|
|
958
|
+
emit.add_argument("--spec", required=True)
|
|
959
|
+
emit.add_argument("--evidence", required=True)
|
|
960
|
+
emit.add_argument("--workdir", default=".")
|
|
961
|
+
emit.add_argument("--output", required=True)
|
|
962
|
+
emit.add_argument("--producer-version", required=True)
|
|
963
|
+
emit.add_argument("--producer-commit")
|
|
964
|
+
emit.add_argument("--created-at", required=True)
|
|
965
|
+
emit.add_argument(
|
|
966
|
+
"--verified-evidence-id",
|
|
967
|
+
action="append",
|
|
968
|
+
default=[],
|
|
969
|
+
help="evidence id whose command Review-B actually executed successfully; repeat per passed receipt",
|
|
970
|
+
)
|
|
971
|
+
return parser
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def main(argv: list[str] | None = None) -> int:
|
|
975
|
+
args = _parser().parse_args(argv)
|
|
976
|
+
try:
|
|
977
|
+
request = validate_request(_load_json(Path(args.request)), _load_json(Path(args.spec)))
|
|
978
|
+
if args.command == "validate-request":
|
|
979
|
+
result = {
|
|
980
|
+
"ok": True,
|
|
981
|
+
"contract": request["contract"],
|
|
982
|
+
"runId": request["runId"],
|
|
983
|
+
"requestDigest": request["requestDigest"],
|
|
984
|
+
"taskDigest": request["taskDigest"],
|
|
985
|
+
"taskIds": [task["id"] for task in request["tasks"]],
|
|
986
|
+
}
|
|
987
|
+
if args.output:
|
|
988
|
+
_atomic_write_json(Path(args.output), result)
|
|
989
|
+
else:
|
|
990
|
+
result = build_implementation_map(
|
|
991
|
+
request,
|
|
992
|
+
_load_json(Path(args.evidence)),
|
|
993
|
+
workdir=Path(args.workdir).resolve(),
|
|
994
|
+
producer_version=_resolve_producer_version(args.producer_version),
|
|
995
|
+
producer_commit=args.producer_commit,
|
|
996
|
+
created_at=args.created_at,
|
|
997
|
+
verified_evidence_ids=set(args.verified_evidence_id),
|
|
998
|
+
)
|
|
999
|
+
_atomic_write_json(Path(args.output), result)
|
|
1000
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
1001
|
+
return 0
|
|
1002
|
+
except ExchangeError as exc:
|
|
1003
|
+
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
1004
|
+
return 2
|
|
1005
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1006
|
+
message = getattr(exc, "strerror", None) or exc.__class__.__name__
|
|
1007
|
+
print(json.dumps({"ok": False, "error": f"operational failure: {message}"}, ensure_ascii=False), file=sys.stderr)
|
|
1008
|
+
return 2
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
if __name__ == "__main__":
|
|
1012
|
+
raise SystemExit(main())
|