-
Notifications
You must be signed in to change notification settings - Fork 134
feat(validators): deterministic completion gates — zero-write, build-green, literal deliverables, config/dialect lints #1175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
efaa0e0
feat(validators): nothing-built inverse completion gate
anandgupta42 ef5db19
feat(validators): build-green completion gate
anandgupta42 606bdee
feat(validators): literal-deliverable (spec-name) completion gate
anandgupta42 519b189
feat(validators): incremental-config consistency lint
anandgupta42 9a797a6
feat(validators): dialect-guard lint
anandgupta42 0b46fff
docs: engine-split assessment for the two parse-level completion checks
anandgupta42 0cc027a
fix(validators): scope build-green failure counts correctly
anandgupta42 c85b65d
test(validators): pin the lane's registration list
anandgupta42 a4eb9eb
docs: placement assessment for the five shipped completion-gate valid…
anandgupta42 e3f5048
docs: end-to-end evidence for the five completion-gate validators
anandgupta42 e3a1ffd
fix(validators): close the false positives the completion gates fire …
anandgupta42 7bdd69f
fix(validators): close the review backlog on the deterministic comple…
anandgupta42 20ad4de
fix(validators): keep the two run-results exemption axes independent
anandgupta42 8150eef
fix(validators): second review wave on the completion gates
anandgupta42 81a5dd1
docs(validators): record the dialect-guard branch-semantics gap as a …
anandgupta42 b6818c3
fix(validators): inspect elif arms and bound the is_incremental() match
anandgupta42 314f1c3
fix(validators): close the build-provenance holes the consensus revie…
anandgupta42 f9ba32f
fix(validators): a delivered-but-untouched deliverable must satisfy t…
anandgupta42 7122bb1
fix(validators): address the bot review wave on the consensus fixes
anandgupta42 2145d82
fix(validators): make the unknown-command fallback permissive all the…
anandgupta42 60990f3
fix(validators): second review sweep — under-firing gates and blockin…
anandgupta42 3d2f6e9
fix(validators): third review sweep — close 9 of 11 net-new P1 vacuou…
anandgupta42 631be58
test(validators): adversarial coverage for the dialect-guard packages…
anandgupta42 677b00b
fix(validators): fourth review sweep — close 6 P1/P2s the third-sweep…
anandgupta42 fc64a36
fix(validators): fifth review sweep — close 6 more sibling gaps the b…
anandgupta42 b97c58a
fix(validators): close the round-4 false positive, gate enforcement o…
anandgupta42 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
532 changes: 532 additions & 0 deletions
532
packages/opencode/src/altimate/validators/dbt-build-green.ts
Large diffs are not rendered by default.
Oops, something went wrong.
229 changes: 229 additions & 0 deletions
229
packages/opencode/src/altimate/validators/dbt-deliverable-names.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| // altimate_change start — literal deliverable / spec-name completion gate | ||
| /** | ||
| * Literal-deliverable (spec-name) gate. | ||
| * | ||
| * A recurring, fully deterministic loss mode in evaluation traces: the work is | ||
| * functionally reasonable but shipped under self-chosen names — a prefix | ||
| * added, a plural dropped, a "v2" suffix, or an entirely different noun — and | ||
| * the agent then self-verifies against its own renamed output and reports | ||
| * success. The literal contract in the task document is never re-read. | ||
| * | ||
| * This gate re-reads it. It compares the deliverable names the task states | ||
| * **literally** against the names the project actually defines, and refuses | ||
| * to terminate when a required name is absent. | ||
| * | ||
| * Conservatism is the whole design: | ||
| * - Required names come only from `extractRequiredDeliverables`, which | ||
| * accepts a name solely from an explicit declaration marker, a | ||
| * deliverables section, or a requirement line — and only when it sits in | ||
| * an inline code span and is identifier- or path-shaped. There is no | ||
| * fuzzy matching and no inference. | ||
| * - When no required-names source is discoverable, `appliesTo` returns | ||
| * false and the session is never inspected. Silence, never a guess. | ||
| * - Produced names are the union of the filesystem inventory and every | ||
| * `manifest.json` name/alias, so an aliased relation cannot read as | ||
| * missing. | ||
| * - Comparison is exact (case-insensitive only). A near-miss name is | ||
| * reported as a possible substitute in the hint, never accepted as the | ||
| * deliverable. | ||
| * | ||
| * Deliberately out of scope: required *column* names. Asserting a column | ||
| * exists means resolving `select *`, CTEs and upstream schemas — real SQL | ||
| * analysis, not a filesystem inventory — so it is not attempted here rather | ||
| * than attempted badly. | ||
| */ | ||
|
|
||
| import { promises as fs } from "fs" | ||
| import type { Validator, ValidatorContext, ValidatorResult } from "../../session/validators/types" | ||
| import { | ||
| findDbtProjectRoot, | ||
| findTaskInstructionFiles, | ||
| extractRequiredDeliverables, | ||
| collectProducedNodeNames, | ||
| modelsModifiedSince, | ||
| modelNameFromPath, | ||
| resolveWithinRoot, | ||
| sanitizeForPrompt, | ||
| type RequiredDeliverables, | ||
| } from "./validator-utils" | ||
|
|
||
| /** The task contract for this workspace, when one is discoverable. */ | ||
| interface Contract { | ||
| /** Path of the first document that contributed a deliverable, for display. */ | ||
| taskFile: string | ||
| /** Every document that contributed at least one deliverable. */ | ||
| taskFiles: string[] | ||
| required: RequiredDeliverables | ||
| } | ||
|
|
||
| /** | ||
| * Read the workspace's literal deliverable contract, merged across EVERY | ||
| * task/instruction document that names one. | ||
| * | ||
| * A workspace can carry more than one document with real obligations — a | ||
| * `TASK.md` naming one model and a `REQUIREMENTS.md` naming another — and | ||
| * stopping at the first one that parses silently drops every deliverable in | ||
| * the rest. A session that satisfies only the first document's contract then | ||
| * passes this gate while the remaining required models are entirely absent. | ||
| */ | ||
| async function readContract(cwd: string, dbtRoot: string): Promise<Contract | null> { | ||
| const taskFiles: string[] = [] | ||
| const models: string[] = [] | ||
| const files: string[] = [] | ||
| const modificationModels: string[] = [] | ||
| const modificationFiles: string[] = [] | ||
| const modelSet = new Set<string>() | ||
| const fileSet = new Set<string>() | ||
| const modificationModelSet = new Set<string>() | ||
| const modificationFileSet = new Set<string>() | ||
| let primarySource: RequiredDeliverables["source"] | null = null | ||
| for (const task of await findTaskInstructionFiles(cwd, dbtRoot)) { | ||
| const required = extractRequiredDeliverables(task.content) | ||
| if (!required) continue | ||
| taskFiles.push(task.path) | ||
| if (primarySource === null) primarySource = required.source | ||
| for (const m of required.models) if (!modelSet.has(m)) { modelSet.add(m); models.push(m) } | ||
| for (const f of required.files) if (!fileSet.has(f)) { fileSet.add(f); files.push(f) } | ||
| for (const m of required.modificationModels) { | ||
| if (!modificationModelSet.has(m)) { modificationModelSet.add(m); modificationModels.push(m) } | ||
| } | ||
| for (const f of required.modificationFiles) { | ||
| if (!modificationFileSet.has(f)) { modificationFileSet.add(f); modificationFiles.push(f) } | ||
| } | ||
| } | ||
| if (taskFiles.length === 0) return null | ||
| return { | ||
| taskFile: taskFiles[0]!, | ||
| taskFiles, | ||
| required: { models, files, modificationModels, modificationFiles, source: primarySource! }, | ||
| } | ||
| } | ||
|
|
||
| /** True when `relative` exists under either the dbt project or the workspace. */ | ||
| async function fileExists(dbtRoot: string, cwd: string, relative: string): Promise<boolean> { | ||
| for (const root of new Set([dbtRoot, cwd])) { | ||
| // Refuse to resolve outside `root` — see `resolveWithinRoot`. A required | ||
| // path is task-document content and can contain `..` segments. | ||
| const safePath = await resolveWithinRoot(root, relative) | ||
| if (!safePath) continue | ||
| try { | ||
| const stat = await fs.stat(safePath) | ||
| if (stat.isFile()) return true | ||
| } catch { | ||
| // keep looking | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| export const DbtDeliverableNamesValidator: Validator = { | ||
| name: "dbt-deliverable-names", | ||
| description: | ||
| "After the agent declares done, compares the deliverable names the task document states literally against the model, seed and snapshot names the project actually defines, and refuses to terminate when a required name is absent — catching renames and self-chosen substitutes.", | ||
|
|
||
| async appliesTo(ctx: ValidatorContext): Promise<boolean> { | ||
| const dbtRoot = await findDbtProjectRoot(ctx.workingDirectory) | ||
| if (!dbtRoot) return false | ||
| return (await readContract(ctx.workingDirectory, dbtRoot)) !== null | ||
| }, | ||
|
|
||
| async check(ctx: ValidatorContext): Promise<ValidatorResult> { | ||
| const startedAt = Date.now() | ||
| const dbtRoot = await findDbtProjectRoot(ctx.workingDirectory) | ||
| if (!dbtRoot) { | ||
| return { ok: true, details: { skipped: "no dbt project", session_id: ctx.sessionID } } | ||
| } | ||
| const contract = await readContract(ctx.workingDirectory, dbtRoot) | ||
| if (!contract) { | ||
| return { ok: true, details: { skipped: "no literal contract", session_id: ctx.sessionID } } | ||
| } | ||
|
|
||
| const produced = await collectProducedNodeNames(dbtRoot) | ||
|
anandgupta42 marked this conversation as resolved.
|
||
| const missingModels = contract.required.models.filter((name) => !produced.has(name)) | ||
| const missingFiles: string[] = [] | ||
| for (const relative of contract.required.files) { | ||
| if (!(await fileExists(dbtRoot, ctx.workingDirectory, relative))) missingFiles.push(relative) | ||
| } | ||
|
|
||
| const details = { | ||
| task_file: contract.taskFile, | ||
| task_files: contract.taskFiles, | ||
| required_source: contract.required.source, | ||
| required_models: contract.required.models, | ||
| required_files: contract.required.files, | ||
| produced_count: produced.size, | ||
| missing_models: missingModels, | ||
| missing_files: missingFiles, | ||
| dbt_root: dbtRoot, | ||
| session_id: ctx.sessionID, | ||
| elapsed_ms: Date.now() - startedAt, | ||
| } | ||
|
|
||
| if (missingModels.length === 0 && missingFiles.length === 0) { | ||
| return { ok: true, details } | ||
| } | ||
|
|
||
| // Names this session authored that the task did not ask for. These are the | ||
| // likely substitutes behind a missing required name; reported as context, | ||
| // never asserted as equivalent. | ||
| const requiredSet = new Set(contract.required.models) | ||
| const authored = await modelsModifiedSince(dbtRoot, ctx.sessionStartMs) | ||
| const unrequested = Array.from( | ||
| new Set( | ||
| authored | ||
| .map((p) => modelNameFromPath(p).toLowerCase()) | ||
| .filter((name) => name.length > 0 && !requiredSet.has(name)), | ||
| ), | ||
| ) | ||
|
|
||
| const reasonParts: string[] = [] | ||
| if (missingModels.length > 0) { | ||
| reasonParts.push( | ||
| `the task names ${missingModels.length} deliverable(s) this project does not define: ${missingModels.join(", ")}`, | ||
| ) | ||
| } | ||
| if (missingFiles.length > 0) { | ||
| reasonParts.push(`required file(s) missing: ${missingFiles.join(", ")}`) | ||
| } | ||
|
|
||
| // The workspace directory is repository/environment-controlled and can in | ||
| // principle carry adversarial text; `contract.taskFile` is built from it. | ||
| // `dbt-nothing-built` already routes its own task-file path through | ||
| // `sanitizeForPrompt` for this reason — this interpolation is the | ||
| // matching gap in this file. | ||
| const safeTaskFile = sanitizeForPrompt(contract.taskFile, 160) | ||
| const hintLines: string[] = [ | ||
| `The task document (${safeTaskFile}) states these names literally. A model that does the right thing under a different name does not satisfy the task, and self-verification against the renamed output will not detect it.`, | ||
| ] | ||
| if (missingModels.length > 0) { | ||
| hintLines.push(` • Create or rename to exactly: ${missingModels.join(", ")}`) | ||
| } | ||
| if (missingFiles.length > 0) { | ||
| hintLines.push(` • Create at exactly these paths: ${missingFiles.join(", ")}`) | ||
| } | ||
| if (unrequested.length > 0) { | ||
| // `unrequested` is derived from ACTUAL FILENAMES ON DISK, which — unlike | ||
| // `missingModels`/`missingFiles` (constrained to identifier/path-shaped | ||
| // code-span tokens by `extractRequiredDeliverables`) — can contain | ||
| // arbitrary bytes, including newlines, on a POSIX filesystem. Spliced | ||
| // verbatim into `fixHint`, a hostile or merely adversarial filename | ||
| // breaks out of the bullet it is quoted in and lands at instruction | ||
| // position in the synthetic retry turn `prompt.ts` builds from this. | ||
| const safeUnrequested = unrequested.map((n) => sanitizeForPrompt(n, 80)) | ||
| hintLines.push( | ||
| ` • Models you created this session that the task did not name: ${safeUnrequested.join(", ")}. If one of them is a renamed version of a required deliverable, rename the file (and any \`ref()\` to it) back to the required name.`, | ||
| ) | ||
|
anandgupta42 marked this conversation as resolved.
|
||
| } | ||
| hintLines.push( | ||
| " • If a required deliverable is produced under an alias, set `alias` in its config so the required name is the relation name.", | ||
| ) | ||
|
|
||
| return { | ||
| ok: false, | ||
| reason: `Deliverable-name mismatch: ${reasonParts.join("; ")}.`, | ||
| fixHint: hintLines.join("\n"), | ||
| details: { ...details, unrequested_models: unrequested }, | ||
| } | ||
| }, | ||
| } | ||
| // altimate_change end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the session deletes, renames, or edits the discovered task document so it no longer yields a contract, this completion-time lookup makes
appliesToreturn false;dbt-nothing-builtperforms the same live lookup, so both contract gates disappear. A session can therefore removeTASK.md, produce no requested model or build artifact, and terminate successfully because every remaining gate sees zero touched models. Capture the original contract at session start or read it from harness state that the agent cannot mutate.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Real, and deliberately left open rather than half-fixed. Recorded as item 19 in
.github/meta/harness-review-followups.mdindd858ef59fso this reasoning is not re-derived later.The finding is right:
readContractandartifactExpectationboth run at completion time against the live filesystem, so a session that deletes or rewritesTASK.mdmakes both contract gates returnappliesTo === falseand can terminate having produced nothing.The fix you name is the right one — capture the contract at session start, or read it from harness state the agent cannot write — and neither exists in this lane.
ValidatorContextcarriessessionStartMsand nothing else; there is no session-scoped artifact store to put a snapshot in. That is the same missing infrastructure follow-up items 4 and 8 need.The available half-measure is worse than the gap: blocking when a task-document candidate path was written during the session cannot distinguish a deletion from a file that never existed, and fires on the entirely normal case of a task that asks for the document itself to be updated. That converts a false negative into a blocking false positive on correct sessions, which is the wrong direction for a gate that terminates work.
Leaving this open rather than resolving it, since it is not addressed.