diff --git a/packages/opencode/src/altimate/validators/validator-utils.ts b/packages/opencode/src/altimate/validators/validator-utils.ts index e1d85c476e..2c667a2a4b 100644 --- a/packages/opencode/src/altimate/validators/validator-utils.ts +++ b/packages/opencode/src/altimate/validators/validator-utils.ts @@ -694,7 +694,8 @@ export function extractRequiredDeliverables(text: string): RequiredDeliverables // required makes the deliverable gate reject the correct implementation // forever, so a negated verb disqualifies the whole line. if (verbIsNegated(line, verb.index)) continue - let spans = inlineCodeSpans(requirementHead(line, verb.index)) + const head = requirementHead(line, verb.index) + let spans = modelCandidateSpans(head) // "Rename `old_orders` to `new_orders`" names two artifacts, but only the // destination is required to exist once the rename is done — the source // is expected to be GONE. `to` is not a qualifier `requirementHead` cuts @@ -772,6 +773,193 @@ function inlineCodeSpans(line: string): string[] { return out } +/** + * Words that mark the code span they introduce as something other than a + * model: a column, a dbt variable, a macro. "Add the column `order_id`" and + * "add a variable called `use_x`" name real things, but not relations, so + * requiring a model by that name blocks a correct implementation forever. + */ +const NON_MODEL_KIND = "columns?|fields?|attributes?|variables?|vars?|macros?|settings?|parameters?" +/** + * "a column called", "the variable named": a kind word led by a determiner, + * a short phrase without a deliverable noun, then "called"/"named". Also + * accepted without a determiner when the whole clause holds no deliverable + * noun ("has columns called"). A deliverable noun before an undetermined kind + * word ("a table of customer attributes called `x`") leaves the span a model. + */ +const INTRODUCED_CALLED_RE = new RegExp( + `\\b(?:a|an|the|one|another|each|every|this|that)\\s+(?:(?:new|extra|additional)\\s+)?(?:${NON_MODEL_KIND})\\b((?:[^.;:,!?]|\\.(?=\\w)){0,60}?)\\b(?:called|named)\\s*$`, + "i", +) +const INTRODUCED_BARE_CALLED_RE = new RegExp( + `\\b(?:${NON_MODEL_KIND})\\b(?:[^.;:,!?]|\\.(?=\\w)){0,60}?\\b(?:called|named)\\s*$`, + "i", +) +const INTRODUCED_COLON_RE = new RegExp(`\\b(?:${NON_MODEL_KIND})\\b\\s*[:\\-(]?\\s*$`, "i") +/** A span directly introduced by a deliverable noun ("the model `x`", "table called `x`") is a model. */ +const INTRODUCED_AS_MODEL_RE = + /\b(?:models?|tables?|views?|seeds?|snapshots?|marts?|files?)\s*(?:(?:called|named)\s*)?:?\s*$/i +/** ... and so is a span followed by one ("`x` model"). */ +const FOLLOWED_BY_MODEL_NOUN_RE = new RegExp( + `^\\s*(?:as\\s+(?:an?\\s+|the\\s+)?)?(?:models?|tables?|views?|seeds?|snapshots?|files?)\\b(?!\\s+(?:${NON_MODEL_KIND})\\b)`, + "i", +) +/** + * "`x` column", "`x` variable": the kind word follows the span and ends the + * noun phrase. "`x` column names" is attributive and does not qualify. + */ +const FOLLOWED_BY_NON_MODEL_RE = new RegExp( + `^\\s*(?:as\\s+(?:an?\\s+|the\\s+)?(?:(?:new|extra|additional|separate)\\s+)?(?:(?:model|table|view)\\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))`, + "i", +) +/** "should have `a`, `b`": what a model has or exposes is its columns. */ +const HAS_LIST_RE = + /\b(?:have|has|having|includes?|contains?|containing|exposes?|exposing)\s+(?:the\s+)?(?:following\s+)?(?:(?:new|extra|additional)\s+)?$/i +/** + * The words just before a "have/include/contain" verb must name the model being + * described ("It should", "that has", "the resulting table should", "These models + * must"). "This project should have `x`" or "ensure the project includes `x`" names + * a different subject, so `x` stays a requirement. + */ +const MODEL_SUBJECT_BEFORE_VERB_RE = + /(?:\b(?:it|they|that|which|these|those|this|each)|\b(?:these|those|both)\s+(?:models|tables|views|snapshots|seeds)|\b(?:model|table|view|snapshot|seed))\s+(?:(?:also|should|must|will|can|then)\s+)*$/i +/** Separator between items of a list of spans, allowing a short parenthetical note after an item. */ +const LIST_SEPARATOR_RE = /^\s*(?:\([^)`]*\))?\s*(?:,|,?\s*(?:and|or|&))?\s*$/i +/** "rename column `a` to `b`": the target of a column rename is a column. */ +const RENAME_TARGET_RE = new RegExp(`^\\s*(?:(?:${NON_MODEL_KIND})\\s+)?(?:to|into|as)\\s*$`, "i") +/** A bare relation-style name; only these count as the model a "have" list describes. */ +const BARE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ +/** A span that collection will keep as a model name (not a stopword such as `date`). */ +function countsAsModel(text: string): boolean { + const name = text.toLowerCase() + return BARE_IDENTIFIER_RE.test(text) && IDENTIFIER_RE.test(name) && !DELIVERABLE_STOPWORDS.has(name) +} +/** Compound noun ("model column"): the model noun belongs to the kind word and introduces no model. */ +const COMPOUND_KIND_RE = new RegExp(`\\b(?:models?|tables?|views?)\\s+(?=(?:${NON_MODEL_KIND})\\b)`, "gi") +/** A span followed by "should have" etc. starts its own clause; it is not another item of the previous list. */ +const CLAUSE_VERB_AFTER_RE = /^\s*(?:should|must|will|needs?|has|have)\b/i +/** A literal path or file name stays eligible for the file check even when it is not a relation. */ +const PATH_SHAPED_RE = /[\\/]|\.(?:sql|csv|ya?ml)$/i +/** True when `before` ends in a have/include/contain verb whose subject is the model just named. */ +function hasModelDescriptionVerb(before: string, previousSpanIsModel: boolean): boolean { + const verb = HAS_LIST_RE.exec(before) + if (verb === null) return false + const subject = before.slice(0, verb.index) + // "`orders` should have `order_id`": the subject is the kept span just before the verb. + if (previousSpanIsModel && /^\s*(?:(?:also|should|must|will|can|then)\s+)*$/i.test(subject)) return true + return MODEL_SUBJECT_BEFORE_VERB_RE.test(subject) +} +/** End of a sentence or independent clause inside a gap between spans. */ +const SENTENCE_END_RE = /[.!?;]["')\]]*(?=\s|$)/ +/** A rename verb that is not negated ("do not rename the model" is not a rename). */ +function hasAffirmativeRename(head: string): boolean { + const re = /\brenam(?:e|es|ed|ing)\b/gi + let m: RegExpExecArray | null + while ((m = re.exec(head)) !== null) if (!verbIsNegated(head, m.index)) return true + return false +} + +/** + * Code spans of a requirement line that can name a model: every span except + * those the line itself introduces as a column, field, variable or macro. + * A span is dropped only on explicit wording around it ("column called `x`", + * "`x` column", "should have `a`, `b`", a list continuing such a span); with no + * such wording it stays, so a bare name is still required. + * + * Preference when the wording is ambiguous: the costlier error for a finish-time + * validator is the false alarm (a retry turn spent on a requirement nobody made), + * so a span that the text marks as a column, field, variable or macro by any + * explicit cue ("column", "as a new column", "model column", "should have" said of + * the model just named) is not required. A span with NO such cue is not ambiguous, + * it is named like a deliverable ("create model X", "the project should have X"), + * and is required exactly as before this filter existed: losing it would empty the + * contract and silence both completion gates. This is pattern matching on English + * phrasing, not understanding; wording it does not recognise falls on the "required" + * side because that is what the validator did before. + * + * Dropping errs toward keeping: a span introduced or followed by a deliverable + * noun is never dropped, a path-shaped span is never dropped (the file check + * still needs it), "have `x`" drops only when an earlier span of the sentence + * is kept or the sentence continues the description of the model ("It should + * have ..."), and the target of "to"/"as" is dropped only on a rename line. + * Dropping a real model could leave the line with no name at all, which makes + * the whole contract read as absent and silences both completion gates. + */ +function modelCandidateSpans(line: string): string[] { + const out: string[] = [] + CODE_SPAN_RE.lastIndex = 0 + let m: RegExpExecArray | null + const matches: { text: string; start: number; end: number }[] = [] + while ((m = CODE_SPAN_RE.exec(line)) !== null) { + if (m[1]) matches.push({ text: m[1], start: m.index, end: m.index + m[0].length }) + } + const gapBefore = (i: number) => line.slice(i === 0 ? 0 : matches[i - 1]!.end, matches[i]!.start) + const gapAfter = (i: number) => line.slice(matches[i]!.end, matches[i + 1]?.start ?? line.length) + // "`dim_a` and `dim_b` models": a trailing model noun covers the whole list before it. + const modelList: boolean[] = matches.map(() => false) + for (let i = matches.length - 1; i >= 0; i--) { + modelList[i] = + FOLLOWED_BY_MODEL_NOUN_RE.test(gapAfter(i)) || (!!modelList[i + 1] && LIST_SEPARATOR_RE.test(gapAfter(i))) + } + const isModelLike = (i: number) => + PATH_SHAPED_RE.test(matches[i]!.text) || INTRODUCED_AS_MODEL_RE.test(gapBefore(i)) || modelList[i]! + let keptModels = 0 + // Rename context is read once per sentence, not by rescanning the growing prefix for every span. + const hasRenameWord = /\brenam/i.test(line) + let sentenceStart = 0 + const nonModel: boolean[] = matches.map(() => false) + // Only a plural kind ("`a` and `b` columns") reaches back over a list; "`x` column" is just `x`. + const pluralKind: boolean[] = matches.map((_, i) => + /s$/i.test(FOLLOWED_BY_NON_MODEL_RE.exec(gapAfter(i))?.[1] ?? ""), + ) + + // Trailing kind word ("`a` and `b` columns"): applies to the whole list it ends. + let pluralList = false + for (let i = matches.length - 1; i >= 0; i--) { + if (isModelLike(i)) { + pluralList = false + continue + } + if (FOLLOWED_BY_NON_MODEL_RE.test(gapAfter(i))) { + nonModel[i] = true + pluralList = pluralKind[i]! + } else if (pluralList && nonModel[i + 1] && LIST_SEPARATOR_RE.test(gapAfter(i))) { + nonModel[i] = true + } else { + pluralList = false + } + } + for (let i = 0; i < matches.length; i++) { + const before = gapBefore(i) + const afterBoundary = before.split(SENTENCE_END_RE).pop() ?? "" + const sentenceBroke = SENTENCE_END_RE.test(before) + if (sentenceBroke) sentenceStart = matches[i]!.start - (afterBoundary.length) + const renameLine = hasRenameWord && hasAffirmativeRename(line.slice(sentenceStart, matches[i]!.start)) + if (!isModelLike(i) && !nonModel[i]) { + // "column ... called `x`" counts only inside one clause that holds no deliverable noun + // before the kind word. + const clause = (before.split(/[;:!?,]|\.(?!\w)/).pop() ?? "").replace(COMPOUND_KIND_RE, "") + // The colon form ("settings: `x`") is judged on the clause up to the colon. + const colonClause = (before.split(/[;!?,]|\.(?!\w)/).pop() ?? "").replace(COMPOUND_KIND_RE, "") + const called = INTRODUCED_CALLED_RE.exec(clause) + nonModel[i] = + (called !== null && !DELIVERABLE_NOUN_RE.test(called[1] ?? "")) || + (INTRODUCED_BARE_CALLED_RE.test(clause) && !DELIVERABLE_NOUN_RE.test(clause)) || + (INTRODUCED_COLON_RE.test(before) && !DELIVERABLE_NOUN_RE.test(colonClause)) || + (keptModels > 0 && hasModelDescriptionVerb(before, i > 0 && !nonModel[i - 1] && countsAsModel(matches[i - 1]!.text))) || + (i > 0 && + nonModel[i - 1] && + !CLAUSE_VERB_AFTER_RE.test(gapAfter(i)) && + (LIST_SEPARATOR_RE.test(before) || (renameLine && RENAME_TARGET_RE.test(before)))) + } + if (!nonModel[i]) { + out.push(matches[i]!.text) + if (countsAsModel(matches[i]!.text)) keptModels++ + } + } + return out +} + /** * Top-level path segments that never produce a dbt relation, even for a * `.sql`/`.csv` file within them. A required `macros/generate_schema_name.sql` diff --git a/packages/opencode/test/altimate/validators/dbt-deliverable-names.test.ts b/packages/opencode/test/altimate/validators/dbt-deliverable-names.test.ts index 07dd49118a..66f55057fe 100644 --- a/packages/opencode/test/altimate/validators/dbt-deliverable-names.test.ts +++ b/packages/opencode/test/altimate/validators/dbt-deliverable-names.test.ts @@ -4,6 +4,7 @@ import { promises as fs } from "fs" import { tmpdir } from "os" import { join } from "path" import { DbtDeliverableNamesValidator } from "../../../src/altimate/validators/dbt-deliverable-names" +import { extractRequiredDeliverables } from "../../../src/altimate/validators/validator-utils" import type { ValidatorContext } from "../../../src/session/validators/types" let dir = "" @@ -168,4 +169,349 @@ describe("DbtDeliverableNamesValidator — check", () => { expect(r.ok).toBe(true) }) }) + +// A code span in a requirement line is only a model when nothing marks it as +// something else. These cases are the shapes found in real benchmark requests +// (column lists, "column called", dbt variables) plus the shapes that must +// keep speaking. +describe("DbtDeliverableNamesValidator — code spans that are not models", () => { + async function requiredModels(task: string): Promise { + // Some tests call this twice; drop the previous project so none is left behind. + if (dir) await fs.rm(dir, { recursive: true, force: true }) + await makeProject() + await writeTask(task) + const r = await DbtDeliverableNamesValidator.check(ctx()) + return r.details!["required_models"] as string[] + } + + test("a column list after 'should have' is not required as models", async () => { + const models = await requiredModels( + "Create a `dim_superhost_evolution` model. It should have `is_currently_superhost`, `status_change_count` and `last_status_change_at`.\n", + ) + expect(models).toEqual(["dim_superhost_evolution"]) + }) + + test("a column list with parenthetical notes is still all columns", async () => { + const models = await requiredModels( + "Create a `dim_superhost_evolution` model. It should have `is_currently_superhost`, `acct_age_before_achieving_superhost` (for first time), `status_change_count`, `last_status_change_at`.\n", + ) + expect(models).toEqual(["dim_superhost_evolution"]) + }) + + test("passes when the model exists and only its columns are in backticks", async () => { + await makeProject() + await writeTask("Create a model `dim_superhost_evolution` that has `is_currently_superhost`, `status_change_count`.\n") + await writeModel("dim_superhost_evolution.sql") + expect((await DbtDeliverableNamesValidator.check(ctx())).ok).toBe(true) + }) + + test("'a `x` column to the model `m`' requires the model and not the column", async () => { + expect(await requiredModels("Add a `department` column to the model `int_workspace_roster`.\n")).toEqual([ + "int_workspace_roster", + ]) + }) + + test("'column called `x`' is not a model", async () => { + expect( + await requiredModels("Create a dbt model called `analysis__answer` that has one column called `answer`.\n"), + ).toEqual(["analysis__answer"]) + }) + + test("'columns `a`, `b` and `c`' are all columns", async () => { + expect( + await requiredModels("Add the columns `order_id`, `order_total` and `placed_at` to the model `fct_orders`.\n"), + ).toEqual(["fct_orders"]) + }) + + test("a renamed column is not a model, the model it lives in still is", async () => { + expect( + await requiredModels("Rename the `old_status` column to `new_status` in the model `stg_accounts`.\n"), + ).toEqual(["stg_accounts"]) + }) + + test("a dbt variable is not a model", async () => { + expect( + await requiredModels( + "Fix the model: add a global variable in dbt_project.yml called `surrogate_key_treat_nulls_as_empty_strings` to the `monthly_agg_reviews` model.\n", + ), + ).toEqual(["monthly_agg_reviews"]) + }) + + test("still requires every model in a list of models", async () => { + expect(await requiredModels("Create the models `stg_orders` and `stg_customers`.\n")).toEqual([ + "stg_orders", + "stg_customers", + ]) + }) + + test("a model that merely follows the word 'include' is still required", async () => { + expect(await requiredModels("Build the marts to include the model `fct_orders`.\n")).toEqual(["fct_orders"]) + }) + + test("a rename's column target is not a model even when it is the last span", async () => { + expect( + await requiredModels("In the model `stg_accounts`, rename column `old_status` to `new_status`.\n"), + ).toEqual(["stg_accounts"]) + }) + + // The model is the only name on these lines. Dropping it would make the + // contract read as absent and silence both completion gates, so each of + // these must keep requiring it. + test.each([ + ["Add a `department` column to `int_workspace_roster` model.", "int_workspace_roster"], + ["Add the column `order_id` to `fct_orders` table.", "fct_orders"], + ["Add the columns `order_id`, `order_total` and `placed_at` to `fct_orders` model.", "fct_orders"], + ["Add a `department` column to the model called `int_workspace_roster`.", "int_workspace_roster"], + ["Add a new column in the model named `fct_orders`.", "fct_orders"], + ["Create a settings model called `app_settings`.", "app_settings"], + ["Create a table of customer attributes called `dim_customer_attributes`.", "dim_customer_attributes"], + ["Create a model that aggregates the `amount` column, called `fct_amounts`.", "fct_amounts"], + ["Update the model `stg_orders` columns so they are snake_case.", "stg_orders"], + ])("the model stays required: %s", async (task, model) => { + expect(await requiredModels(task + "\n")).toEqual([model]) + // The contract must still exist, or the gate is never consulted. + expect(await DbtDeliverableNamesValidator.appliesTo(ctx())).toBe(true) + }) + + test("models a project should have are still required", async () => { + expect( + await requiredModels("Create it so the project should have `stg_orders` and `stg_customers` models.\n"), + ).toEqual(["stg_orders", "stg_customers"]) + expect(await requiredModels("Make sure the marts folder has `fct_orders` built as a table.\n")).toEqual([ + "fct_orders", + ]) + }) + + test("'contains' and 'exposes' lists are columns", async () => { + expect(await requiredModels("Create a `dim_x` model. It should contain `col_a`, `col_b`.\n")).toEqual(["dim_x"]) + expect(await requiredModels("Create a `dim_x` model. It must expose `col_a` and `col_b`.\n")).toEqual(["dim_x"]) + }) + + test("a trailing 'columns' applies to the whole list before it", async () => { + expect(await requiredModels("Add the `first_name` and `last_name` columns to the model `customers`.\n")).toEqual([ + "customers", + ]) + }) + + test("a path-shaped span is still checked as a file when it follows a kind word", async () => { + await makeProject() + await writeTask("Create macro `macros/helper.sql` for the model `orders`.\n") + await writeModel("orders.sql") + const r = await DbtDeliverableNamesValidator.check(ctx()) + expect(r.ok).toBe(false) + expect(r.details!["missing_files"]).toEqual(["macros/helper.sql"]) + }) + + test("a column called `x` is a column even when 'model' appears earlier in the clause", () => { + expect(extractRequiredDeliverables("Update the model to add a column called `status_flag`.\n")).toBeNull() + expect( + extractRequiredDeliverables("Update the model `orders` to add a column called `status_flag`.\n")!.models, + ).toEqual(["orders"]) + expect( + extractRequiredDeliverables("Create the model `orders` and a column called `status`.\n")!.models, + ).toEqual(["orders"]) + }) + + test("a second model after 'have' in a new sentence is still required", async () => { + expect( + await requiredModels("Create model `stg_orders`. The project should also have `fct_orders`.\n"), + ).toEqual(["stg_orders", "fct_orders"]) + }) + + test("a rename after another verb still drops the column target", async () => { + expect( + await requiredModels("Update the model `accounts` and rename column `old_status` to `new_status`.\n"), + ).toEqual(["accounts"]) + }) + + test("a model named after a colon keeps being required", async () => { + expect(await requiredModels("Create a model for storing application settings: `app_settings`.\n")).toEqual([ + "app_settings", + ]) + }) + + test("a new clause after a semicolon is not a description of the previous model", async () => { + expect( + await requiredModels("Create model `stg_orders`; the warehouse should have `fct_orders`.\n"), + ).toEqual(["stg_orders", "fct_orders"]) + expect( + await requiredModels("Build `stg_orders`; `order_id` column must be present in the resulting model.\n"), + ).toEqual(["stg_orders"]) + }) + + test("a file earlier on the line does not make a later 'has `x`' a column", async () => { + await makeProject() + await writeTask("Create the macro `macros/key.sql` and ensure the project has `dim_accounts` as a table.\n") + const r = await DbtDeliverableNamesValidator.check(ctx()) + expect(r.details!["required_models"]).toEqual(["dim_accounts"]) + expect(r.details!["required_files"]).toEqual(["macros/key.sql"]) + }) + + test("'the resulting table should have `x`' describes the model just named", async () => { + expect( + await requiredModels("Create the model `fct_orders`. The resulting table should have `order_id`.\n"), + ).toEqual(["fct_orders"]) + }) + + test("a negated rename does not make the model a rename target", async () => { + expect( + await requiredModels("Add a column `department` to `int_workspace_roster` (do not rename the model).\n"), + ).toEqual(["int_workspace_roster"]) + }) + + test("a singular trailing 'column' does not reach back over other spans", async () => { + expect( + await requiredModels("Build `stg_orders` and `order_id` column must be present in the model.\n"), + ).toEqual(["stg_orders"]) + }) + + test("a postfix 'columns' covers a list of three or more", async () => { + expect( + await requiredModels("Add the `first_name`, `middle_name`, and `last_name` columns to the model `customers`.\n"), + ).toEqual(["customers"]) + }) + + test("'add column x' after a model clause drops x", async () => { + expect(await requiredModels("In the model `orders`, add column `foo`.\n")).toEqual(["orders"]) + }) + + test("a rename later in the line does not hide an earlier model", async () => { + expect( + await requiredModels( + "Add the column `foo` to the model `orders` and rename the column `old` to `new` in this model.\n", + ), + ).toEqual(["orders"]) + }) + + test("a relation path does not hide a second model named after 'has'", async () => { + expect( + await requiredModels("Create model `models/stg_orders.sql` and ensure the project has `fct_orders`.\n"), + ).toEqual(["stg_orders", "fct_orders"]) + }) + + test("a non-identifier span does not count as the model a have list describes", async () => { + expect( + await requiredModels("Create a model that computes `COUNT(*)` and ensure the project has `fct_orders`.\n"), + ).toEqual(["fct_orders"]) + }) + + test("'as columns' and 'as a model column' mark columns", async () => { + expect( + await requiredModels("Add `order_id` and `customer_id` as columns to the model `orders`.\n"), + ).toEqual(["orders"]) + expect(await requiredModels("Add `order_id` as a model column to `fct_orders`.\n")).toEqual(["fct_orders"]) + }) + + test("'include' and plural subjects describe the model just named", async () => { + expect(await requiredModels("Create a `dim_x` model. It should include `col_a` and `col_b`.\n")).toEqual(["dim_x"]) + expect( + await requiredModels("Create models `orders` and `customers`. These models should have `created_at`.\n"), + ).toEqual(["orders", "customers"]) + }) + + test("'as a table' after a span marks it as a model", async () => { + expect( + await requiredModels("Create model `stg_orders` and ensure the project has `fct_orders` as a table.\n"), + ).toEqual(["stg_orders", "fct_orders"]) + }) + + test("a closing quote after the sentence end still ends the sentence", async () => { + expect( + await requiredModels('"Build the model `stg_orders`." The project should also have `fct_orders`.\n'), + ).toEqual(["stg_orders", "fct_orders"]) + }) + + // Direction of the rule: when the wording leaves a span's role open, it is NOT + // dropped (a model that is plainly named stays required); a span is dropped only + // when the text calls it a column, field, variable or macro. The cost of a wrong + // drop is a silenced gate; the cost of a wrong keep is one retry turn, and the + // sibling cases below pin both sides. + test.each([ + ["Create model `stg_orders`. This project should have `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders`. That warehouse should have `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create a model that computes `date` and ensure the project has `fct_orders`.", ["fct_orders"]], + ["Create a model that uses `count` and ensure the project has `fct_orders`.", ["count", "fct_orders"]], + ["Create model `stg_orders` and ensure the project includes `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders` and ensure the project contains `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders` and ensure the project has `fct_orders` with `id` as key.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders`. The required models should include `fct_orders`.", ["stg_orders", "fct_orders"]], + ])("a plainly named model is not lost: %s", async (task, models) => { + expect(await requiredModels(task + "\n")).toEqual(models) + }) + + test("a kept span that is the subject of 'should have' describes itself", async () => { + expect(await requiredModels("Build the model `orders`. `orders` should have `order_id`.\n")).toEqual(["orders"]) + }) + + test("a new clause is not another item of the previous column list", async () => { + expect( + await requiredModels("Build the models `orders` and `dim_dates`. `orders` should have `order_id`, and `dim_dates` should have `d`.\n"), + ).toEqual(["orders", "dim_dates"]) + }) + + test("a stopword span is not the subject of 'should have'", async () => { + expect(await requiredModels("Build the model `stg_orders`. The `project` should have `fct_orders`.\n")).toEqual([ + "stg_orders", + "fct_orders", + ]) + }) + + test("'both models' describes the models just named, and a trailing 'models' covers a list", async () => { + expect( + await requiredModels("Create models `orders` and `customers`. Both models should have `created_at`.\n"), + ).toEqual(["orders", "customers"]) + expect(await requiredModels("Create model `bundle`. It should contain `dim_a` and `dim_b` models.\n")).toEqual([ + "bundle", + "dim_a", + "dim_b", + ]) + }) + + test.each([ + ["Add `status_flag` as a new column to the model `orders`.", ["orders"]], + ["Add `status_flag` as an additional column to the model `orders`.", ["orders"]], + ["Add `status_flag` as an extra field to the model `orders`.", ["orders"]], + ["Add a model column `status_flag` to `orders`.", ["orders"]], + ["Add a table column `status_flag` to the model `orders`.", ["orders"]], + ])("a span the text calls a column is not a model: %s", async (task, models) => { + expect(await requiredModels(task + "\n")).toEqual(models) + }) + + // For plainly worded requests the required set must equal what origin/main + // returned before the span filter existed; the filter only differs where a span + // is explicitly called a column, variable or macro. + test.each([ + ["Create the model `fct_orders`.", ["fct_orders"]], + ["Create the models `stg_orders` and `stg_customers`.", ["stg_orders", "stg_customers"]], + ["Create model `stg_orders`. This project should have `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create a model that computes `date` and ensure the project has `fct_orders`.", ["fct_orders"]], + ["Create model `stg_orders` and ensure the project includes `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders` and ensure the project contains `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders`. That warehouse should have `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders`. Those teams should include `fct_orders`.", ["stg_orders", "fct_orders"]], + ["Create model `stg_orders` and ensure the project has `fct_orders` with `id` as key.", ["stg_orders", "fct_orders"]], + ["Rename the model `old_orders` to `new_orders`.", ["new_orders"]], + ["Update the model `orders` so it excludes cancelled rows.", ["orders"]], + ["Create the model `dim_customer` and the table `dim_product`.", ["dim_customer", "dim_product"]], + ["Build `stg_a` and `stg_b` as views.", ["stg_a", "stg_b"]], + ["Add the seed `country_codes` and the snapshot `snap_orders`.", ["country_codes", "snap_orders"]], + ["Do not create the model `legacy_orders`; create `fct_orders` instead.", null], + ["Create `models/marts/fct_orders.sql` and the model `dim_dates`.", ["fct_orders", "dim_dates"]], + ["Create a model that uses `count` and ensure the project has `fct_orders`.", ["count", "fct_orders"]], + ["Fix the model `stg_orders`; the project should also have `fct_payments` as a table.", ["stg_orders", "fct_payments"]], + ["Create the model `a_model`. The mart must include `fct_sales`.", ["a_model", "fct_sales"]], + ] as [string, string[] | null][])("unchanged from origin/main: %s", async (task, expected) => { + const r = extractRequiredDeliverables(task + "\n") + expect(r === null ? null : r.models).toEqual(expected) + }) + + test("still fails when the model is missing even though its columns are listed", async () => { + await makeProject() + await writeTask("Create a `dim_superhost_evolution` model. It should have `is_currently_superhost`.\n") + const r = await DbtDeliverableNamesValidator.check(ctx()) + expect(r.ok).toBe(false) + expect(r.details!["missing_models"]).toEqual(["dim_superhost_evolution"]) + expect(r.reason).not.toContain("is_currently_superhost") + }) +}) // altimate_change end