diff --git a/.buildkite/README.md b/.buildkite/README.md index cd98988..5d26c15 100644 --- a/.buildkite/README.md +++ b/.buildkite/README.md @@ -3,6 +3,13 @@ Buildkite runs repository checks on pull requests and the default branch. Pull-request builds also run changed-code Fallow, package inspection, and exact-head product validation. +OS-neutral gates run on the included `macos-medium` M4 queue. Each Git-using +step fails closed unless Git is at least 2.50.1 and below 3.0.0 and the exact +bundle, ancestry, and commit-resolution capabilities used by Tabellio pass. +The gate records the actual Git version, architecture, and OS as hosted +evidence; CI does not build or download an architecture-specific Git artifact. +Linux queues are reserved for an explicit published-artifact compatibility +contract and are not used by this pipeline. The product-validation step exports the Git validation ref as a portable bundle instead of treating an internal `.git` ref as a workspace artifact. diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 1ba51ab..4a2979b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -3,22 +3,20 @@ env: NO_COLOR: "1" agents: - queue: "linux-small" + queue: "macos-medium" steps: - - label: ":git: Git 2.50.1" + - label: ":git: Supported Git" key: "git-toolchain" - command: "bash .buildkite/scripts/build-modern-git.sh" - if: build.pull_request.id != null || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch - timeout_in_minutes: 15 + command: "bash .buildkite/scripts/verify-git-toolchain.sh" + timeout_in_minutes: 2 artifact_paths: - - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" + - "tabellio-git-toolchain.json" - label: ":test_tube: Repository check" key: "repository-check" command: "bash .buildkite/scripts/tests.sh" depends_on: "git-toolchain" - if: build.pull_request.id != null || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "tabellio-pr-evidence.json" @@ -30,7 +28,6 @@ steps: key: "fallow" command: "bash .buildkite/scripts/fallow.sh" depends_on: "git-toolchain" - if: build.pull_request.id != null || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "fallow-audit.json" @@ -41,7 +38,6 @@ steps: - label: ":package: Package dry-run" key: "package" command: "bash .buildkite/scripts/package.sh" - if: build.pull_request.id != null || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "package-dry-run.json" @@ -53,7 +49,6 @@ steps: key: "product-validation" command: "bash .buildkite/scripts/product-validation.sh" depends_on: "git-toolchain" - if: build.pull_request.id != null || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" diff --git a/.buildkite/scripts/build-modern-git.sh b/.buildkite/scripts/build-modern-git.sh deleted file mode 100644 index 305896a..0000000 --- a/.buildkite/scripts/build-modern-git.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -version="2.50.1" -archive="git-${version}.tar.xz" -expected_sha256="7e3e6c36decbd8f1eedd14d42db6674be03671c2204864befa2a41756c5c8fc4" -workspace="$(mktemp -d)" -install_root="/tmp/intelip-tabellio-git-${version}" -artifact_dir=".artifacts/toolchain" -apt_lists="${workspace}/apt-lists" -apt_source_list="${workspace}/sources.list" -apt_source_parts="${workspace}/apt-sources.list.d" -apt_options=( - -o Dir::Etc::sourcelist="$apt_source_list" - -o Dir::Etc::sourceparts="$apt_source_parts" - -o Dir::State::lists="$apt_lists" - -o APT::Get::List-Cleanup="1" -) -packages=( - build-essential - ca-certificates - gettext - libcurl4-gnutls-dev - libexpat1-dev - libssl-dev - zlib1g-dev -) - -trap 'rm -rf "$workspace" "$install_root"' EXIT -rm -rf "$install_root" -mkdir -p "$install_root" - -missing_packages=() -for package in "${packages[@]}"; do - if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -qx "install ok installed"; then - missing_packages+=("$package") - fi -done - -if ((${#missing_packages[@]} > 0)); then - mkdir -p "${apt_lists}/partial" "$apt_source_parts" - chmod 755 "$workspace" "$apt_lists" "${apt_lists}/partial" "$apt_source_parts" - if [[ -f /etc/apt/sources.list ]]; then - awk ' - /^[[:space:]]*deb(-src)?[[:space:]]/ && - /(ubuntu\.com\/(ubuntu|ubuntu-ports)|debian\.org\/debian(-security|-ports)?)/ { print } - ' /etc/apt/sources.list > "$apt_source_list" - else - : > "$apt_source_list" - fi - for source_file in \ - /etc/apt/sources.list.d/ubuntu.sources \ - /etc/apt/sources.list.d/debian.sources \ - /etc/apt/sources.list.d/ubuntu.list \ - /etc/apt/sources.list.d/debian.list; do - [[ -f "$source_file" ]] || continue - cp -- "$source_file" "$apt_source_parts/" - done - sudo apt-get "${apt_options[@]}" update - sudo apt-get "${apt_options[@]}" install -y --no-install-recommends "${missing_packages[@]}" -fi - -curl --fail --location --silent --show-error \ - "https://www.kernel.org/pub/software/scm/git/${archive}" \ - --output "${workspace}/${archive}" - -printf '%s %s\n' "$expected_sha256" "${workspace}/${archive}" | sha256sum --check -tar -C "$workspace" -xf "${workspace}/${archive}" - -make -C "${workspace}/git-${version}" -j2 prefix="$install_root" all -make -C "${workspace}/git-${version}" prefix="$install_root" install - -mkdir -p "$artifact_dir" -tar -C "$install_root" -czf "${artifact_dir}/git-${version}-linux-amd64.tar.gz" . diff --git a/.buildkite/scripts/fallow.sh b/.buildkite/scripts/fallow.sh index 01a3e42..1c6b5fb 100755 --- a/.buildkite/scripts/fallow.sh +++ b/.buildkite/scripts/fallow.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -. .buildkite/scripts/use-modern-git.sh +. .buildkite/scripts/verify-git-toolchain.sh build_context="${TABELLIO_BUILD_CONTEXT:-provider}" if [[ "${BUILDKITE_PULL_REQUEST:-false}" == "false" && "$build_context" != "preflight" ]]; then diff --git a/.buildkite/scripts/product-validation.sh b/.buildkite/scripts/product-validation.sh index 368941a..f8e4cf1 100755 --- a/.buildkite/scripts/product-validation.sh +++ b/.buildkite/scripts/product-validation.sh @@ -10,11 +10,11 @@ if [[ "$pull_request" == "false" && "$pipeline_branch" == "$default_branch" ]]; default_branch_build=true fi if [[ "$pull_request" == "false" && "$build_context" != "preflight" && "$default_branch_build" != "true" ]]; then - printf '%s\n' "Buildkite product validation requires a pull request, default-branch build, or explicit preflight build." >&2 - exit 2 + printf '%s\n' '{"decision":"not_required","reason":"product validation runs on pull requests, the default branch, or explicit preflight builds."}' > tabellio-validation-result.json + exit 0 fi -. .buildkite/scripts/use-modern-git.sh +. .buildkite/scripts/verify-git-toolchain.sh candidate="${BUILDKITE_COMMIT:-HEAD}" base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-${TABELLIO_BASE_BRANCH:-main}}" diff --git a/.buildkite/scripts/tests.sh b/.buildkite/scripts/tests.sh index 3c2ad80..0bddf21 100644 --- a/.buildkite/scripts/tests.sh +++ b/.buildkite/scripts/tests.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -. .buildkite/scripts/use-modern-git.sh +. .buildkite/scripts/verify-git-toolchain.sh npm run check node scripts/write-tabellio-evidence-envelope.mjs --out tabellio-pr-evidence.json node scripts/check-tabellio-evidence-envelope.mjs --evidence tabellio-pr-evidence.json diff --git a/.buildkite/scripts/use-modern-git.sh b/.buildkite/scripts/use-modern-git.sh deleted file mode 100644 index 9f7d626..0000000 --- a/.buildkite/scripts/use-modern-git.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -version="2.50.1" -artifact=".artifacts/toolchain/git-${version}-linux-amd64.tar.gz" -install_root="/tmp/intelip-tabellio-git-${version}" - -buildkite-agent artifact download "$artifact" . -rm -rf "$install_root" -mkdir -p "$install_root" -tar -C "$install_root" -xzf "$artifact" - -export PATH="${install_root}/bin:${PATH}" -export GIT_EXEC_PATH="${install_root}/libexec/git-core" -export GIT_TEMPLATE_DIR="${install_root}/share/git-core/templates" -export GITPERLLIB="${install_root}/share/perl5" -git --version diff --git a/.buildkite/scripts/verify-git-toolchain.sh b/.buildkite/scripts/verify-git-toolchain.sh new file mode 100755 index 0000000..8c41e06 --- /dev/null +++ b/.buildkite/scripts/verify-git-toolchain.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +minimum_version="2.50.1" +maximum_version="3.0.0" + +version_number() { + local version="$1" + if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + printf 'Tabellio CI requires a semantic Git version; found %s.\n' "$version" >&2 + return 1 + fi + printf '%09d%09d%09d\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" +} + +check_supported_version() { + local version="$1" + local actual minimum maximum + actual="$(version_number "$version")" || return 1 + minimum="$(version_number "$minimum_version")" + maximum="$(version_number "$maximum_version")" + if [[ "$actual" < "$minimum" || "$actual" > "$maximum" || "$actual" == "$maximum" ]]; then + printf 'Tabellio CI requires Git >=%s and <%s; found %s.\n' \ + "$minimum_version" "$maximum_version" "$version" >&2 + return 1 + fi +} + +if [[ "${1:-}" == "--check-version" ]]; then + [[ "$#" == 2 ]] || { + printf '%s\n' "usage: verify-git-toolchain.sh --check-version " >&2 + exit 2 + } + check_supported_version "$2" + exit +fi + +actual_version="$(git version | awk '{ print $3 }')" +check_supported_version "$actual_version" + +head_commit="$(git rev-parse --verify 'HEAD^{commit}')" +git merge-base --is-ancestor "$head_commit" "$head_commit" + +( + temporary_dir="$(mktemp -d)" + trap 'rm -rf "$temporary_dir"' EXIT + git bundle create "$temporary_dir/capability.bundle" --all + git bundle verify "$temporary_dir/capability.bundle" >/dev/null +) + +architecture="$(uname -m)" +operating_system="$(uname -s)" +evidence_path="${TABELLIO_GIT_EVIDENCE_PATH:-tabellio-git-toolchain.json}" +if [[ ! "$architecture" =~ ^[A-Za-z0-9._-]+$ || ! "$operating_system" =~ ^[A-Za-z0-9._-]+$ ]]; then + printf '%s\n' "Tabellio CI could not record a portable architecture/OS identity." >&2 + exit 1 +fi + +printf '{"schemaVersion":"tabellio-git-toolchain/v0.1","gitVersion":"%s","architecture":"%s","os":"%s","capabilities":["bundle-create-verify","merge-base-is-ancestor","rev-parse-commit"]}\n' \ + "$actual_version" "$architecture" "$operating_system" > "$evidence_path" +printf 'Git %s supported on %s/%s; required capabilities passed.\n' \ + "$actual_version" "$operating_system" "$architecture" diff --git a/.tabellio/validators.json b/.tabellio/validators.json index 96a3a7e..95dd7e6 100644 --- a/.tabellio/validators.json +++ b/.tabellio/validators.json @@ -348,6 +348,30 @@ "metrics": [{"name": "baseline_integration_security_pass", "unit": "boolean", "passValue": 1, "failValue": 0}], "cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0}, "summary": "Packaged artifact completeness and private-evidence boundaries" + }, + "wave-admission-schema": { + "commands": [["node", "--test", "--test-name-pattern", "^schema accepts", "tests/wave-admission.test.mjs"]], + "metrics": [{"name": "wave_admission_schema_pass", "unit": "boolean", "passValue": 1, "failValue": 0}], + "cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0}, + "summary": "Wave manifest structure and bounded identity contract" + }, + "wave-admission-semantic": { + "commands": [["node", "--test", "--test-name-pattern", "^negative matrix", "tests/wave-admission.test.mjs"]], + "metrics": [{"name": "wave_admission_negative_pass", "unit": "boolean", "passValue": 1, "failValue": 0}], + "cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0}, + "summary": "Stable fail-closed admission reasons" + }, + "wave-admission-workflow": { + "commands": [["node", "--test", "--test-name-pattern", "workflow accepts|CLI renders", "tests/wave-admission.test.mjs"]], + "metrics": [{"name": "wave_admission_workflow_pass", "unit": "boolean", "passValue": 1, "failValue": 0}], + "cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0}, + "summary": "Three-repository admission and rejection demo" + }, + "wave-admission-security": { + "commands": [["node", "--test", "--test-name-pattern", "^security rejects", "tests/wave-admission.test.mjs"]], + "metrics": [{"name": "wave_admission_security_pass", "unit": "boolean", "passValue": 1, "failValue": 0}], + "cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0}, + "summary": "Repository path safety and no-external-action boundary" } } } diff --git a/docs/wave-admission.md b/docs/wave-admission.md new file mode 100644 index 0000000..8b05299 --- /dev/null +++ b/docs/wave-admission.md @@ -0,0 +1,50 @@ +# Wave Admission + +Wave admission answers one question before parallel implementation starts: +which lanes are safe to pull now? + +The manifest binds each lane to explicit Plane, repository, task, base-commit, +owned-surface, dependency, WIP, and integration-owner evidence. Admission is a +pure local decision. It does not start tasks, update Plane, call providers, +merge, deploy, or release. + +## Contract + +Use `tabellio-wave-manifest/v0.1` from +`schemas/wave-manifest.v0.1.schema.json`. + +A lane is rejected when: + +| Code | Meaning | +| --- | --- | +| `LANE_NOT_READY` | Plane state is not exactly `Ready`. | +| `WIP_LIMIT_EXCEEDED` | Existing plus requested WIP exceeds manifest limit, capped at three. | +| `DEPENDENCY_INCOMPLETE` | Declared dependency is not complete. | +| `MAPPING_MISSING` | Plane, repository, and task mapping cannot be resolved. | +| `SURFACE_OVERLAP` | Two lanes claim intersecting surfaces in one repository. | +| `STALE_BASE` | Planned and observed base commits differ. | +| `INTEGRATOR_MISSING` | Final integration owner does not resolve to a mapping. | +| `SURFACE_INVALID` | Owned surface is not a safe repository-relative path or `/**` prefix. | +| `DUPLICATE_IDENTITY` | Mapping, task, lane, or story identity is duplicated. | + +Reason ordering and wording are deterministic. + +## Demo + +```bash +node scripts/tabellio-wave-admit.mjs \ + --manifest examples/tabellio-wave/accepted-three-repository.json + +node scripts/tabellio-wave-admit.mjs \ + --manifest examples/tabellio-wave/rejected-overlap-dependency.json +``` + +First command accepts three independent Ready lanes. Second rejects overlapping +Tabellio surfaces and an incomplete dependency with stable reason codes. + +## Boundary + +Manifest is snapshot evidence supplied by operator or coordinator. Admission +does not query Plane or Git, and cannot infer links from names or timing. +Callers must build mappings from authoritative identifiers and refresh observed +base commits before each admission decision. diff --git a/examples/tabellio-wave/accepted-three-repository.json b/examples/tabellio-wave/accepted-three-repository.json new file mode 100644 index 0000000..ed5216b --- /dev/null +++ b/examples/tabellio-wave/accepted-three-repository.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": "tabellio-wave-manifest/v0.1", + "id": "intb-282-accepted-demo", + "capturedAt": "2026-07-31T18:00:00Z", + "wip": {"limit": 3, "started": 0}, + "finalIntegrator": {"mappingId": "tabellio", "owner": "Tabellio repository owner"}, + "mappings": [ + {"id": "tabellio", "planeProject": "INTB", "repository": "IntelIP/Tabellio", "taskId": "task-tabellio"}, + {"id": "contexenda", "planeProject": "CTX", "repository": "IntelIP/Contexenda", "taskId": "task-contexenda"}, + {"id": "vaticor", "planeProject": "VATI", "repository": "IntelIP/vaticor", "taskId": "task-vaticor"} + ], + "lanes": [ + { + "id": "wave-admission", + "mappingId": "tabellio", + "storyId": "INTB-282", + "state": "Ready", + "baseCommit": "1111111111111111111111111111111111111111", + "observedBaseCommit": "1111111111111111111111111111111111111111", + "ownedSurfaces": ["schemas/wave-manifest.v0.1.schema.json", "scripts/lib/wave-admission.mjs"], + "dependencies": [], + "wipSlots": 1 + }, + { + "id": "docx-fixtures", + "mappingId": "contexenda", + "storyId": "CTX-25", + "state": "Ready", + "baseCommit": "2222222222222222222222222222222222222222", + "observedBaseCommit": "2222222222222222222222222222222222222222", + "ownedSurfaces": ["tests/fixtures/docx/**"], + "dependencies": [{"storyId": "CTX-24", "status": "completed"}], + "wipSlots": 1 + }, + { + "id": "paper-eval", + "mappingId": "vaticor", + "storyId": "VATI-12", + "state": "Ready", + "baseCommit": "3333333333333333333333333333333333333333", + "observedBaseCommit": "3333333333333333333333333333333333333333", + "ownedSurfaces": ["evals/paper/**"], + "dependencies": [], + "wipSlots": 1 + } + ] +} diff --git a/examples/tabellio-wave/rejected-overlap-dependency.json b/examples/tabellio-wave/rejected-overlap-dependency.json new file mode 100644 index 0000000..d848eb6 --- /dev/null +++ b/examples/tabellio-wave/rejected-overlap-dependency.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": "tabellio-wave-manifest/v0.1", + "id": "intb-282-rejected-demo", + "capturedAt": "2026-07-31T18:00:00Z", + "wip": {"limit": 3, "started": 0}, + "finalIntegrator": {"mappingId": "tabellio", "owner": "Tabellio repository owner"}, + "mappings": [ + {"id": "tabellio", "planeProject": "INTB", "repository": "IntelIP/Tabellio", "taskId": "task-tabellio"}, + {"id": "contexenda", "planeProject": "CTX", "repository": "IntelIP/Contexenda", "taskId": "task-contexenda"}, + {"id": "vaticor", "planeProject": "VATI", "repository": "IntelIP/vaticor", "taskId": "task-vaticor"} + ], + "lanes": [ + { + "id": "wave-admission", + "mappingId": "tabellio", + "storyId": "INTB-282", + "state": "Ready", + "baseCommit": "1111111111111111111111111111111111111111", + "observedBaseCommit": "1111111111111111111111111111111111111111", + "ownedSurfaces": ["scripts/lib/**"], + "dependencies": [], + "wipSlots": 1 + }, + { + "id": "surface-lease", + "mappingId": "tabellio", + "storyId": "INTB-283", + "state": "Ready", + "baseCommit": "1111111111111111111111111111111111111111", + "observedBaseCommit": "1111111111111111111111111111111111111111", + "ownedSurfaces": ["scripts/lib/surface-lease.mjs"], + "dependencies": [{"storyId": "INTB-282", "status": "incomplete"}], + "wipSlots": 1 + }, + { + "id": "paper-eval", + "mappingId": "vaticor", + "storyId": "VATI-12", + "state": "Ready", + "baseCommit": "3333333333333333333333333333333333333333", + "observedBaseCommit": "3333333333333333333333333333333333333333", + "ownedSurfaces": ["evals/paper/**"], + "dependencies": [], + "wipSlots": 1 + } + ] +} diff --git a/package.json b/package.json index fb7e7db..b6c593b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "tabellio-github-releases": "scripts/tabellio-github-releases.mjs", "tabellio-analytics-releases": "scripts/tabellio-analytics-releases.mjs", "tabellio-plane-work-items": "scripts/tabellio-plane-work-items.mjs", + "tabellio-wave-admit": "scripts/tabellio-wave-admit.mjs", "tabellio-deployment-check": "scripts/check-tabellio-deployment-receipt.mjs", "tabellio-cloud-run-deployment": "scripts/tabellio-cloud-run-deployment.mjs", "tabellio-vercel-deployment": "scripts/tabellio-vercel-deployment.mjs" diff --git a/schemas/wave-manifest.v0.1.schema.json b/schemas/wave-manifest.v0.1.schema.json new file mode 100644 index 0000000..1a59d25 --- /dev/null +++ b/schemas/wave-manifest.v0.1.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://intelip.co/tabellio/schemas/wave-manifest.v0.1.schema.json", + "title": "Tabellio Wave Manifest v0.1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "capturedAt", "wip", "finalIntegrator", "mappings", "lanes"], + "properties": { + "schemaVersion": {"const": "tabellio-wave-manifest/v0.1"}, + "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"}, + "capturedAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$" + }, + "wip": { + "type": "object", + "additionalProperties": false, + "required": ["limit", "started"], + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 3}, + "started": {"type": "integer", "minimum": 0, "maximum": 3} + } + }, + "finalIntegrator": { + "type": "object", + "additionalProperties": false, + "required": ["mappingId", "owner"], + "properties": { + "mappingId": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000\\r\\n]+$"}, + "owner": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000\\r\\n]+$"} + } + }, + "mappings": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "planeProject", "repository", "taskId"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000\\r\\n]+$"}, + "planeProject": {"type": "string", "pattern": "^[A-Z][A-Z0-9]{1,9}$"}, + "repository": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "taskId": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000\\r\\n]+$"} + } + } + }, + "lanes": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "mappingId", "storyId", "state", "baseCommit", "observedBaseCommit", "ownedSurfaces", "dependencies", "wipSlots"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000\\r\\n]+$"}, + "mappingId": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000\\r\\n]+$"}, + "storyId": {"type": "string", "pattern": "^[A-Z][A-Z0-9]{1,9}-[1-9][0-9]*$"}, + "state": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^\\u0000\\r\\n]+$"}, + "baseCommit": {"type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$"}, + "observedBaseCommit": {"type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$"}, + "ownedSurfaces": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": {"type": "string", "minLength": 1, "maxLength": 500, "pattern": "^[^\\u0000\\r\\n]+$"} + }, + "dependencies": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["storyId", "status"], + "properties": { + "storyId": {"type": "string", "pattern": "^[A-Z][A-Z0-9]{1,9}-[1-9][0-9]*$"}, + "status": {"enum": ["completed", "incomplete"]} + } + } + }, + "wipSlots": {"type": "integer", "minimum": 1, "maximum": 3} + } + } + } + } +} diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs new file mode 100644 index 0000000..f810d8a --- /dev/null +++ b/scripts/lib/wave-admission.mjs @@ -0,0 +1,424 @@ +const SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const STORY_PATTERN = /^[A-Z][A-Z0-9]{1,9}-[1-9][0-9]*$/; +const PROJECT_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export const REASON_CODES = Object.freeze({ + DEPENDENCY_INCOMPLETE: "DEPENDENCY_INCOMPLETE", + DUPLICATE_IDENTITY: "DUPLICATE_IDENTITY", + INTEGRATOR_MISSING: "INTEGRATOR_MISSING", + LANE_NOT_READY: "LANE_NOT_READY", + MAPPING_MISSING: "MAPPING_MISSING", + STALE_BASE: "STALE_BASE", + SURFACE_INVALID: "SURFACE_INVALID", + SURFACE_OVERLAP: "SURFACE_OVERLAP", + WIP_LIMIT_EXCEEDED: "WIP_LIMIT_EXCEEDED" +}); + +export function validateWaveManifest(value) { + object(value, "manifest"); + exactKeys( + value, + ["schemaVersion", "id", "capturedAt", "wip", "finalIntegrator", "mappings", "lanes"], + "manifest" + ); + equal(value.schemaVersion, "tabellio-wave-manifest/v0.1", "manifest.schemaVersion"); + match(value.id, ID_PATTERN, "manifest.id"); + dateTime(value.capturedAt, "manifest.capturedAt"); + validateWip(value.wip); + validateIntegrator(value.finalIntegrator); + array(value.mappings, "manifest.mappings", 1, 100); + value.mappings.forEach((mapping, index) => validateMapping(mapping, index)); + array(value.lanes, "manifest.lanes", 1, 100); + value.lanes.forEach((lane, index) => validateLane(lane, index)); + return value; +} + +export function admitWave(value) { + const manifest = validateWaveManifest(value); + const mappingById = new Map(manifest.mappings.map((mapping) => [mapping.id, mapping])); + const reasonsByLane = new Map(manifest.lanes.map((lane) => [lane.id, []])); + const waveReasons = [ + ...duplicateIdentityReasons(manifest), + ...integratorReasons(manifest, mappingById) + ]; + applyWipReasons(manifest, reasonsByLane); + applyLaneReasons(manifest, mappingById, reasonsByLane); + applyRepositoryBaseReasons(manifest, mappingById, reasonsByLane); + applyOverlapReasons(manifest, mappingById, reasonsByLane); + const lanes = manifest.lanes.map((lane) => + laneDecision(lane, waveReasons, reasonsByLane.get(lane.id)) + ); + const accepted = lanes.filter((lane) => lane.decision === "accepted").length; + const rejected = lanes.length - accepted; + return { + schemaVersion: "tabellio-wave-admission/v0.1", + waveId: manifest.id, + decision: rejected === 0 ? "accepted" : "rejected", + summary: `${accepted} lane(s) accepted; ${rejected} lane(s) rejected.`, + lanes + }; +} + +function duplicateIdentityReasons(manifest) { + const identities = [ + ["Mapping id", manifest.mappings.map((mapping) => mapping.id)], + ["Task id", manifest.mappings.map((mapping) => mapping.taskId)], + ["Lane id", manifest.lanes.map((lane) => lane.id)], + ["Story id", manifest.lanes.map((lane) => lane.storyId)] + ]; + return identities.flatMap(([label, values]) => + duplicateValues(values).map((value) => + reason(REASON_CODES.DUPLICATE_IDENTITY, `${label} ${value} is duplicated.`) + ) + ); +} + +function integratorReasons(manifest, mappingById) { + const mappingExists = mappingById.has(manifest.finalIntegrator.mappingId); + const ownerExists = manifest.finalIntegrator.owner.trim() !== ""; + return mappingExists && ownerExists + ? [] + : [reason( + REASON_CODES.INTEGRATOR_MISSING, + "Final integration ownership does not resolve to a declared mapping." + )]; +} + +function applyWipReasons(manifest, reasonsByLane) { + const requestedSlots = manifest.lanes.reduce((sum, lane) => sum + lane.wipSlots, 0); + if (manifest.wip.started + requestedSlots <= manifest.wip.limit) return; + const message = `Wave requests ${requestedSlots} slot(s) with ${manifest.wip.started} already started; limit is ${manifest.wip.limit}.`; + for (const lane of manifest.lanes) { + reasonsByLane.get(lane.id).push(reason(REASON_CODES.WIP_LIMIT_EXCEEDED, message)); + } +} + +function applyLaneReasons(manifest, mappingById, reasonsByLane) { + const laneByStoryId = new Map(manifest.lanes.map((lane) => [lane.storyId, lane])); + for (const lane of manifest.lanes) { + reasonsByLane.get(lane.id).push(...laneReasons(lane, mappingById, laneByStoryId)); + } +} + +function laneReasons(lane, mappingById, laneByStoryId) { + return [ + ...mappingReasons(lane, mappingById), + ...readinessReasons(lane), + ...baseReasons(lane), + ...dependencyReasons(lane, laneByStoryId), + ...surfaceReasons(lane) + ]; +} + +function mappingReasons(lane, mappingById) { + const mapping = mappingById.get(lane.mappingId); + const storyProject = lane.storyId.split("-", 1)[0]; + return mapping && mapping.planeProject === storyProject + ? [] + : [reason( + REASON_CODES.MAPPING_MISSING, + `Lane ${lane.id} does not resolve to a declared Plane/repository/task mapping.` + )]; +} + +function readinessReasons(lane) { + return lane.state === "Ready" + ? [] + : [reason( + REASON_CODES.LANE_NOT_READY, + `Story ${lane.storyId} is ${lane.state}; Ready is required.` + )]; +} + +function baseReasons(lane) { + return lane.baseCommit === lane.observedBaseCommit + ? [] + : [reason( + REASON_CODES.STALE_BASE, + `Story ${lane.storyId} base commit differs from current repository evidence.` + )]; +} + +function dependencyReasons(lane, laneByStoryId) { + return lane.dependencies + .filter((dependency) => { + const dependencyLane = laneByStoryId.get(dependency.storyId); + const dependencyIsInFlight = dependencyLane && + !["Completed", "Done"].includes(dependencyLane.state); + return dependency.status !== "completed" || dependencyIsInFlight; + }) + .map((dependency) => reason( + REASON_CODES.DEPENDENCY_INCOMPLETE, + `Story ${lane.storyId} dependency ${dependency.storyId} is incomplete.` + )); +} + +function surfaceReasons(lane) { + return lane.ownedSurfaces + .filter((surface) => !safeSurface(surface)) + .map(() => reason( + REASON_CODES.SURFACE_INVALID, + `Story ${lane.storyId} owned surface is not a safe repository-relative pattern.` + )); +} + +function applyRepositoryBaseReasons(manifest, mappingById, reasonsByLane) { + const lanesByRepository = groupLanesByRepository(manifest.lanes, mappingById); + for (const [repository, lanes] of lanesByRepository) { + if (new Set(lanes.map((lane) => lane.observedBaseCommit)).size <= 1) continue; + const message = `Repository ${repository} has conflicting current base evidence in this wave.`; + for (const lane of lanes) { + reasonsByLane.get(lane.id).push(reason(REASON_CODES.STALE_BASE, message)); + } + } +} + +function groupLanesByRepository(lanes, mappingById) { + const lanesByRepository = new Map(); + for (const lane of lanes) { + const mapping = mappingById.get(lane.mappingId); + if (!mapping) continue; + const repository = canonicalRepository(mapping.repository); + const repositoryLanes = lanesByRepository.get(repository) ?? []; + repositoryLanes.push(lane); + lanesByRepository.set(repository, repositoryLanes); + } + return lanesByRepository; +} + +function applyOverlapReasons(manifest, mappingById, reasonsByLane) { + for (const [left, right] of lanePairs(manifest.lanes)) { + const overlap = laneOverlap(left, right, mappingById); + if (!overlap) continue; + const message = `Stories ${left.storyId} and ${right.storyId} overlap in ${overlap.repository}: ${overlap.surfaces[0]} <> ${overlap.surfaces[1]}.`; + reasonsByLane.get(left.id).push(reason(REASON_CODES.SURFACE_OVERLAP, message)); + reasonsByLane.get(right.id).push(reason(REASON_CODES.SURFACE_OVERLAP, message)); + } +} + +function lanePairs(lanes) { + const pairs = []; + for (let left = 0; left < lanes.length; left += 1) { + for (let right = left + 1; right < lanes.length; right += 1) { + pairs.push([lanes[left], lanes[right]]); + } + } + return pairs; +} + +function laneOverlap(left, right, mappingById) { + const leftMapping = mappingById.get(left.mappingId); + const rightMapping = mappingById.get(right.mappingId); + const repository = sharedRepository(leftMapping, rightMapping); + if (!repository) return null; + const surfaces = firstOverlap(left.ownedSurfaces, right.ownedSurfaces); + return surfaces ? {repository, surfaces} : null; +} + +function sharedRepository(leftMapping, rightMapping) { + if (!leftMapping) return null; + if (!rightMapping) return null; + const leftRepository = canonicalRepository(leftMapping.repository); + const rightRepository = canonicalRepository(rightMapping.repository); + return leftRepository === rightRepository ? leftRepository : null; +} + +function canonicalRepository(value) { + return value.toLowerCase(); +} + +function laneDecision(lane, waveReasons, laneSpecificReasons) { + const reasons = stableReasons([...waveReasons, ...laneSpecificReasons]); + return { + id: lane.id, + storyId: lane.storyId, + decision: reasons.length === 0 ? "accepted" : "rejected", + reasons + }; +} + +function validateWip(value) { + object(value, "manifest.wip"); + exactKeys(value, ["limit", "started"], "manifest.wip"); + integer(value.limit, "manifest.wip.limit", 1, 3); + integer(value.started, "manifest.wip.started", 0, 3); +} + +function validateIntegrator(value) { + object(value, "manifest.finalIntegrator"); + exactKeys(value, ["mappingId", "owner"], "manifest.finalIntegrator"); + string(value.mappingId, "manifest.finalIntegrator.mappingId", 128); + string(value.owner, "manifest.finalIntegrator.owner", 200); +} + +function validateMapping(value, index) { + const path = `manifest.mappings[${index}]`; + object(value, path); + exactKeys(value, ["id", "planeProject", "repository", "taskId"], path); + string(value.id, `${path}.id`, 128); + match(value.planeProject, PROJECT_PATTERN, `${path}.planeProject`); + match(value.repository, REPOSITORY_PATTERN, `${path}.repository`); + string(value.taskId, `${path}.taskId`, 200); +} + +function validateLane(value, index) { + const path = `manifest.lanes[${index}]`; + object(value, path); + exactKeys( + value, + ["id", "mappingId", "storyId", "state", "baseCommit", "observedBaseCommit", "ownedSurfaces", "dependencies", "wipSlots"], + path + ); + string(value.id, `${path}.id`, 128); + string(value.mappingId, `${path}.mappingId`, 128); + match(value.storyId, STORY_PATTERN, `${path}.storyId`); + string(value.state, `${path}.state`, 64); + match(value.baseCommit, SHA_PATTERN, `${path}.baseCommit`); + match(value.observedBaseCommit, SHA_PATTERN, `${path}.observedBaseCommit`); + array(value.ownedSurfaces, `${path}.ownedSurfaces`, 1, 100); + value.ownedSurfaces.forEach((surface, surfaceIndex) => + string(surface, `${path}.ownedSurfaces[${surfaceIndex}]`, 500) + ); + array(value.dependencies, `${path}.dependencies`, 0, 100); + value.dependencies.forEach((dependency, dependencyIndex) => { + const dependencyPath = `${path}.dependencies[${dependencyIndex}]`; + object(dependency, dependencyPath); + exactKeys(dependency, ["storyId", "status"], dependencyPath); + match(dependency.storyId, STORY_PATTERN, `${dependencyPath}.storyId`); + if (!["completed", "incomplete"].includes(dependency.status)) { + throw new Error(`${dependencyPath}.status must be completed or incomplete.`); + } + }); + integer(value.wipSlots, `${path}.wipSlots`, 1, 3); +} + +function safeSurface(value) { + if (typeof value !== "string") return false; + const invalidShape = [ + value === "", + value.includes("\\"), + value.startsWith("/"), + value.endsWith("/"), + /[\0\r\n]/.test(value) + ]; + if (invalidShape.includes(true)) return false; + const plain = stripSurfaceGlob(value); + const invalidPlain = [ + plain === "", + plain.startsWith("./"), + plain.includes("//"), + /[*?[\]{}]/.test(plain) + ]; + if (invalidPlain.includes(true)) return false; + return plain.split("/").every((part) => !["", ".", ".."].includes(part)); +} + +function stripSurfaceGlob(value) { + return value.endsWith("/**") ? value.slice(0, -3) : value; +} + +function firstOverlap(leftSurfaces, rightSurfaces) { + return surfacePairs(leftSurfaces, rightSurfaces).find(([left, right]) => + safeSurface(left) && safeSurface(right) && surfacesOverlap(left, right) + ) ?? null; +} + +function surfacePairs(leftSurfaces, rightSurfaces) { + return leftSurfaces.flatMap((left) => + rightSurfaces.map((right) => [left, right]) + ); +} + +function surfacesOverlap(left, right) { + const leftSurface = normalizedSurface(left); + const rightSurface = normalizedSurface(right); + return leftSurface.path === rightSurface.path || + parentSurfaceContains(leftSurface, rightSurface.path) || + parentSurfaceContains(rightSurface, leftSurface.path); +} + +function parentSurfaceContains(surface, path) { + return surface.prefix && path.startsWith(`${surface.path}/`); +} + +function normalizedSurface(value) { + const prefix = value.endsWith("/**"); + return {path: stripSurfaceGlob(value), prefix}; +} + +function stableReasons(values) { + return [...new Map( + values + .sort((left, right) => `${left.code}\0${left.message}`.localeCompare(`${right.code}\0${right.message}`)) + .map((value) => [`${value.code}\0${value.message}`, value]) + ).values()]; +} + +function reason(code, message) { + return {code, message}; +} + +function duplicateValues(values) { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return [...counts].filter(([, count]) => count > 1).map(([value]) => value).sort(); +} + +function exactKeys(value, expected, path) { + const actual = Object.keys(value); + const missing = expected.filter((key) => !actual.includes(key)); + const unexpected = actual.filter((key) => !expected.includes(key)); + if (missing.length + unexpected.length === 0) return; + throw new Error(`${path} must contain exactly: ${[...expected].sort().join(", ")}.`); +} + +function object(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object.`); + } +} + +function array(value, path, minimum, maximum) { + if (!Array.isArray(value) || value.length < minimum || value.length > maximum) { + throw new Error(`${path} must contain ${minimum} to ${maximum} items.`); + } +} + +function string(value, path, maximum) { + const invalid = [typeof value !== "string", value === "", value.length > maximum, /[\0\r\n]/.test(value)]; + if (!invalid.includes(true)) return; + throw new Error(`${path} must be a non-empty single-line string up to ${maximum} characters.`); +} + +function match(value, pattern, path) { + if (typeof value !== "string" || !pattern.test(value)) throw new Error(`${path} is invalid.`); +} + +function equal(value, expected, path) { + if (value !== expected) throw new Error(`${path} must be ${expected}.`); +} + +function integer(value, path, minimum, maximum) { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error(`${path} must be an integer from ${minimum} to ${maximum}.`); + } +} + +function dateTime(value, path) { + string(value, path, 64); + const inputSecond = value.replace(/\.\d+Z$/, "Z"); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value)) invalidDateTime(path); + if (value.startsWith("0000-")) invalidDateTime(path); + if (canonicalSecond(value) !== inputSecond) invalidDateTime(path); +} + +function canonicalSecond(value) { + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) ? `${parsed.toISOString().slice(0, 19)}Z` : null; +} + +function invalidDateTime(path) { + throw new Error(`${path} must be a UTC RFC 3339 timestamp.`); +} diff --git a/scripts/tabellio-wave-admit.mjs b/scripts/tabellio-wave-admit.mjs new file mode 100644 index 0000000..7ac4fae --- /dev/null +++ b/scripts/tabellio-wave-admit.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import {readFile, realpath} from "node:fs/promises"; +import {isAbsolute, relative, resolve} from "node:path"; + +import {admitWave} from "./lib/wave-admission.mjs"; + +try { + const options = parseOptions(process.argv.slice(2)); + const root = await realpath(process.cwd()); + const manifestPath = await containedPath(root, options.manifest, "manifest"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const report = admitWave(manifest); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (report.decision !== "accepted") process.exitCode = 1; +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; +} + +function parseOptions(args) { + if (args.length !== 2 || args[0] !== "--manifest" || !args[1]) { + throw new Error("Usage: tabellio-wave-admit --manifest "); + } + return {manifest: args[1]}; +} + +async function containedPath(root, input, label) { + if (isAbsolute(input)) throw new Error(`${label} must be repository-relative.`); + const lexicalTarget = resolve(root, input); + const lexicalRel = relative(root, lexicalTarget); + const lexicalEscape = [lexicalRel === "", lexicalRel.startsWith(".."), isAbsolute(lexicalRel)].includes(true); + if (lexicalEscape) throw new Error(`${label} must stay inside the repository.`); + const target = await realpath(lexicalTarget); + const rel = relative(root, target); + const escapesRepository = [rel === "", rel.startsWith(".."), isAbsolute(rel)].includes(true); + if (escapesRepository) throw new Error(`${label} must stay inside the repository.`); + return target; +} diff --git a/tabellio.validation.json b/tabellio.validation.json index 4e04d68..6fd9ce5 100644 --- a/tabellio.validation.json +++ b/tabellio.validation.json @@ -5,7 +5,7 @@ "requireEntireCheckpoint": true, "acceptance": { "id": "tabellio-v0.5-control-plane-and-analytics", - "source": "repository product contract, docs/product-validation.md, and INTB-261", + "source": "repository product contract, docs/product-validation.md, INTB-261, and INTB-282", "risk": "high", "outcomes": [ "Agent runs, checkpoints, reviews, and validation results remain bound to exact Git state.", @@ -13,14 +13,16 @@ "External actions and release operations remain approval-gated and fail closed.", "Validation worktrees and isolated home directories remain outside the repository and Git common directory and are removed after each run.", "Analytics validator evidence distinguishes malformed inputs from valid contract failures.", - "The packaged cross-repository baseline binds exact repository heads, provider snapshots, and a reproducible report." + "The packaged cross-repository baseline binds exact repository heads, provider snapshots, and a reproducible report.", + "Wave admission accepts only Ready, dependency-satisfied, non-overlapping, current-base work with explicit WIP and integration ownership." ], "invariants": [ "Validation never publishes, deploys, or mutates a live external system.", "Unknown or missing cost telemetry cannot be treated as zero.", "A validation run cannot select a workspace root inside the repository or Git common directory.", "Analytics validator summaries and artifacts never copy rejected private source text.", - "Baseline unavailable reasons remain derived from their packaged provider snapshots." + "Baseline unavailable reasons remain derived from their packaged provider snapshots.", + "Wave admission remains deterministic and performs no external mutation." ], "forbiddenOutcomes": [ "A stale validation result is accepted for a moved head.", @@ -28,7 +30,8 @@ "An unapproved external action is executed.", "A validation worktree, temporary HOME, dependency cache, or generated validator output is created under .git.", "Malformed analytics input is reported as passed or as a valid product failure.", - "The shipped validation manifest references baseline evidence omitted from the package." + "The shipped validation manifest references baseline evidence omitted from the package.", + "Wave admission launches tasks, mutates Plane, or accepts stale, overlapping, dependency-incomplete, or unowned work." ], "requiredValidatorTypes": ["static", "schema", "semantic", "workflow", "operational", "security"] }, @@ -42,6 +45,10 @@ {"id": "analytics-semantic", "type": "semantic", "argv": ["node", "scripts/tabellio-analytics-validator.mjs", "--profile", "semantic", "--validator-id", "analytics-semantic", "--dataset", "reports/analytics/2026-07-28-intb-261-baseline.json", "--expected-digest", "8f8071c5797af2c38abea8d81785bff259f9f42ab347ffe535c29e3c5b7fb9d5", "--source", "reports/analytics/sources/2026-07-28-condere-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-probanda-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-tabellio-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-vaticor-provider-snapshot.json", "--required-repository", "IntelIP/Condere", "--required-repository", "IntelIP/Probanda", "--required-repository", "IntelIP/Tabellio", "--required-repository", "IntelIP/vaticor", "--out", ".artifacts/tabellio/analytics-semantic.json", "--exit-mode", "evidence"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/analytics-semantic.json"}, "policy": {"metricThresholds": [{"metric": "analytics_validator_semantic_pass", "operator": "eq", "value": 1}, {"metric": "analytics_repository_count", "operator": "eq", "value": 4}, {"metric": "analytics_trace_count", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}}, {"id": "analytics-workflow", "type": "workflow", "argv": ["node", "scripts/tabellio-analytics-validator.mjs", "--profile", "workflow", "--validator-id", "analytics-workflow", "--dataset", "reports/analytics/2026-07-28-intb-261-baseline.json", "--expected-digest", "8f8071c5797af2c38abea8d81785bff259f9f42ab347ffe535c29e3c5b7fb9d5", "--source", "reports/analytics/sources/2026-07-28-condere-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-probanda-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-tabellio-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-vaticor-provider-snapshot.json", "--out", ".artifacts/tabellio/analytics-workflow.json", "--exit-mode", "evidence"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/analytics-workflow.json"}, "policy": {"metricThresholds": [{"metric": "analytics_validator_workflow_pass", "operator": "eq", "value": 1}, {"metric": "analytics_snapshot_count", "operator": "eq", "value": 4}], "maxCostUsd": 0, "requireCostTelemetry": true}}, {"id": "analytics-operational", "type": "operational", "argv": ["node", "scripts/tabellio-analytics-validator.mjs", "--profile", "operational", "--validator-id", "analytics-operational", "--dataset", "reports/analytics/2026-07-28-intb-261-baseline.json", "--expected-digest", "8f8071c5797af2c38abea8d81785bff259f9f42ab347ffe535c29e3c5b7fb9d5", "--out", ".artifacts/tabellio/analytics-operational.json", "--exit-mode", "evidence"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/analytics-operational.json"}, "policy": {"metricThresholds": [{"metric": "analytics_projection_25x_duration_ms", "operator": "lte", "value": 1000}], "maxCostUsd": 0, "requireCostTelemetry": true}}, - {"id": "analytics-security", "type": "security", "argv": ["node", "scripts/tabellio-analytics-validator.mjs", "--profile", "security", "--validator-id", "analytics-security", "--dataset", "reports/analytics/2026-07-28-intb-261-baseline.json", "--expected-digest", "8f8071c5797af2c38abea8d81785bff259f9f42ab347ffe535c29e3c5b7fb9d5", "--source", "reports/analytics/sources/2026-07-28-condere-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-probanda-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-tabellio-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-vaticor-provider-snapshot.json", "--out", ".artifacts/tabellio/analytics-security.json", "--exit-mode", "evidence"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/analytics-security.json"}, "policy": {"metricThresholds": [{"metric": "analytics_validator_security_pass", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}} + {"id": "analytics-security", "type": "security", "argv": ["node", "scripts/tabellio-analytics-validator.mjs", "--profile", "security", "--validator-id", "analytics-security", "--dataset", "reports/analytics/2026-07-28-intb-261-baseline.json", "--expected-digest", "8f8071c5797af2c38abea8d81785bff259f9f42ab347ffe535c29e3c5b7fb9d5", "--source", "reports/analytics/sources/2026-07-28-condere-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-probanda-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-tabellio-provider-snapshot.json", "--source", "reports/analytics/sources/2026-07-28-vaticor-provider-snapshot.json", "--out", ".artifacts/tabellio/analytics-security.json", "--exit-mode", "evidence"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/analytics-security.json"}, "policy": {"metricThresholds": [{"metric": "analytics_validator_security_pass", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}}, + {"id": "wave-admission-schema", "type": "schema", "argv": ["node", "scripts/tabellio-validator.mjs", "--profile", "wave-admission-schema", "--validator-id", "wave-admission-schema", "--out", ".artifacts/tabellio/wave-admission-schema.json"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/wave-admission-schema.json"}, "policy": {"metricThresholds": [{"metric": "wave_admission_schema_pass", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}}, + {"id": "wave-admission-negative", "type": "semantic", "argv": ["node", "scripts/tabellio-validator.mjs", "--profile", "wave-admission-semantic", "--validator-id", "wave-admission-negative", "--out", ".artifacts/tabellio/wave-admission-negative.json"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/wave-admission-negative.json"}, "policy": {"metricThresholds": [{"metric": "wave_admission_negative_pass", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}}, + {"id": "wave-admission-workflow", "type": "workflow", "argv": ["node", "scripts/tabellio-validator.mjs", "--profile", "wave-admission-workflow", "--validator-id", "wave-admission-workflow", "--out", ".artifacts/tabellio/wave-admission-workflow.json"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/wave-admission-workflow.json"}, "policy": {"metricThresholds": [{"metric": "wave_admission_workflow_pass", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}}, + {"id": "wave-admission-security", "type": "security", "argv": ["node", "scripts/tabellio-validator.mjs", "--profile", "wave-admission-security", "--validator-id", "wave-admission-security", "--out", ".artifacts/tabellio/wave-admission-security.json"], "cwd": ".", "timeoutMs": 300000, "required": true, "evidence": {"path": ".artifacts/tabellio/wave-admission-security.json"}, "policy": {"metricThresholds": [{"metric": "wave_admission_security_pass", "operator": "eq", "value": 1}], "maxCostUsd": 0, "requireCostTelemetry": true}} ] } diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 954ff33..b580c8d 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -1,43 +1,55 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); async function repositoryFile(path) { return readFile(new URL(`../${path}`, import.meta.url), "utf8"); } +async function hostGitVersion() { + const {stdout} = await execFileAsync("git", ["version"]); + return stdout.match(/^git version (\d+\.\d+\.\d+)/)?.[1] ?? ""; +} + test("Buildkite adds bounded pull-request quality gates without CI cutover", async () => { - const [pipeline, productValidation, fallow, packageCheck, gitToolchain] = await Promise.all([ + const [pipeline, productValidation, repositoryCheck, fallow, packageCheck, gitToolchain] = await Promise.all([ repositoryFile(".buildkite/pipeline.yml"), repositoryFile(".buildkite/scripts/product-validation.sh"), + repositoryFile(".buildkite/scripts/tests.sh"), repositoryFile(".buildkite/scripts/fallow.sh"), repositoryFile(".buildkite/scripts/package.sh"), - repositoryFile(".buildkite/scripts/build-modern-git.sh"), + repositoryFile(".buildkite/scripts/verify-git-toolchain.sh"), ]); assert.match(pipeline, /key: "repository-check"/); assert.match(pipeline, /key: "fallow"/); assert.match(pipeline, /key: "package"/); assert.match(pipeline, /key: "product-validation"/); + assert.match(pipeline, /tabellio-git-toolchain\.json/); + assert.match(pipeline, /^agents:\n queue: "macos-medium"$/m); + assert.doesNotMatch(pipeline, /queue: "linux-small"/); + assert.doesNotMatch(pipeline, /linux-amd64/); + assert.doesNotMatch(pipeline, /build-modern-git/); assert.doesNotMatch(pipeline, /BUILDKITE_GITHUB_EVENT/); - assert.equal( - pipeline.match(/build\.pull_request\.id != null/g)?.length, - 5, - ); - assert.match( - pipeline, - /build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight"/, - ); - assert.match(pipeline, /build\.branch == pipeline\.default_branch/); + assert.doesNotMatch(pipeline, /build\.pull_request\.id/); + assert.doesNotMatch(pipeline, /build\.env\("BUILDKITE_PULL_REQUEST"\)/); + assert.doesNotMatch(pipeline, /^\s+if:/m); assert.doesNotMatch(productValidation, /git show -s --format=%s/); assertMatches(productValidation, [ /BUILDKITE_COMMIT:-HEAD/, - /default-branch build/, + /default branch/, /TABELLIO_BUILD_CONTEXT:-provider/, /TABELLIO_BASE_BRANCH:-main/, /set -euo pipefail/, - /exit 2/, + /decision":"not_required"/, + /exit 0/, /test "\$\(git rev-parse HEAD\^\{commit\}\)"/, /git bundle create .*validation-ref\.bundle/, /git bundle verify .*validation-ref\.bundle/, @@ -58,19 +70,21 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.match(fallow, /fallow@2\.89\.0/); assert.match(fallow, /--gate new-only/); + assert.match(repositoryCheck, /\.buildkite\/scripts\/verify-git-toolchain\.sh/); + assert.match(fallow, /\.buildkite\/scripts\/verify-git-toolchain\.sh/); + assert.match(productValidation, /\.buildkite\/scripts\/verify-git-toolchain\.sh/); assert.match(packageCheck, /npm pack --dry-run --json/); assert.match(packageCheck, /forgejo\|change-request-provider/); - assert.match(gitToolchain, /dpkg-query/); - assert.match(gitToolchain, /Dir::Etc::sourcelist="\$apt_source_list"/); - assert.match(gitToolchain, /Dir::Etc::sourceparts="\$apt_source_parts"/); - assert.match(gitToolchain, /Dir::State::lists="\$apt_lists"/); - assert.match(gitToolchain, /ubuntu\\\.com/); - assert.match(gitToolchain, /debian\\\.org/); - assert.match(gitToolchain, /sources\.list\.d\/ubuntu\.sources/); - assert.match(gitToolchain, /sources\.list\.d\/debian\.sources/); - assert.doesNotMatch(gitToolchain, /sources\.list\.d\/\*/); - assert.equal(gitToolchain.match(/sudo apt-get "\$\{apt_options\[@\]\}"/g)?.length, 2); - assert.doesNotMatch(gitToolchain, /^\s*sudo apt-get update\s*$/m); + assert.match(gitToolchain, /minimum_version="2\.50\.1"/); + assert.match(gitToolchain, /maximum_version="3\.0\.0"/); + assert.match(gitToolchain, /actual_version="\$\(git version \| awk/); + assert.match(gitToolchain, /git bundle create/); + assert.match(gitToolchain, /git bundle verify/); + assert.match(gitToolchain, /git merge-base --is-ancestor/); + assert.match(gitToolchain, /git rev-parse --verify/); + assert.match(gitToolchain, /architecture/); + assert.match(gitToolchain, /operating_system/); + assert.doesNotMatch(gitToolchain, /apt-get|dpkg-query|linux-amd64/); }); function assertMatches(value, patterns) { @@ -84,3 +98,57 @@ test("GitHub merged-head validation remains during Buildkite migration", async ( assert.match(workflow, /pull-requests: read/); assert.match(workflow, /scripts\/resolve-merged-checkpoint\.mjs/); }); + +test("Git capability gate accepts the supported range and rejects unsafe bounds", async () => { + const script = new URL("../.buildkite/scripts/verify-git-toolchain.sh", import.meta.url); + await execFileAsync("bash", [script.pathname, "--check-version", "2.50.1"]); + await execFileAsync("bash", [script.pathname, "--check-version", "2.52.0"]); + await assert.rejects( + execFileAsync("bash", [script.pathname, "--check-version", "2.49.9"]), + (error) => error.code === 1 && error.stderr.includes(">=2.50.1 and <3.0.0") + ); + await assert.rejects( + execFileAsync("bash", [script.pathname, "--check-version", "3.0.0"]), + (error) => error.code === 1 && error.stderr.includes(">=2.50.1 and <3.0.0") + ); +}); + +test("sourced Git capability gate records features and restores caller cleanup state", async (context) => { + const script = new URL("../.buildkite/scripts/verify-git-toolchain.sh", import.meta.url); + const hostVersion = await hostGitVersion(); + try { + await execFileAsync("bash", [script.pathname, "--check-version", hostVersion]); + } catch { + context.skip(`host Git ${hostVersion} is outside the supported runtime range`); + return; + } + const directory = await mkdtemp(join(tmpdir(), "tabellio-git-source-")); + const evidence = join(directory, "evidence.json"); + const sourceCheck = [ + "temporary_dir=caller-owned", + "trap 'true' EXIT", + "before=\"$(trap -p EXIT)\"", + `. ${JSON.stringify(script.pathname)}`, + 'test "$temporary_dir" = caller-owned', + 'test "$before" = "$(trap -p EXIT)"', + `test -f ${JSON.stringify(evidence)}`, + `test -z "$(find ${JSON.stringify(directory)} -mindepth 1 -type d -print -quit)"` + ].join("\n"); + try { + await execFileAsync("bash", ["-c", sourceCheck], { + cwd: new URL("..", import.meta.url), + env: {...process.env, TABELLIO_GIT_EVIDENCE_PATH: evidence} + }); + const record = JSON.parse(await readFile(evidence, "utf8")); + assert.match(record.gitVersion, /^\d+\.\d+\.\d+$/); + assert.match(record.architecture, /^[A-Za-z0-9._-]+$/); + assert.match(record.os, /^[A-Za-z0-9._-]+$/); + assert.deepEqual(record.capabilities, [ + "bundle-create-verify", + "merge-base-is-ancestor", + "rev-parse-commit" + ]); + } finally { + await rm(directory, {recursive: true, force: true}); + } +}); diff --git a/tests/wave-admission.test.mjs b/tests/wave-admission.test.mjs new file mode 100644 index 0000000..a29cc8d --- /dev/null +++ b/tests/wave-admission.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import {execFile} from "node:child_process"; +import {mkdtemp, readFile, rm, symlink, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {promisify} from "node:util"; +import test from "node:test"; + +import { + admitWave, + REASON_CODES, + validateWaveManifest +} from "../scripts/lib/wave-admission.mjs"; +import {validateJsonSchema} from "../scripts/lib/json-schema-validator.mjs"; + +const execFileAsync = promisify(execFile); + +async function fixture(name) { + return JSON.parse(await readFile(new URL(`../examples/tabellio-wave/${name}`, import.meta.url))); +} + +test("schema accepts bounded three-repository manifest", async () => { + const manifest = await fixture("accepted-three-repository.json"); + const schema = JSON.parse(await readFile( + new URL("../schemas/wave-manifest.v0.1.schema.json", import.meta.url) + )); + assert.deepEqual(validateJsonSchema(manifest, schema), []); + assert.equal(validateWaveManifest(manifest), manifest); +}); + +test("workflow accepts independent Ready lanes with explicit WIP and integration ownership", async () => { + const report = admitWave(await fixture("accepted-three-repository.json")); + assert.equal(report.decision, "accepted"); + assert.equal(report.summary, "3 lane(s) accepted; 0 lane(s) rejected."); + assert.deepEqual(report.lanes.map((lane) => lane.decision), ["accepted", "accepted", "accepted"]); +}); + +test("negative matrix rejects overlap and incomplete dependency with stable reasons", async () => { + const report = admitWave(await fixture("rejected-overlap-dependency.json")); + assert.equal(report.decision, "rejected"); + assert.deepEqual( + report.lanes.find((lane) => lane.storyId === "INTB-283").reasons.map((item) => item.code), + [REASON_CODES.DEPENDENCY_INCOMPLETE, REASON_CODES.SURFACE_OVERLAP] + ); + assert.deepEqual( + report.lanes.find((lane) => lane.storyId === "INTB-282").reasons.map((item) => item.code), + [REASON_CODES.SURFACE_OVERLAP] + ); +}); + +test("negative matrix rejects non-Ready, WIP, missing mapping, stale base, and absent integrator", async () => { + const manifest = await fixture("accepted-three-repository.json"); + manifest.wip.started = 1; + manifest.finalIntegrator.mappingId = "missing"; + manifest.lanes[0].state = "In Progress"; + manifest.lanes[1].mappingId = "missing"; + manifest.lanes[2].observedBaseCommit = "4444444444444444444444444444444444444444"; + const report = admitWave(manifest); + const codes = new Set(report.lanes.flatMap((lane) => lane.reasons.map((item) => item.code))); + assert.deepEqual( + [...codes].sort(), + [ + REASON_CODES.INTEGRATOR_MISSING, + REASON_CODES.LANE_NOT_READY, + REASON_CODES.MAPPING_MISSING, + REASON_CODES.STALE_BASE, + REASON_CODES.WIP_LIMIT_EXCEEDED + ].sort() + ); +}); + +test("repository identity is case-insensitive for overlap and base consistency", async () => { + const overlapManifest = await fixture("accepted-three-repository.json"); + overlapManifest.mappings[1].repository = "intelip/tabellio"; + overlapManifest.lanes[1].ownedSurfaces = ["scripts/lib/**"]; + const overlapReport = admitWave(overlapManifest); + assert.ok(overlapReport.lanes[0].reasons.some((item) => item.code === REASON_CODES.SURFACE_OVERLAP)); + assert.ok(overlapReport.lanes[1].reasons.some((item) => item.code === REASON_CODES.SURFACE_OVERLAP)); + + const baseManifest = await fixture("accepted-three-repository.json"); + baseManifest.mappings[1].repository = "intelip/tabellio"; + const baseReport = admitWave(baseManifest); + assert.ok(baseReport.lanes[0].reasons.some((item) => item.code === REASON_CODES.STALE_BASE)); + assert.ok(baseReport.lanes[1].reasons.some((item) => item.code === REASON_CODES.STALE_BASE)); +}); + +test("mapping, glob, and in-wave dependency identity fail closed", async () => { + const manifest = await fixture("accepted-three-repository.json"); + manifest.mappings[0].planeProject = "CTX"; + manifest.lanes[0].ownedSurfaces = ["scripts/*.mjs"]; + manifest.lanes[0].dependencies = [{ + storyId: manifest.lanes[1].storyId, + status: "completed" + }]; + const report = admitWave(manifest); + const codes = report.lanes[0].reasons.map((item) => item.code); + assert.ok(codes.includes(REASON_CODES.MAPPING_MISSING)); + assert.ok(codes.includes(REASON_CODES.SURFACE_INVALID)); + assert.ok(codes.includes(REASON_CODES.DEPENDENCY_INCOMPLETE)); +}); + +test("schema and runtime both require UTC Z timestamps", async () => { + const manifest = await fixture("accepted-three-repository.json"); + const schema = JSON.parse(await readFile( + new URL("../schemas/wave-manifest.v0.1.schema.json", import.meta.url) + )); + manifest.capturedAt = "2026-07-31T18:00:00+00:00"; + assert.notDeepEqual(validateJsonSchema(manifest, schema), []); + assert.throws(() => validateWaveManifest(manifest), /UTC RFC 3339/); +}); + +test("schema and runtime reject normalized invalid capture dates", async () => { + const manifest = await fixture("accepted-three-repository.json"); + const schema = JSON.parse(await readFile( + new URL("../schemas/wave-manifest.v0.1.schema.json", import.meta.url) + )); + manifest.capturedAt = "2026-02-30T00:00:00Z"; + assert.notDeepEqual(validateJsonSchema(manifest, schema), []); + assert.throws(() => validateWaveManifest(manifest), /UTC RFC 3339/); +}); + +test("schema and runtime reject year-zero capture timestamps", async () => { + const manifest = await fixture("accepted-three-repository.json"); + const schema = JSON.parse(await readFile( + new URL("../schemas/wave-manifest.v0.1.schema.json", import.meta.url) + )); + manifest.capturedAt = "0000-01-01T00:00:00Z"; + assert.notDeepEqual(validateJsonSchema(manifest, schema), []); + assert.throws(() => validateWaveManifest(manifest), /UTC RFC 3339/); +}); + +test("schema and runtime reject multiline bounded strings", async () => { + const manifest = await fixture("accepted-three-repository.json"); + const schema = JSON.parse(await readFile( + new URL("../schemas/wave-manifest.v0.1.schema.json", import.meta.url) + )); + manifest.finalIntegrator.owner = "owner\nname"; + assert.notDeepEqual(validateJsonSchema(manifest, schema), []); + assert.throws(() => validateWaveManifest(manifest), /single-line/); +}); + +test("security rejects unsafe owned surfaces and repository-external CLI inputs", async () => { + const manifest = await fixture("accepted-three-repository.json"); + manifest.lanes[0].ownedSurfaces = ["../secrets"]; + const report = admitWave(manifest); + assert.equal(report.lanes[0].reasons[0].code, REASON_CODES.SURFACE_INVALID); + await assert.rejects( + execFileAsync(process.execPath, ["scripts/tabellio-wave-admit.mjs", "--manifest", "../outside.json"]), + (error) => error.code === 2 && error.stderr.includes("manifest must stay inside the repository") + ); + + const externalRoot = await mkdtemp(join(tmpdir(), "tabellio-wave-external-")); + const linkRoot = await mkdtemp(join(process.cwd(), ".tabellio-wave-link-")); + const externalManifest = join(externalRoot, "manifest.json"); + const manifestLink = join(linkRoot, "manifest.json"); + try { + await writeFile(externalManifest, "{}\n"); + await symlink(externalManifest, manifestLink); + await assert.rejects( + execFileAsync(process.execPath, [ + "scripts/tabellio-wave-admit.mjs", + "--manifest", + manifestLink.slice(process.cwd().length + 1) + ]), + (error) => error.code === 2 && error.stderr.includes("manifest must stay inside the repository") + ); + } finally { + await rm(linkRoot, {recursive: true, force: true}); + await rm(externalRoot, {recursive: true, force: true}); + } +}); + +test("CLI renders business-readable accepted and rejected reports without external action", async () => { + const accepted = await execFileAsync(process.execPath, [ + "scripts/tabellio-wave-admit.mjs", + "--manifest", + "examples/tabellio-wave/accepted-three-repository.json" + ]); + assert.equal(JSON.parse(accepted.stdout).decision, "accepted"); + await assert.rejects( + execFileAsync(process.execPath, [ + "scripts/tabellio-wave-admit.mjs", + "--manifest", + "examples/tabellio-wave/rejected-overlap-dependency.json" + ]), + (error) => { + const report = JSON.parse(error.stdout); + return error.code === 1 && + report.lanes.some((lane) => lane.reasons.some((item) => item.code === REASON_CODES.SURFACE_OVERLAP)) && + report.lanes.some((lane) => lane.reasons.some((item) => item.code === REASON_CODES.DEPENDENCY_INCOMPLETE)); + } + ); +});