From d043a44805a11205e65c25bcfc32fbdcbeb83cd3 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 11:44:25 -0700 Subject: [PATCH 01/15] INTB-282 add deterministic wave admission Entire-Checkpoint: ea0ff36384d2 --- .tabellio/validators.json | 24 ++ docs/wave-admission.md | 50 +++ .../accepted-three-repository.json | 47 +++ .../rejected-overlap-dependency.json | 47 +++ package.json | 1 + schemas/wave-manifest.v0.1.schema.json | 85 +++++ scripts/lib/wave-admission.mjs | 294 ++++++++++++++++++ scripts/tabellio-wave-admit.mjs | 36 +++ tabellio.validation.json | 17 +- tests/wave-admission.test.mjs | 101 ++++++ 10 files changed, 697 insertions(+), 5 deletions(-) create mode 100644 docs/wave-admission.md create mode 100644 examples/tabellio-wave/accepted-three-repository.json create mode 100644 examples/tabellio-wave/rejected-overlap-dependency.json create mode 100644 schemas/wave-manifest.v0.1.schema.json create mode 100644 scripts/lib/wave-admission.mjs create mode 100644 scripts/tabellio-wave-admit.mjs create mode 100644 tests/wave-admission.test.mjs 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..ed066a2 --- /dev/null +++ b/schemas/wave-manifest.v0.1.schema.json @@ -0,0 +1,85 @@ +{ + "$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"}, + "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}, + "owner": {"type": "string", "minLength": 1, "maxLength": 200} + } + }, + "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}, + "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} + } + } + }, + "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}, + "mappingId": {"type": "string", "minLength": 1, "maxLength": 128}, + "storyId": {"type": "string", "pattern": "^[A-Z][A-Z0-9]{1,9}-[1-9][0-9]*$"}, + "state": {"type": "string", "minLength": 1, "maxLength": 64}, + "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} + }, + "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..5adc7de --- /dev/null +++ b/scripts/lib/wave-admission.mjs @@ -0,0 +1,294 @@ +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 = []; + + duplicateValues(manifest.mappings.map((mapping) => mapping.id)).forEach((id) => + waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Mapping id ${id} is duplicated.`)) + ); + duplicateValues(manifest.mappings.map((mapping) => mapping.taskId)).forEach((taskId) => + waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Task id ${taskId} is duplicated.`)) + ); + duplicateValues(manifest.lanes.map((lane) => lane.id)).forEach((id) => + waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Lane id ${id} is duplicated.`)) + ); + duplicateValues(manifest.lanes.map((lane) => lane.storyId)).forEach((storyId) => + waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Story id ${storyId} is duplicated.`)) + ); + + const integratorMapping = mappingById.get(manifest.finalIntegrator.mappingId); + if (!integratorMapping || manifest.finalIntegrator.owner.trim() === "") { + waveReasons.push(reason( + REASON_CODES.INTEGRATOR_MISSING, + "Final integration ownership does not resolve to a declared mapping." + )); + } + + const requestedSlots = manifest.lanes.reduce((sum, lane) => sum + lane.wipSlots, 0); + if (manifest.wip.started + requestedSlots > manifest.wip.limit) { + 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)); + } + } + + for (const lane of manifest.lanes) { + const laneReasons = reasonsByLane.get(lane.id); + if (!mappingById.has(lane.mappingId)) { + laneReasons.push(reason( + REASON_CODES.MAPPING_MISSING, + `Lane ${lane.id} does not resolve to a declared Plane/repository/task mapping.` + )); + } + if (lane.state !== "Ready") { + laneReasons.push(reason( + REASON_CODES.LANE_NOT_READY, + `Story ${lane.storyId} is ${lane.state}; Ready is required.` + )); + } + if (lane.baseCommit !== lane.observedBaseCommit) { + laneReasons.push(reason( + REASON_CODES.STALE_BASE, + `Story ${lane.storyId} base commit differs from current repository evidence.` + )); + } + for (const dependency of lane.dependencies) { + if (dependency.status !== "completed") { + laneReasons.push(reason( + REASON_CODES.DEPENDENCY_INCOMPLETE, + `Story ${lane.storyId} dependency ${dependency.storyId} is incomplete.` + )); + } + } + for (const surface of lane.ownedSurfaces) { + if (!safeSurface(surface)) { + laneReasons.push(reason( + REASON_CODES.SURFACE_INVALID, + `Story ${lane.storyId} owned surface is not a safe repository-relative pattern.` + )); + } + } + } + + for (let leftIndex = 0; leftIndex < manifest.lanes.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < manifest.lanes.length; rightIndex += 1) { + const left = manifest.lanes[leftIndex]; + const right = manifest.lanes[rightIndex]; + const leftMapping = mappingById.get(left.mappingId); + const rightMapping = mappingById.get(right.mappingId); + if (!leftMapping || !rightMapping || leftMapping.repository !== rightMapping.repository) continue; + const overlap = firstOverlap(left.ownedSurfaces, right.ownedSurfaces); + if (!overlap) continue; + const message = `Stories ${left.storyId} and ${right.storyId} overlap in ${leftMapping.repository}: ${overlap[0]} <> ${overlap[1]}.`; + reasonsByLane.get(left.id).push(reason(REASON_CODES.SURFACE_OVERLAP, message)); + reasonsByLane.get(right.id).push(reason(REASON_CODES.SURFACE_OVERLAP, message)); + } + } + + const lanes = manifest.lanes.map((lane) => { + const reasons = stableReasons([...waveReasons, ...reasonsByLane.get(lane.id)]); + return { + id: lane.id, + storyId: lane.storyId, + decision: reasons.length === 0 ? "accepted" : "rejected", + reasons + }; + }); + 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 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" || value === "" || value.includes("\\")) return false; + if (value.startsWith("/") || value.endsWith("/") || /[\0\r\n]/.test(value)) return false; + const plain = value.endsWith("/**") ? value.slice(0, -3) : value; + if (plain === "" || plain.startsWith("./") || plain.includes("//")) return false; + return plain.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} + +function firstOverlap(leftSurfaces, rightSurfaces) { + for (const left of leftSurfaces) { + if (!safeSurface(left)) continue; + for (const right of rightSurfaces) { + if (!safeSurface(right)) continue; + if (surfacesOverlap(left, right)) return [left, right]; + } + } + return null; +} + +function surfacesOverlap(left, right) { + const leftPrefix = left.endsWith("/**"); + const rightPrefix = right.endsWith("/**"); + const leftPath = leftPrefix ? left.slice(0, -3) : left; + const rightPath = rightPrefix ? right.slice(0, -3) : right; + if (leftPath === rightPath) return true; + if (leftPrefix && rightPath.startsWith(`${leftPath}/`)) return true; + if (rightPrefix && leftPath.startsWith(`${rightPath}/`)) return true; + return false; +} + +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).sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + throw new Error(`${path} must contain exactly: ${wanted.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) { + if (typeof value !== "string" || value === "" || value.length > maximum || /[\0\r\n]/.test(value)) { + 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); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) || !Number.isFinite(Date.parse(value))) { + 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..0477c25 --- /dev/null +++ b/scripts/tabellio-wave-admit.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +import {readFile} 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 = process.cwd(); + const manifestPath = 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]}; +} + +function containedPath(root, input, label) { + if (isAbsolute(input)) throw new Error(`${label} must be repository-relative.`); + const target = resolve(root, input); + const rel = relative(root, target); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + 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/wave-admission.test.mjs b/tests/wave-admission.test.mjs new file mode 100644 index 0000000..2c83994 --- /dev/null +++ b/tests/wave-admission.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import {execFile} from "node:child_process"; +import {readFile} from "node:fs/promises"; +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("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") + ); +}); + +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)); + } + ); +}); From ca6fa19632588496e3d9d564605d6e3e12b46a7a Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 11:53:09 -0700 Subject: [PATCH 02/15] fix: satisfy changed-code quality gate Entire-Checkpoint: ea0ff36384d2 --- scripts/lib/wave-admission.mjs | 304 ++++++++++++++++++++------------ scripts/tabellio-wave-admit.mjs | 5 +- 2 files changed, 192 insertions(+), 117 deletions(-) diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs index 5adc7de..c670df8 100644 --- a/scripts/lib/wave-admission.mjs +++ b/scripts/lib/wave-admission.mjs @@ -39,107 +39,164 @@ 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 = []; - - duplicateValues(manifest.mappings.map((mapping) => mapping.id)).forEach((id) => - waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Mapping id ${id} is duplicated.`)) - ); - duplicateValues(manifest.mappings.map((mapping) => mapping.taskId)).forEach((taskId) => - waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Task id ${taskId} is duplicated.`)) - ); - duplicateValues(manifest.lanes.map((lane) => lane.id)).forEach((id) => - waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Lane id ${id} is duplicated.`)) + const waveReasons = [ + ...duplicateIdentityReasons(manifest), + ...integratorReasons(manifest, mappingById) + ]; + applyWipReasons(manifest, reasonsByLane); + applyLaneReasons(manifest, mappingById, reasonsByLane); + applyOverlapReasons(manifest, mappingById, reasonsByLane); + const lanes = manifest.lanes.map((lane) => + laneDecision(lane, waveReasons, reasonsByLane.get(lane.id)) ); - duplicateValues(manifest.lanes.map((lane) => lane.storyId)).forEach((storyId) => - waveReasons.push(reason(REASON_CODES.DUPLICATE_IDENTITY, `Story id ${storyId} is duplicated.`)) + 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.`) + ) ); +} - const integratorMapping = mappingById.get(manifest.finalIntegrator.mappingId); - if (!integratorMapping || manifest.finalIntegrator.owner.trim() === "") { - waveReasons.push(reason( +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) { - 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)); - } + 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) { for (const lane of manifest.lanes) { - const laneReasons = reasonsByLane.get(lane.id); - if (!mappingById.has(lane.mappingId)) { - laneReasons.push(reason( - REASON_CODES.MAPPING_MISSING, - `Lane ${lane.id} does not resolve to a declared Plane/repository/task mapping.` - )); - } - if (lane.state !== "Ready") { - laneReasons.push(reason( - REASON_CODES.LANE_NOT_READY, - `Story ${lane.storyId} is ${lane.state}; Ready is required.` - )); - } - if (lane.baseCommit !== lane.observedBaseCommit) { - laneReasons.push(reason( - REASON_CODES.STALE_BASE, - `Story ${lane.storyId} base commit differs from current repository evidence.` - )); - } - for (const dependency of lane.dependencies) { - if (dependency.status !== "completed") { - laneReasons.push(reason( - REASON_CODES.DEPENDENCY_INCOMPLETE, - `Story ${lane.storyId} dependency ${dependency.storyId} is incomplete.` - )); - } - } - for (const surface of lane.ownedSurfaces) { - if (!safeSurface(surface)) { - laneReasons.push(reason( - REASON_CODES.SURFACE_INVALID, - `Story ${lane.storyId} owned surface is not a safe repository-relative pattern.` - )); - } - } + reasonsByLane.get(lane.id).push(...laneReasons(lane, mappingById)); + } +} + +function laneReasons(lane, mappingById) { + return [ + ...mappingReasons(lane, mappingById), + ...readinessReasons(lane), + ...baseReasons(lane), + ...dependencyReasons(lane), + ...surfaceReasons(lane) + ]; +} + +function mappingReasons(lane, mappingById) { + return mappingById.has(lane.mappingId) + ? [] + : [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) { + return lane.dependencies + .filter((dependency) => dependency.status !== "completed") + .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 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)); } +} - for (let leftIndex = 0; leftIndex < manifest.lanes.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < manifest.lanes.length; rightIndex += 1) { - const left = manifest.lanes[leftIndex]; - const right = manifest.lanes[rightIndex]; - const leftMapping = mappingById.get(left.mappingId); - const rightMapping = mappingById.get(right.mappingId); - if (!leftMapping || !rightMapping || leftMapping.repository !== rightMapping.repository) continue; - const overlap = firstOverlap(left.ownedSurfaces, right.ownedSurfaces); - if (!overlap) continue; - const message = `Stories ${left.storyId} and ${right.storyId} overlap in ${leftMapping.repository}: ${overlap[0]} <> ${overlap[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; +} - const lanes = manifest.lanes.map((lane) => { - const reasons = stableReasons([...waveReasons, ...reasonsByLane.get(lane.id)]); - return { - id: lane.id, - storyId: lane.storyId, - decision: reasons.length === 0 ? "accepted" : "rejected", - reasons - }; - }); - const accepted = lanes.filter((lane) => lane.decision === "accepted").length; - const rejected = lanes.length - accepted; +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; + return leftMapping.repository === rightMapping.repository ? leftMapping.repository : null; +} + +function laneDecision(lane, waveReasons, laneSpecificReasons) { + const reasons = stableReasons([...waveReasons, ...laneSpecificReasons]); 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 + id: lane.id, + storyId: lane.storyId, + decision: reasons.length === 0 ? "accepted" : "rejected", + reasons }; } @@ -199,33 +256,52 @@ function validateLane(value, index) { } function safeSurface(value) { - if (typeof value !== "string" || value === "" || value.includes("\\")) return false; - if (value.startsWith("/") || value.endsWith("/") || /[\0\r\n]/.test(value)) return false; - const plain = value.endsWith("/**") ? value.slice(0, -3) : value; - if (plain === "" || plain.startsWith("./") || plain.includes("//")) return false; - return plain.split("/").every((part) => part !== "" && part !== "." && part !== ".."); + 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("//")]; + 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) { - for (const left of leftSurfaces) { - if (!safeSurface(left)) continue; - for (const right of rightSurfaces) { - if (!safeSurface(right)) continue; - if (surfacesOverlap(left, right)) return [left, right]; - } - } - return null; + 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 leftPrefix = left.endsWith("/**"); - const rightPrefix = right.endsWith("/**"); - const leftPath = leftPrefix ? left.slice(0, -3) : left; - const rightPath = rightPrefix ? right.slice(0, -3) : right; - if (leftPath === rightPath) return true; - if (leftPrefix && rightPath.startsWith(`${leftPath}/`)) return true; - if (rightPrefix && leftPath.startsWith(`${rightPath}/`)) return true; - return false; + 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) { @@ -247,11 +323,11 @@ function duplicateValues(values) { } function exactKeys(value, expected, path) { - const actual = Object.keys(value).sort(); - const wanted = [...expected].sort(); - if (JSON.stringify(actual) !== JSON.stringify(wanted)) { - throw new Error(`${path} must contain exactly: ${wanted.join(", ")}.`); - } + 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) { @@ -267,9 +343,9 @@ function array(value, path, minimum, maximum) { } function string(value, path, maximum) { - if (typeof value !== "string" || value === "" || value.length > maximum || /[\0\r\n]/.test(value)) { - throw new Error(`${path} must be a non-empty single-line string up to ${maximum} characters.`); - } + 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) { diff --git a/scripts/tabellio-wave-admit.mjs b/scripts/tabellio-wave-admit.mjs index 0477c25..84a43b7 100644 --- a/scripts/tabellio-wave-admit.mjs +++ b/scripts/tabellio-wave-admit.mjs @@ -29,8 +29,7 @@ function containedPath(root, input, label) { if (isAbsolute(input)) throw new Error(`${label} must be repository-relative.`); const target = resolve(root, input); const rel = relative(root, target); - if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { - throw new Error(`${label} must stay inside the repository.`); - } + const escapesRepository = [rel === "", rel.startsWith(".."), isAbsolute(rel)].includes(true); + if (escapesRepository) throw new Error(`${label} must stay inside the repository.`); return target; } From 31f5abb0c3aeeebc2ee44b2a1a6f15a5f2802fe4 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 11:59:43 -0700 Subject: [PATCH 03/15] fix: evaluate Buildkite preflight safely Entire-Checkpoint: ea0ff36384d2 --- .buildkite/pipeline.yml | 10 +++++----- tests/buildkite-high-gates.test.mjs | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 1ba51ab..869352a 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -9,7 +9,7 @@ steps: - label: ":git: Git 2.50.1" 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 + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null timeout_in_minutes: 15 artifact_paths: - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" @@ -18,7 +18,7 @@ steps: 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 + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null timeout_in_minutes: 10 artifact_paths: - "tabellio-pr-evidence.json" @@ -30,7 +30,7 @@ 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 + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null timeout_in_minutes: 10 artifact_paths: - "fallow-audit.json" @@ -41,7 +41,7 @@ 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 + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null timeout_in_minutes: 10 artifact_paths: - "package-dry-run.json" @@ -53,7 +53,7 @@ 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 + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 954ff33..e6704f1 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -24,6 +24,12 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy pipeline.match(/build\.pull_request\.id != null/g)?.length, 5, ); + assert.equal( + pipeline.match( + /if: build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight" \|\| build\.branch == pipeline\.default_branch \|\| build\.pull_request\.id != null/g, + )?.length, + 5, + ); assert.match( pipeline, /build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight"/, From bb56688ec3924c662ec076cc1a1ab26320bb4254 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 12:02:24 -0700 Subject: [PATCH 04/15] fix: avoid nullable Buildkite PR dereference Entire-Checkpoint: ea0ff36384d2 --- .buildkite/pipeline.yml | 10 +++++----- tests/buildkite-high-gates.test.mjs | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 869352a..0fd926b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -9,7 +9,7 @@ steps: - label: ":git: Git 2.50.1" key: "git-toolchain" command: "bash .buildkite/scripts/build-modern-git.sh" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null timeout_in_minutes: 15 artifact_paths: - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" @@ -18,7 +18,7 @@ steps: key: "repository-check" command: "bash .buildkite/scripts/tests.sh" depends_on: "git-toolchain" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null timeout_in_minutes: 10 artifact_paths: - "tabellio-pr-evidence.json" @@ -30,7 +30,7 @@ steps: key: "fallow" command: "bash .buildkite/scripts/fallow.sh" depends_on: "git-toolchain" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null timeout_in_minutes: 10 artifact_paths: - "fallow-audit.json" @@ -41,7 +41,7 @@ steps: - label: ":package: Package dry-run" key: "package" command: "bash .buildkite/scripts/package.sh" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null timeout_in_minutes: 10 artifact_paths: - "package-dry-run.json" @@ -53,7 +53,7 @@ steps: key: "product-validation" command: "bash .buildkite/scripts/product-validation.sh" depends_on: "git-toolchain" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request.id != null + if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index e6704f1..9d70ad9 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -21,12 +21,13 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.match(pipeline, /key: "product-validation"/); assert.doesNotMatch(pipeline, /BUILDKITE_GITHUB_EVENT/); assert.equal( - pipeline.match(/build\.pull_request\.id != null/g)?.length, + pipeline.match(/build\.pull_request != null/g)?.length, 5, ); + assert.doesNotMatch(pipeline, /build\.pull_request\.id/); assert.equal( pipeline.match( - /if: build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight" \|\| build\.branch == pipeline\.default_branch \|\| build\.pull_request\.id != null/g, + /if: build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight" \|\| build\.branch == pipeline\.default_branch \|\| build\.pull_request != null/g, )?.length, 5, ); From ccb0ddded3d9e0335e4bd0093e6981319a636776 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 12:06:00 -0700 Subject: [PATCH 05/15] revert: preserve Buildkite PR condition contract Entire-Checkpoint: ea0ff36384d2 --- .buildkite/pipeline.yml | 10 +++++----- tests/buildkite-high-gates.test.mjs | 9 +-------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 0fd926b..1ba51ab 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -9,7 +9,7 @@ steps: - label: ":git: Git 2.50.1" key: "git-toolchain" command: "bash .buildkite/scripts/build-modern-git.sh" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null + if: build.pull_request.id != null || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 15 artifact_paths: - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" @@ -18,7 +18,7 @@ steps: key: "repository-check" command: "bash .buildkite/scripts/tests.sh" depends_on: "git-toolchain" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null + 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 +30,7 @@ steps: key: "fallow" command: "bash .buildkite/scripts/fallow.sh" depends_on: "git-toolchain" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null + 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 +41,7 @@ steps: - label: ":package: Package dry-run" key: "package" command: "bash .buildkite/scripts/package.sh" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null + 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 +53,7 @@ steps: key: "product-validation" command: "bash .buildkite/scripts/product-validation.sh" depends_on: "git-toolchain" - if: build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch || build.pull_request != null + 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/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 9d70ad9..954ff33 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -21,14 +21,7 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.match(pipeline, /key: "product-validation"/); assert.doesNotMatch(pipeline, /BUILDKITE_GITHUB_EVENT/); assert.equal( - pipeline.match(/build\.pull_request != null/g)?.length, - 5, - ); - assert.doesNotMatch(pipeline, /build\.pull_request\.id/); - assert.equal( - pipeline.match( - /if: build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight" \|\| build\.branch == pipeline\.default_branch \|\| build\.pull_request != null/g, - )?.length, + pipeline.match(/build\.pull_request\.id != null/g)?.length, 5, ); assert.match( From f21e118135141db630d52c09e104f822da127b0a Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:01:33 -0700 Subject: [PATCH 06/15] ci: retrigger PR-context Buildkite validation Entire-Checkpoint: 08db4ce9394c From eee26c1979b2423963a29db9cf59a6b500efd82c Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:06:21 -0700 Subject: [PATCH 07/15] fix: admit Buildkite PR-context jobs Entire-Checkpoint: 08db4ce9394c --- .buildkite/pipeline.yml | 10 +++++----- tests/buildkite-high-gates.test.mjs | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 1ba51ab..5b7edad 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -9,7 +9,7 @@ steps: - label: ":git: Git 2.50.1" 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 + if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 15 artifact_paths: - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" @@ -18,7 +18,7 @@ steps: 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 + if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "tabellio-pr-evidence.json" @@ -30,7 +30,7 @@ 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 + if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "fallow-audit.json" @@ -41,7 +41,7 @@ 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 + if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "package-dry-run.json" @@ -53,7 +53,7 @@ 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 + if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 954ff33..450dd0c 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -21,9 +21,12 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.match(pipeline, /key: "product-validation"/); assert.doesNotMatch(pipeline, /BUILDKITE_GITHUB_EVENT/); assert.equal( - pipeline.match(/build\.pull_request\.id != null/g)?.length, + pipeline.match( + /build\.env\("BUILDKITE_PULL_REQUEST"\) != null && build\.env\("BUILDKITE_PULL_REQUEST"\) != "false"/g, + )?.length, 5, ); + assert.doesNotMatch(pipeline, /build\.pull_request\.id/); assert.match( pipeline, /build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight"/, From 7426b3e877fc9fcc3692164eff4c6ee0ce6167dd Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:09:35 -0700 Subject: [PATCH 08/15] fix: honor provider-admitted webhook builds Entire-Checkpoint: 08db4ce9394c --- .buildkite/pipeline.yml | 10 +++++----- tests/buildkite-high-gates.test.mjs | 5 ++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 5b7edad..1328dbf 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -9,7 +9,7 @@ steps: - label: ":git: Git 2.50.1" key: "git-toolchain" command: "bash .buildkite/scripts/build-modern-git.sh" - if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch + if: build.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 15 artifact_paths: - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" @@ -18,7 +18,7 @@ steps: key: "repository-check" command: "bash .buildkite/scripts/tests.sh" depends_on: "git-toolchain" - if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch + if: build.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "tabellio-pr-evidence.json" @@ -30,7 +30,7 @@ steps: key: "fallow" command: "bash .buildkite/scripts/fallow.sh" depends_on: "git-toolchain" - if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch + if: build.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "fallow-audit.json" @@ -41,7 +41,7 @@ steps: - label: ":package: Package dry-run" key: "package" command: "bash .buildkite/scripts/package.sh" - if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch + if: build.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 10 artifact_paths: - "package-dry-run.json" @@ -53,7 +53,7 @@ steps: key: "product-validation" command: "bash .buildkite/scripts/product-validation.sh" depends_on: "git-toolchain" - if: (build.env("BUILDKITE_PULL_REQUEST") != null && build.env("BUILDKITE_PULL_REQUEST") != "false") || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch + if: build.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 450dd0c..72e054d 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -21,12 +21,11 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.match(pipeline, /key: "product-validation"/); assert.doesNotMatch(pipeline, /BUILDKITE_GITHUB_EVENT/); assert.equal( - pipeline.match( - /build\.env\("BUILDKITE_PULL_REQUEST"\) != null && build\.env\("BUILDKITE_PULL_REQUEST"\) != "false"/g, - )?.length, + pipeline.match(/build\.source == "webhook"/g)?.length, 5, ); assert.doesNotMatch(pipeline, /build\.pull_request\.id/); + assert.doesNotMatch(pipeline, /build\.env\("BUILDKITE_PULL_REQUEST"\)/); assert.match( pipeline, /build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight"/, From 57d9debdface03bbd07bba61912e13dfb9f9c631 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:12:55 -0700 Subject: [PATCH 09/15] fix: delegate Buildkite admission to provider Entire-Checkpoint: 08db4ce9394c --- .buildkite/pipeline.yml | 5 ----- tests/buildkite-high-gates.test.mjs | 10 +--------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 1328dbf..e03b79a 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -9,7 +9,6 @@ steps: - label: ":git: Git 2.50.1" key: "git-toolchain" command: "bash .buildkite/scripts/build-modern-git.sh" - if: build.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 15 artifact_paths: - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" @@ -18,7 +17,6 @@ steps: key: "repository-check" command: "bash .buildkite/scripts/tests.sh" depends_on: "git-toolchain" - if: build.source == "webhook" || 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.source == "webhook" || 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.source == "webhook" || 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.source == "webhook" || build.env("TABELLIO_BUILD_CONTEXT") == "preflight" || build.branch == pipeline.default_branch timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 72e054d..1f2c845 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -20,17 +20,9 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.match(pipeline, /key: "package"/); assert.match(pipeline, /key: "product-validation"/); assert.doesNotMatch(pipeline, /BUILDKITE_GITHUB_EVENT/); - assert.equal( - pipeline.match(/build\.source == "webhook"/g)?.length, - 5, - ); assert.doesNotMatch(pipeline, /build\.pull_request\.id/); assert.doesNotMatch(pipeline, /build\.env\("BUILDKITE_PULL_REQUEST"\)/); - assert.match( - pipeline, - /build\.env\("TABELLIO_BUILD_CONTEXT"\) == "preflight"/, - ); - assert.match(pipeline, /build\.branch == pipeline\.default_branch/); + assert.doesNotMatch(pipeline, /^\s+if:/m); assert.doesNotMatch(productValidation, /git show -s --format=%s/); assertMatches(productValidation, [ From f2eab5d3df329eb7683ae10d50f72c4d00d1c781 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:21:52 -0700 Subject: [PATCH 10/15] fix: harden wave admission identity checks Entire-Checkpoint: 08db4ce9394c --- schemas/wave-manifest.v0.1.schema.json | 6 ++- scripts/lib/wave-admission.mjs | 55 ++++++++++++++++++---- scripts/tabellio-wave-admit.mjs | 14 ++++-- tests/wave-admission.test.mjs | 64 +++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/schemas/wave-manifest.v0.1.schema.json b/schemas/wave-manifest.v0.1.schema.json index ed066a2..d0fc5b2 100644 --- a/schemas/wave-manifest.v0.1.schema.json +++ b/schemas/wave-manifest.v0.1.schema.json @@ -8,7 +8,11 @@ "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"}, + "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, diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs index c670df8..76ed030 100644 --- a/scripts/lib/wave-admission.mjs +++ b/scripts/lib/wave-admission.mjs @@ -45,6 +45,7 @@ export function admitWave(value) { ]; 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)) @@ -95,23 +96,26 @@ function applyWipReasons(manifest, reasonsByLane) { } 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)); + reasonsByLane.get(lane.id).push(...laneReasons(lane, mappingById, laneByStoryId)); } } -function laneReasons(lane, mappingById) { +function laneReasons(lane, mappingById, laneByStoryId) { return [ ...mappingReasons(lane, mappingById), ...readinessReasons(lane), ...baseReasons(lane), - ...dependencyReasons(lane), + ...dependencyReasons(lane, laneByStoryId), ...surfaceReasons(lane) ]; } function mappingReasons(lane, mappingById) { - return mappingById.has(lane.mappingId) + const mapping = mappingById.get(lane.mappingId); + const storyProject = lane.storyId.split("-", 1)[0]; + return mapping && mapping.planeProject === storyProject ? [] : [reason( REASON_CODES.MAPPING_MISSING, @@ -137,9 +141,14 @@ function baseReasons(lane) { )]; } -function dependencyReasons(lane) { +function dependencyReasons(lane, laneByStoryId) { return lane.dependencies - .filter((dependency) => dependency.status !== "completed") + .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.` @@ -155,6 +164,25 @@ function surfaceReasons(lane) { )); } +function applyRepositoryBaseReasons(manifest, mappingById, reasonsByLane) { + const lanesByRepository = new Map(); + for (const lane of manifest.lanes) { + const mapping = mappingById.get(lane.mappingId); + if (!mapping) continue; + const repository = canonicalRepository(mapping.repository); + const lanes = lanesByRepository.get(repository) ?? []; + lanes.push(lane); + lanesByRepository.set(repository, lanes); + } + 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 applyOverlapReasons(manifest, mappingById, reasonsByLane) { for (const [left, right] of lanePairs(manifest.lanes)) { const overlap = laneOverlap(left, right, mappingById); @@ -187,7 +215,13 @@ function laneOverlap(left, right, mappingById) { function sharedRepository(leftMapping, rightMapping) { if (!leftMapping) return null; if (!rightMapping) return null; - return leftMapping.repository === rightMapping.repository ? leftMapping.repository : 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) { @@ -266,7 +300,12 @@ function safeSurface(value) { ]; if (invalidShape.includes(true)) return false; const plain = stripSurfaceGlob(value); - const invalidPlain = [plain === "", plain.startsWith("./"), plain.includes("//")]; + const invalidPlain = [ + plain === "", + plain.startsWith("./"), + plain.includes("//"), + /[*?[\]{}]/.test(plain) + ]; if (invalidPlain.includes(true)) return false; return plain.split("/").every((part) => !["", ".", ".."].includes(part)); } diff --git a/scripts/tabellio-wave-admit.mjs b/scripts/tabellio-wave-admit.mjs index 84a43b7..7ac4fae 100644 --- a/scripts/tabellio-wave-admit.mjs +++ b/scripts/tabellio-wave-admit.mjs @@ -1,14 +1,14 @@ #!/usr/bin/env node -import {readFile} from "node:fs/promises"; +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 = process.cwd(); - const manifestPath = containedPath(root, options.manifest, "manifest"); + 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`); @@ -25,9 +25,13 @@ function parseOptions(args) { return {manifest: args[1]}; } -function containedPath(root, input, label) { +async function containedPath(root, input, label) { if (isAbsolute(input)) throw new Error(`${label} must be repository-relative.`); - const target = resolve(root, input); + 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.`); diff --git a/tests/wave-admission.test.mjs b/tests/wave-admission.test.mjs index 2c83994..4886162 100644 --- a/tests/wave-admission.test.mjs +++ b/tests/wave-admission.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import {execFile} from "node:child_process"; -import {readFile} from "node:fs/promises"; +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"; @@ -67,6 +69,46 @@ test("negative matrix rejects non-Ready, WIP, missing mapping, stale base, and a ); }); +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("security rejects unsafe owned surfaces and repository-external CLI inputs", async () => { const manifest = await fixture("accepted-three-repository.json"); manifest.lanes[0].ownedSurfaces = ["../secrets"]; @@ -76,6 +118,26 @@ test("security rejects unsafe owned surfaces and repository-external CLI inputs" 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 () => { From 348706e54ff92e1e77f63acb8cc9f90c66d20cc4 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:26:13 -0700 Subject: [PATCH 11/15] refactor: simplify repository base checks --- scripts/lib/wave-admission.mjs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs index 76ed030..852fb7e 100644 --- a/scripts/lib/wave-admission.mjs +++ b/scripts/lib/wave-admission.mjs @@ -165,15 +165,7 @@ function surfaceReasons(lane) { } function applyRepositoryBaseReasons(manifest, mappingById, reasonsByLane) { - const lanesByRepository = new Map(); - for (const lane of manifest.lanes) { - const mapping = mappingById.get(lane.mappingId); - if (!mapping) continue; - const repository = canonicalRepository(mapping.repository); - const lanes = lanesByRepository.get(repository) ?? []; - lanes.push(lane); - lanesByRepository.set(repository, lanes); - } + 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.`; @@ -183,6 +175,19 @@ function applyRepositoryBaseReasons(manifest, mappingById, reasonsByLane) { } } +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); From 71cca4a26c17d75d9a29a1eea11aa4976b39fccb Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:47:31 -0700 Subject: [PATCH 12/15] ci: route portable gates to macOS M4 --- .buildkite/README.md | 5 ++ .buildkite/pipeline.yml | 8 +-- .buildkite/scripts/build-modern-git.sh | 74 ---------------------- .buildkite/scripts/fallow.sh | 2 +- .buildkite/scripts/product-validation.sh | 2 +- .buildkite/scripts/tests.sh | 2 +- .buildkite/scripts/use-modern-git.sh | 17 ----- .buildkite/scripts/verify-git-toolchain.sh | 12 ++++ tests/buildkite-high-gates.test.mjs | 27 ++++---- 9 files changed, 37 insertions(+), 112 deletions(-) delete mode 100644 .buildkite/scripts/build-modern-git.sh delete mode 100644 .buildkite/scripts/use-modern-git.sh create mode 100755 .buildkite/scripts/verify-git-toolchain.sh diff --git a/.buildkite/README.md b/.buildkite/README.md index cd98988..d5cf83d 100644 --- a/.buildkite/README.md +++ b/.buildkite/README.md @@ -3,6 +3,11 @@ 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 the hosted image supplies the repository-pinned Git +version; 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 e03b79a..d103cbf 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -3,15 +3,13 @@ env: NO_COLOR: "1" agents: - queue: "linux-small" + queue: "macos-medium" steps: - label: ":git: Git 2.50.1" key: "git-toolchain" - command: "bash .buildkite/scripts/build-modern-git.sh" - timeout_in_minutes: 15 - artifact_paths: - - ".artifacts/toolchain/git-2.50.1-linux-amd64.tar.gz" + command: "bash .buildkite/scripts/verify-git-toolchain.sh" + timeout_in_minutes: 2 - label: ":test_tube: Repository check" key: "repository-check" 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..1551b36 100755 --- a/.buildkite/scripts/product-validation.sh +++ b/.buildkite/scripts/product-validation.sh @@ -14,7 +14,7 @@ if [[ "$pull_request" == "false" && "$build_context" != "preflight" && "$default exit 2 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..7532326 --- /dev/null +++ b/.buildkite/scripts/verify-git-toolchain.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +required_version="2.50.1" +actual_version="$(git version | awk '{ print $3 }')" + +if [[ "$actual_version" != "$required_version" ]]; then + printf 'Tabellio CI requires Git %s; found %s.\n' "$required_version" "$actual_version" >&2 + exit 1 +fi + +git version diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 1f2c845..7c5f7e0 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -7,18 +7,23 @@ async function repositoryFile(path) { } 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, /^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.doesNotMatch(pipeline, /build\.pull_request\.id/); assert.doesNotMatch(pipeline, /build\.env\("BUILDKITE_PULL_REQUEST"\)/); @@ -52,19 +57,15 @@ 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, /required_version="2\.50\.1"/); + assert.match(gitToolchain, /actual_version="\$\(git version \| awk/); + assert.match(gitToolchain, /actual_version.*required_version/); + assert.doesNotMatch(gitToolchain, /apt-get|dpkg-query|linux-amd64|uname/); }); function assertMatches(value, patterns) { From 0324da2ca1e39792c81b139439156069f041ae24 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 17:56:56 -0700 Subject: [PATCH 13/15] ci: validate supported Git capabilities --- .buildkite/README.md | 6 ++- .buildkite/pipeline.yml | 4 +- .buildkite/scripts/product-validation.sh | 4 +- .buildkite/scripts/verify-git-toolchain.sh | 56 +++++++++++++++++-- schemas/wave-manifest.v0.1.schema.json | 16 +++--- scripts/lib/wave-admission.mjs | 11 +++- tests/buildkite-high-gates.test.mjs | 62 +++++++++++++++++++--- tests/wave-admission.test.mjs | 20 +++++++ 8 files changed, 155 insertions(+), 24 deletions(-) diff --git a/.buildkite/README.md b/.buildkite/README.md index d5cf83d..5d26c15 100644 --- a/.buildkite/README.md +++ b/.buildkite/README.md @@ -4,8 +4,10 @@ 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 the hosted image supplies the repository-pinned Git -version; CI does not build or download an architecture-specific Git artifact. +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 diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index d103cbf..4a2979b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -6,10 +6,12 @@ agents: queue: "macos-medium" steps: - - label: ":git: Git 2.50.1" + - label: ":git: Supported Git" key: "git-toolchain" command: "bash .buildkite/scripts/verify-git-toolchain.sh" timeout_in_minutes: 2 + artifact_paths: + - "tabellio-git-toolchain.json" - label: ":test_tube: Repository check" key: "repository-check" diff --git a/.buildkite/scripts/product-validation.sh b/.buildkite/scripts/product-validation.sh index 1551b36..f8e4cf1 100755 --- a/.buildkite/scripts/product-validation.sh +++ b/.buildkite/scripts/product-validation.sh @@ -10,8 +10,8 @@ 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/verify-git-toolchain.sh diff --git a/.buildkite/scripts/verify-git-toolchain.sh b/.buildkite/scripts/verify-git-toolchain.sh index 7532326..de1217c 100755 --- a/.buildkite/scripts/verify-git-toolchain.sh +++ b/.buildkite/scripts/verify-git-toolchain.sh @@ -1,12 +1,60 @@ #!/usr/bin/env bash set -euo pipefail -required_version="2.50.1" +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 -if [[ "$actual_version" != "$required_version" ]]; then - printf 'Tabellio CI requires Git %s; found %s.\n' "$required_version" "$actual_version" >&2 +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 -git version +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/schemas/wave-manifest.v0.1.schema.json b/schemas/wave-manifest.v0.1.schema.json index d0fc5b2..1a59d25 100644 --- a/schemas/wave-manifest.v0.1.schema.json +++ b/schemas/wave-manifest.v0.1.schema.json @@ -27,8 +27,8 @@ "additionalProperties": false, "required": ["mappingId", "owner"], "properties": { - "mappingId": {"type": "string", "minLength": 1, "maxLength": 128}, - "owner": {"type": "string", "minLength": 1, "maxLength": 200} + "mappingId": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000\\r\\n]+$"}, + "owner": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000\\r\\n]+$"} } }, "mappings": { @@ -40,10 +40,10 @@ "additionalProperties": false, "required": ["id", "planeProject", "repository", "taskId"], "properties": { - "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "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} + "taskId": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000\\r\\n]+$"} } } }, @@ -56,17 +56,17 @@ "additionalProperties": false, "required": ["id", "mappingId", "storyId", "state", "baseCommit", "observedBaseCommit", "ownedSurfaces", "dependencies", "wipSlots"], "properties": { - "id": {"type": "string", "minLength": 1, "maxLength": 128}, - "mappingId": {"type": "string", "minLength": 1, "maxLength": 128}, + "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}, + "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} + "items": {"type": "string", "minLength": 1, "maxLength": 500, "pattern": "^[^\\u0000\\r\\n]+$"} }, "dependencies": { "type": "array", diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs index 852fb7e..c5ee8a8 100644 --- a/scripts/lib/wave-admission.mjs +++ b/scripts/lib/wave-admission.mjs @@ -408,7 +408,16 @@ function integer(value, path, minimum, maximum) { function dateTime(value, path) { string(value, path, 64); - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) || !Number.isFinite(Date.parse(value))) { + const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value); + const parsed = new Date(value); + const exact = parts && + parsed.getUTCFullYear() === Number(parts[1]) && + parsed.getUTCMonth() + 1 === Number(parts[2]) && + parsed.getUTCDate() === Number(parts[3]) && + parsed.getUTCHours() === Number(parts[4]) && + parsed.getUTCMinutes() === Number(parts[5]) && + parsed.getUTCSeconds() === Number(parts[6]); + if (!Number.isFinite(parsed.getTime()) || !exact) { throw new Error(`${path} must be a UTC RFC 3339 timestamp.`); } } diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 7c5f7e0..a4139d1 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -1,6 +1,12 @@ 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"); @@ -20,6 +26,7 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy 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/); @@ -32,11 +39,12 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy 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/, @@ -62,10 +70,16 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy 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, /required_version="2\.50\.1"/); + 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, /actual_version.*required_version/); - assert.doesNotMatch(gitToolchain, /apt-get|dpkg-query|linux-amd64|uname/); + 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) { @@ -79,3 +93,39 @@ 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("Git capability gate records the actual toolchain and exercised features", async () => { + const directory = await mkdtemp(join(tmpdir(), "tabellio-git-toolchain-")); + const evidence = join(directory, "evidence.json"); + try { + await execFileAsync("bash", [".buildkite/scripts/verify-git-toolchain.sh"], { + 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 index 4886162..c072c81 100644 --- a/tests/wave-admission.test.mjs +++ b/tests/wave-admission.test.mjs @@ -109,6 +109,26 @@ test("schema and runtime both require UTC Z timestamps", async () => { 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 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"]; From 64fc1e505051f1feeb26ed09fc7ba25217172c0e Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 18:06:38 -0700 Subject: [PATCH 14/15] refactor: simplify wave timestamp validation --- scripts/lib/wave-admission.mjs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs index c5ee8a8..e8f44c8 100644 --- a/scripts/lib/wave-admission.mjs +++ b/scripts/lib/wave-admission.mjs @@ -408,16 +408,16 @@ function integer(value, path, minimum, maximum) { function dateTime(value, path) { string(value, path, 64); - const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value); + 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 (canonicalSecond(value) !== inputSecond) invalidDateTime(path); +} + +function canonicalSecond(value) { const parsed = new Date(value); - const exact = parts && - parsed.getUTCFullYear() === Number(parts[1]) && - parsed.getUTCMonth() + 1 === Number(parts[2]) && - parsed.getUTCDate() === Number(parts[3]) && - parsed.getUTCHours() === Number(parts[4]) && - parsed.getUTCMinutes() === Number(parts[5]) && - parsed.getUTCSeconds() === Number(parts[6]); - if (!Number.isFinite(parsed.getTime()) || !exact) { - throw new Error(`${path} must be a UTC RFC 3339 timestamp.`); - } + 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.`); } From ae2503e6469fb2e9faa392665c50ae2874f255bf Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Fri, 31 Jul 2026 18:35:34 -0700 Subject: [PATCH 15/15] Fix exact-head review findings --- .buildkite/scripts/verify-git-toolchain.sh | 10 +++++--- scripts/lib/wave-admission.mjs | 1 + tests/buildkite-high-gates.test.mjs | 29 +++++++++++++++++++--- tests/wave-admission.test.mjs | 10 ++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.buildkite/scripts/verify-git-toolchain.sh b/.buildkite/scripts/verify-git-toolchain.sh index de1217c..8c41e06 100755 --- a/.buildkite/scripts/verify-git-toolchain.sh +++ b/.buildkite/scripts/verify-git-toolchain.sh @@ -41,10 +41,12 @@ 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 +( + 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)" diff --git a/scripts/lib/wave-admission.mjs b/scripts/lib/wave-admission.mjs index e8f44c8..f810d8a 100644 --- a/scripts/lib/wave-admission.mjs +++ b/scripts/lib/wave-admission.mjs @@ -410,6 +410,7 @@ 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); } diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index a4139d1..b580c8d 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -12,6 +12,11 @@ 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, repositoryCheck, fallow, packageCheck, gitToolchain] = await Promise.all([ repositoryFile(".buildkite/pipeline.yml"), @@ -108,11 +113,29 @@ test("Git capability gate accepts the supported range and rejects unsafe bounds" ); }); -test("Git capability gate records the actual toolchain and exercised features", async () => { - const directory = await mkdtemp(join(tmpdir(), "tabellio-git-toolchain-")); +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", [".buildkite/scripts/verify-git-toolchain.sh"], { + await execFileAsync("bash", ["-c", sourceCheck], { cwd: new URL("..", import.meta.url), env: {...process.env, TABELLIO_GIT_EVIDENCE_PATH: evidence} }); diff --git a/tests/wave-admission.test.mjs b/tests/wave-admission.test.mjs index c072c81..a29cc8d 100644 --- a/tests/wave-admission.test.mjs +++ b/tests/wave-admission.test.mjs @@ -119,6 +119,16 @@ test("schema and runtime reject normalized invalid capture dates", async () => { 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(