Skip to content

Commit 679021b

Browse files
Anand Guptaclaude
authored andcommitted
fix(validators): keep the model required when a column span sits beside it
Independent review showed the first version of the span filter could drop the only model name on a line ("Add a `x` column to `m` model"), which makes the contract read as absent and silences `dbt-deliverable-names` and `dbt-nothing-built`. A span introduced or followed by a deliverable noun is now never dropped, "called" only binds to a column word within the same clause and without an intervening noun, "have/contain/expose" lists drop only after an earlier kept span, the rename target rule applies only to rename lines, and "`x` column names" is no longer read as a column. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent dd0148d commit 679021b

2 files changed

Lines changed: 84 additions & 13 deletions

File tree

‎packages/opencode/src/altimate/validators/validator-utils.ts‎

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ export function extractRequiredDeliverables(text: string): RequiredDeliverables
694694
// required makes the deliverable gate reject the correct implementation
695695
// forever, so a negated verb disqualifies the whole line.
696696
if (verbIsNegated(line, verb.index)) continue
697-
let spans = modelCandidateSpans(requirementHead(line, verb.index))
697+
let spans = modelCandidateSpans(requirementHead(line, verb.index), RENAME_VERB_RE.test(verb[0]))
698698
// "Rename `old_orders` to `new_orders`" names two artifacts, but only the
699699
// destination is required to exist once the rename is done — the source
700700
// is expected to be GONE. `to` is not a qualifier `requirementHead` cuts
@@ -779,15 +779,34 @@ function inlineCodeSpans(line: string): string[] {
779779
* requiring a model by that name blocks a correct implementation forever.
780780
*/
781781
const NON_MODEL_KIND = "columns?|fields?|attributes?|variables?|vars?|macros?|settings?|parameters?"
782-
/** Kind word, then at most a short phrase, then "called"/"named" or a colon, ending the gap before a span. */
783-
const INTRODUCED_AS_NON_MODEL_RE = new RegExp(
784-
`\\b(?:${NON_MODEL_KIND})\\b(?:(?:[^.;:!?]|\\.(?=\\w)){0,60}\\b(?:called|named)|\\s*:|\\s*-|\\s*\\()?\\s*$`,
782+
/**
783+
* Kind word, a short phrase, then "called"/"named"; or a kind word and a colon,
784+
* dash or opening parenthesis — ending the gap before a span. Applied to one
785+
* clause of the gap that holds no deliverable noun, so a later "called" that
786+
* belongs to a model ("a settings model called `app_settings`") does not hand
787+
* its name to the kind word.
788+
*/
789+
const INTRODUCED_CALLED_RE = new RegExp(
790+
`\\b(?:${NON_MODEL_KIND})\\b(?:[^.;:,!?]|\\.(?=\\w)){0,60}?\\b(?:called|named)\\s*$`,
785791
"i",
786792
)
787-
/** "`x` column", "`x` variable": the kind word follows the span. */
788-
const FOLLOWED_BY_NON_MODEL_RE = new RegExp(`^\\s*(?:${NON_MODEL_KIND})\\b`, "i")
789-
/** "should have `a`, `b`": what a model has is its columns. */
790-
const HAS_LIST_RE = /\b(?:have|has|having)\s+(?:the\s+)?(?:following\s+)?(?:(?:new|extra|additional)\s+)?$/i
793+
const INTRODUCED_COLON_RE = new RegExp(`\\b(?:${NON_MODEL_KIND})\\b\\s*[:\\-(]?\\s*$`, "i")
794+
/** A span directly introduced by a deliverable noun ("the model `x`", "table called `x`") is a model. */
795+
const INTRODUCED_AS_MODEL_RE =
796+
/\b(?:models?|tables?|views?|seeds?|snapshots?|marts?|files?)\s*(?:(?:called|named)\s*)?:?\s*$/i
797+
/** ... and so is a span followed by one ("`x` model"). */
798+
const FOLLOWED_BY_MODEL_NOUN_RE = /^\s*(?:models?|tables?|views?|seeds?|snapshots?|files?)\b/i
799+
/**
800+
* "`x` column", "`x` variable": the kind word follows the span and ends the
801+
* noun phrase. "`x` column names" is attributive and does not qualify.
802+
*/
803+
const FOLLOWED_BY_NON_MODEL_RE = new RegExp(
804+
`^\\s*(?:${NON_MODEL_KIND})\\b(?=\\s*(?:$|[,.;:)]|(?:to|in|of|on|from|that|which|with|for|as|and|or|is|are|should|must|so|called|named|by)\\b))`,
805+
"i",
806+
)
807+
/** "should have `a`, `b`": what a model has or exposes is its columns. */
808+
const HAS_LIST_RE =
809+
/\b(?:have|has|having|contains?|containing|exposes?|exposing)\s+(?:the\s+)?(?:following\s+)?(?:(?:new|extra|additional)\s+)?$/i
791810
/** Separator between items of a list of spans, allowing a short parenthetical note after an item. */
792811
const LIST_SEPARATOR_RE = /^\s*(?:\([^)`]*\))?\s*(?:,|;|,?\s*(?:and|or|&))?\s*$/i
793812
/** "rename column `a` to `b`": the target of a column rename is a column. */
@@ -799,8 +818,15 @@ const RENAME_TARGET_RE = new RegExp(`^\\s*(?:(?:${NON_MODEL_KIND})\\s+)?(?:to|in
799818
* A span is dropped only on explicit wording around it ("column called `x`",
800819
* "`x` column", "should have `a`, `b`", a list continuing such a span); with no
801820
* such wording it stays, so a bare name is still required.
821+
*
822+
* Dropping errs toward keeping: a span introduced or followed by a deliverable
823+
* noun is never dropped, "have `x`" drops only when an earlier span of the line
824+
* is already kept (so "the project should have `stg_a` and `stg_b`" keeps both),
825+
* and the target of "to"/"as" is dropped only on a rename line. Dropping a real
826+
* model could leave the line with no name at all, which makes the whole
827+
* contract read as absent and silences both completion gates.
802828
*/
803-
function modelCandidateSpans(line: string): string[] {
829+
function modelCandidateSpans(line: string, renameLine: boolean): string[] {
804830
const out: string[] = []
805831
CODE_SPAN_RE.lastIndex = 0
806832
let prevEnd = 0
@@ -814,11 +840,17 @@ function modelCandidateSpans(line: string): string[] {
814840
const cur = matches[i]!
815841
const before = line.slice(prevEnd, cur.start)
816842
const after = line.slice(cur.end, matches[i + 1]?.start ?? line.length)
843+
// "column ... called `x`" counts only inside one clause that names no deliverable noun.
844+
const clause = before.split(/[;:!?,]|\.(?!\w)/).pop() ?? ""
845+
const introducedAsNonModel =
846+
(INTRODUCED_CALLED_RE.test(clause) && !DELIVERABLE_NOUN_RE.test(clause)) || INTRODUCED_COLON_RE.test(before)
817847
const nonModel: boolean =
818-
INTRODUCED_AS_NON_MODEL_RE.test(before) ||
819-
FOLLOWED_BY_NON_MODEL_RE.test(after) ||
820-
HAS_LIST_RE.test(before) ||
821-
(prevNonModel && (LIST_SEPARATOR_RE.test(before) || RENAME_TARGET_RE.test(before)))
848+
!INTRODUCED_AS_MODEL_RE.test(before) &&
849+
!FOLLOWED_BY_MODEL_NOUN_RE.test(after) &&
850+
(introducedAsNonModel ||
851+
FOLLOWED_BY_NON_MODEL_RE.test(after) ||
852+
(out.length > 0 && HAS_LIST_RE.test(before)) ||
853+
(prevNonModel && (LIST_SEPARATOR_RE.test(before) || (renameLine && RENAME_TARGET_RE.test(before)))))
822854
if (!nonModel) out.push(cur.text)
823855
prevNonModel = nonModel
824856
prevEnd = cur.end

‎packages/opencode/test/altimate/validators/dbt-deliverable-names.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,45 @@ describe("DbtDeliverableNamesValidator — code spans that are not models", () =
245245
expect(await requiredModels("Build the marts to include the model `fct_orders`.\n")).toEqual(["fct_orders"])
246246
})
247247

248+
test("a rename's column target is not a model even when it is the last span", async () => {
249+
expect(
250+
await requiredModels("In the model `stg_accounts`, rename column `old_status` to `new_status`.\n"),
251+
).toEqual(["stg_accounts"])
252+
})
253+
254+
// The model is the only name on these lines. Dropping it would make the
255+
// contract read as absent and silence both completion gates, so each of
256+
// these must keep requiring it.
257+
test.each([
258+
["Add a `department` column to `int_workspace_roster` model.", "int_workspace_roster"],
259+
["Add the column `order_id` to `fct_orders` table.", "fct_orders"],
260+
["Add the columns `order_id`, `order_total` and `placed_at` to `fct_orders` model.", "fct_orders"],
261+
["Add a `department` column to the model called `int_workspace_roster`.", "int_workspace_roster"],
262+
["Add a new column in the model named `fct_orders`.", "fct_orders"],
263+
["Create a settings model called `app_settings`.", "app_settings"],
264+
["Create a table of customer attributes called `dim_customer_attributes`.", "dim_customer_attributes"],
265+
["Create a model that aggregates the `amount` column, called `fct_amounts`.", "fct_amounts"],
266+
["Update the model `stg_orders` columns so they are snake_case.", "stg_orders"],
267+
])("the model stays required: %s", async (task, model) => {
268+
expect(await requiredModels(task + "\n")).toEqual([model])
269+
// The contract must still exist, or the gate is never consulted.
270+
expect(await DbtDeliverableNamesValidator.appliesTo(ctx())).toBe(true)
271+
})
272+
273+
test("models a project should have are still required", async () => {
274+
expect(
275+
await requiredModels("Create it so the project should have `stg_orders` and `stg_customers` models.\n"),
276+
).toEqual(["stg_orders", "stg_customers"])
277+
expect(await requiredModels("Make sure the marts folder has `fct_orders` built as a table.\n")).toEqual([
278+
"fct_orders",
279+
])
280+
})
281+
282+
test("'contains' and 'exposes' lists are columns", async () => {
283+
expect(await requiredModels("Create a `dim_x` model. It should contain `col_a`, `col_b`.\n")).toEqual(["dim_x"])
284+
expect(await requiredModels("Create a `dim_x` model. It must expose `col_a` and `col_b`.\n")).toEqual(["dim_x"])
285+
})
286+
248287
test("still fails when the model is missing even though its columns are listed", async () => {
249288
await makeProject()
250289
await writeTask("Create a `dim_superhost_evolution` model. It should have `is_currently_superhost`.\n")

0 commit comments

Comments
 (0)