Skip to content

#116: custom business-rule test generation (plan) - #117

Merged
wjduenow merged 47 commits into
devfrom
feature/116-business-rule-tests
May 25, 2026
Merged

#116: custom business-rule test generation (plan)#117
wjduenow merged 47 commits into
devfrom
feature/116-business-rule-tests

Conversation

@wjduenow

@wjduenow wjduenow commented May 23, 2026

Copy link
Copy Markdown
Owner

Summary

Super plan for #116 — custom business-rule test generation (research Opportunity 2, v2 milestone).

Extends the drafter beyond the four built-in dbt test types to a fifth: custom singular SQL tests encoding business rules — sourced from meta.signalforge.business_rules (NL) and LLM inference — then run through the existing prune → grade → diff pipeline so always-pass / uninformative rules are dropped.

Phase: detailing (awaiting approval)
Stories: 16 implementation + Quality Gate + Patterns & Memory (18 total)
Decisions: 15 captured (DEC-001 … DEC-015)

Locked scope

  • Both input paths (meta-driven primary + LLM-inferred fallback), shipped together
  • Arbitrary failing-rows SELECT (full dbt singular-test contract; {{ this }}/{{ ref }}/{{ source }}, cross-model joins)
  • Multi-table tests run full-scan within the bytes cap (so cross-model rules actually get pruned); over-cap → kept-without-evidence
  • .sql files written on generate --write (fail-closed writer + injection-safe filenames + --force overwrite); prune-existing extended to ingest existing tests/*.sql, read-only
  • Bounded Jinja resolution, no Jinja engine; control-flow Jinja rejected loudly

Architecture review

No blockers; five concerns resolved into DECs (SQL-injection backtick gap, runaway cost, multi-table sampling, on-disk artifact path-safety, prompt-injection).

Plan document

plans/super/116-business-rule-tests.md.

Next steps

  • Review the plan in this PR
  • Approve in Claude Code to proceed to devolve (beads creation)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for custom business-rule tests declared via meta.signalforge.business_rules in dbt YAML
    • generate command now writes proposed singular .sql test files with --write flag; use --force to overwrite existing generated files
    • prune-existing command now ingests and prunes hand-authored tests/*.sql tests alongside schema tests via new --tests-dir flag
  • Documentation

    • Updated CLI operations guide with new test file generation and singular test ingestion behavior
    • Added comprehensive business-rule test workflow documentation and examples

Review Change Stack

wjduenow and others added 2 commits May 22, 2026 18:02
16 implementation stories + Quality Gate + Patterns & Memory; 15 decisions.
Phase: detailing (awaiting approval).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (4)
  • feature/.*
  • bug/.*
  • hotfix/.*
  • feat/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f6408768-bda0-41d4-9298-2ac2ef0096c9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

codecov-commenter commented May 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@wjduenow
wjduenow marked this pull request as ready for review May 23, 2026 01:09
wjduenow and others added 25 commits May 22, 2026 18:12
…drift mirror

Add a fifth CandidateTest variant CandidateTestCustomSQL (DEC-002) for
custom singular SQL business-rule tests:
- type: Literal["custom_sql"], sql: str (failing-rows SQL),
  column: str | None (None = model-level), rationale: str | None = None
- frozen Pydantic v2, extra="ignore", non-empty sql validator
- added to the CandidateTest discriminated union and __all__
- StrictCandidateTestCustomSQL(extra="forbid") mirror in the draft
  drift detector + new union member
- model-level custom_sql row added to candidate_schema_v1.json fixture

Minimal downstream arm to keep pyright/tests green: prune/compiler.py
_compile_test now matches CandidateTestRelationships explicitly and
raises NotImplementedError for custom_sql (compiler support is a
separate bead); behavior for the four existing variants is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend `_strip_string_literals` to also neutralise backtick-quoted
identifier spans (`` `...` ``) alongside single/double-quoted string
literals. Without this, a stray quote inside a backtick identifier
(e.g. `` `it's` ``) opened a phantom single-quoted literal that could
swallow a real top-level `;`, letting statement-stacking slip past
`validate_test_sql`'s cheap-reject checks (DEC-008 of #116).

Backtick spans use the same doubled-quote escape handling as the
existing quote chars; all prior behaviour (single/double quotes,
balanced-paren depth scan, comment-marker detection) is preserved.
This stays a cheap-reject checker — no full SQL parser
(warehouse-adapters.md / prune-engine.md DEC-024 preserved).

Tests: a `;` masked by a stray quote inside backticks is now caught;
a top-level `;` after a backtick span containing `;` is caught; benign
backtick identifiers (incl. an in-span `;`) are still allowed; comment
markers inside quotes/backticks are ignored; balanced-paren logic is
unaffected by backtick content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…012)

Extend the shared artifact-id seam so the fifth CandidateTest variant
CandidateTestCustomSQL gets a stable args-hash and dotted artifact id.

- model_test_args_hash: add a custom_sql branch hashing {type, column,
  sql} as canonical JSON, so distinct SQL -> distinct hash and identical
  SQL collides deterministically; column (None vs str) keeps a
  column-scoped and model-level custom_sql with the same SQL apart.
- artifact_id_for is generic over test.type and already emits
  test.column.<col>.custom_sql / test.model.custom_sql with the
  optional .<args_hash> suffix; no formatter change needed.

Tests cover distinct/identical SQL hashing, column distinguishing the
hash, both dotted-path shapes, args-hash suffix, collision +
ordinal-duplicate disambiguation via compute_args_hashes, and
cross-stage byte parity with the grade engine. The cross-stage
is-identity parity test stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…solution

Expose dbt sources from the manifest and add Jinja-ref relation resolvers
(DEC-005 of #116), the manifest-layer foundation for resolving dbt refs in
later business-rule-test stories. Stage-0: deterministic, no logging, typed
errors with remediation.

- models.py: new `Source` read-back model (frozen, extra="ignore", schema_
  alias + identifier/name relation_name fallback); `Manifest.sources` field;
  `Manifest.resolve_ref` / `resolve_source` method wrappers; `Model.resolve_this`.
- loader.py: filter `resource_type == "source"` into `Manifest.sources` at
  load; `resolve_ref(manifest, name, *, package, version)` (matches by
  Model.name, two-arg package disambiguation, fail-loud on unknown/ambiguous);
  `resolve_source(manifest, source_name, table_name)` -> TableRef. TableRef
  imported lazily (deferred) to keep manifest stage-0 import-clean.
- errors.py: `RefNotFoundError`, `AmbiguousRefError`, `SourceNotFoundError`
  (all carry remediation).
- __init__.py: re-export `Source`, the two resolvers, and the three errors.
- cli/_helpers.py: register the three new errors in the exit-code table
  (tier 2 input-validation) so the 7th AST scan stays green.
- tests: `Source` validate + drift-detector (extra="forbid" mirror) in
  test_models.py; new test_resolve.py covering ref/source/this resolution,
  package disambiguation, identifier fallback, and fail-loud unknown/ambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…riter

US-011 of #116 (DEC-010, DEC-014). Ships the two primitives the later
`generate --write` CLI write-path will call to emit singular business-rule
tests as standalone `.sql` files:

- `signalforge.diff._test_file_writer.anchor_to_filename(...)` — injection-safe
  builder producing a relative `tests/<model>__<descriptor>_<hash>.sql`. Every
  component slugged to `[A-Za-z0-9_]`; `..`/`/`/`\`/control chars/NUL/absolute
  paths all collapse to `_` so a crafted manifest model name or LLM-emitted
  column name cannot escape `tests/`. Exhaustive adversarial unit tests.
- `signalforge.diff._test_file_writer.write_test_file(...)` — the project's
  sixth fail-closed writer, mirroring `diff/_sidecar.py` verbatim: size-check
  before open -> symlink-hardened canonicalisation -> mkdir -p ->
  os.open(O_WRONLY|O_CREAT|O_TRUNC, 0o600) -> short-write while-loop -> fsync ->
  close in try/finally. No except around write/fsync (propagation IS the
  defence). Prepends a `-- signalforge:generated <hash>` header marker.

Two new typed errors (`DiffTestFileWriteError`, `DiffTestFileRecordTooLargeError`)
subclass `DiffError`, re-export from the package, and register tier 3 in
`_EXCEPTION_TO_EXIT_CODE` (scan 7). Module added to `_FAIL_CLOSED_WRITER_MODULES`
(scan 8). CLI does NOT wire `--write` (later bead) — primitives + errors only.

Full validation green: ruff, format, pyright (0 errors), pytest (1992 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stom_sql

Add the fifth CandidateTest variant (custom_sql, DEC-002) to the drafter's
recognised-type set and anchor-contract validator (US-004).

config.py: add "custom_sql" to VALID_TEST_TYPES so it is a recognised type
and can be named in exclude_tests.

parser.py _validate_anchor_contract: bespoke custom_sql handling, collect-all
(no short-circuit), matching the existing style:
- sql must be non-empty after strip (whitespace-only is a violation; the
  model's truthiness validator already rejects "")
- exempt from the parent-column-equality rule (a column-scoped custom_sql may
  reference other columns in its SQL); only membership of the declared column
  matters when column is not None
- column=None is a valid model-level business-rule assertion
- the existing exclude_tests gate (keyed on test.type) covers custom_sql
Structural validation only — SQL Jinja/safety is the resolver/compiler's job.

Tests: valid column-scoped + model-level, references-other-columns-not-rejected,
empty-sql (column + model level), unknown declared column (column + model level),
and exclude_tests rejection (column + model level).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ompt

Teach the drafter to propose custom singular SQL business-rule tests
(DEC-001, DEC-015 of #116). Two input paths:

1. meta-driven (primary): read meta.signalforge.business_rules at column
   level (Column.meta) and model level (Model.config.meta), accepting a
   natural-language str OR list[str]. Mirrors the safety layer's
   meta.get("signalforge") dict-guard pattern (strict isinstance(dict)
   check; scalars/lists under the key are treated as config noise). Rules
   render into the DYNAMIC (non-cached) block as a fenced ## BUSINESS RULES
   section, model-level first then per-column (columns sorted for
   byte-stability), deduped.
2. inferred fallback: the system prompt permits the LLM to infer custom_sql
   tests from the model SQL + column profile when no rules are supplied.

Prompt changes (draft/prompts.py):
- Add a custom_sql JSON-shape illustration. It lives in a separate
  _CUSTOM_SQL_CATALOGUE_LINE appended unconditionally after the filtered
  four standard types — exclude_tests / VALID_TEST_TYPES stay four-typed
  and unchanged (config.py is owned by a parallel bead).
- SCOPE section describes custom_sql as full singular-test failing-rows
  SELECTs that may use {{ this }} / {{ ref('m') }}, covering both the
  meta-driven and inferred paths.
- _PROMPT_VERSION rotates 2563a71c5e31f0db -> 2e465018c1f6db22; the
  exclude_tests prompt-version recipe is intact. Cache-stability snapshot
  test updated in lockstep (cached block unchanged — business rules are
  dynamic-block-only).

Orchestrator: business rules are read from the Model inside
_render_dynamic_block, which render_prompt already receives, so no
signature change is needed (both render_prompt callers keep working).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add signalforge.manifest.resolve_template_refs(sql, model, manifest) — a
bounded regex substituter (NO Jinja engine, DEC-004) that turns dbt-Jinja
references in singular-test SQL into qualified table names for the prune
compiler (US-007) and ingest reader (US-013) to consume:

- {{ this }}            -> model.resolve_this().qualified_name
- {{ ref('m') }} /
  {{ ref('pkg','m') }}  -> manifest.resolve_ref(...).qualified_name
                           (last positional = name, leading = package)
- {{ source('s','t') }} -> manifest.resolve_source(...).qualified_name

Whitespace + both quote styles tolerated (mirrors ingest parser's unwrap).
Fails loud on {% ... %} blocks, var()/env_var(), macro calls, dynamic
ref()/source(), and any residual {{ }} after substitution. Underlying
RefNotFoundError / AmbiguousRefError / SourceNotFoundError propagate.

Placement keeps layering + AST scans intact:
- Resolver lives in the manifest layer (stage-0: deterministic, no logging,
  typed errors carry remediation); re-exported from signalforge.manifest.
- New TemplateResolutionError(ManifestError) + UnsupportedJinjaError go in
  the EXISTING manifest/errors.py (no new errors.py — scan-7 count stays 11).
- Both registered in cli._helpers._EXCEPTION_TO_EXIT_CODE at tier 2; the
  test_exit_codes catch-all path constructs them via the layer-base shape.

Tests: each ref form, source, this, pkg disambiguation, version-kwarg
tolerance, whitespace, multi-ref, no-jinja passthrough, all rejection paths,
and propagation of the three manifest resolver errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the NotImplementedError placeholder arm for CandidateTestCustomSQL
in prune/compiler.py with a real _compile_custom_sql (DEC-003/006/008/009).

- Resolve dbt-Jinja refs ({{ this }} / {{ ref() }} / {{ source() }}) via
  signalforge.manifest.resolve_template_refs; thread the Model under prune
  through _compile_test as a keyword-only `model` param (engine call site
  passes model=model). Built-ins ignore it; existing call sites unchanged.
- SQL-safety pre-flight (validate_test_sql) on the RESOLVED sql.
- Conservative-bias routing: TemplateResolutionError / UnsupportedJinjaError
  / QuerySyntaxError all return the existing _InvalidIdentifier sentinel so
  the engine routes to kept-without-evidence (no 6th DropReason; the
  compiler never raises for these — DEC-006).
- Single-table (no JOIN after literal-stripping) -> sample-CTE wrap mirroring
  the built-ins (substitute own qualified name with the `sample` alias);
  multi-table (JOIN survives) -> full-scan, partition filter applied to the
  model's own table only. Dialect-driven via Dialect.quote_char; no
  BigQuery-isms.
- Returns the resolved failing-rows SELECT (NOT a pre-wrapped count) so the
  adapter's run_test_sql owns the COUNT(*) envelope, avoiding double-count.

Snapshot fixtures pin byte-exact output: custom_sql.sql (single-table full),
custom_sql_sample.sql (single-table sample), custom_sql_fullscan.sql
(multi-table). Tests cover ref/source resolution, unsupported-Jinja/var/
safety-reject -> sentinel, no-model -> sentinel, full-scan partition wrap,
determinism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…013)

Extend the stage-0 ingest layer to read an operator's existing singular dbt
tests (.sql files under tests/) into CandidateTestCustomSQL records so
prune-existing can later prune them (DEC-013).

- parser.classify_singular_test(sql, *, file_name, model, manifest): reuses
  signalforge.manifest.resolve_template_refs to resolve ref()/source()/this
  (no regex duplicated). Associated -> CandidateTestCustomSQL(column=None);
  references a different / unknown / ambiguous model -> None (not included,
  not skip-recorded); unsupported Jinja ({% %}, {{ var() }}, macros, residual
  {{ }}) -> SkippedTest(reason="malformed-supported-test"). The closed 3-value
  SkipReason is unchanged.
- reader.read_test_files(tests_dir, model, manifest, *, project_dir=None,
  existing=None): enumerates *.sql (sorted), size-caps each from stat() before
  read (5 MB, mirrors read_schema DEC-005), dedupes associated tests by
  (model, "custom_sql", sql_hash) via blake2b-8 of the SQL body, and seeds the
  dedupe set from an optional schema.yml-sourced `existing` candidate so the
  same test from both sources collapses. Returns a model-level-only
  CandidateSchema (no columns; no anchor check — singular tests are
  model-level with column=None).
- Re-export read_test_files from signalforge.ingest.
- Stage-0 discipline preserved: no logging, no audit writer, deterministic,
  typed errors carry remediation, extra="ignore" read-back models.

Fixtures: tests/fixtures/ingest/custom_sql_files/ (orders ref, customers ref,
unsupported-macro). Tests: tests/ingest/test_test_files.py (classifier matrix +
reader: associate/exclude/skip, dedupe within dir and against existing,
oversize-before-read, missing dir, non-sql ignored, sorted determinism).

Validation green: ruff check + format, pyright (0 errors), full pytest
(2084 passed, 96.58% cov). Logger grep-gate (ingest stays silent) + AST
audit-completeness scans pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ql files

US-010 of #116. Teach the diff layer to surface kept singular custom_sql
business-rule tests as standalone .sql file proposals (DEC-011) — they
are NOT schema.yml blocks.

- models.py: add ProposedTestFile frozen model (path + sql); add
  DiffReport.proposed_test_files tuple; bump audit_schema_version 2 -> 3
  in lockstep (sidecar shape evolved). Minimal __repr__ keeps the SQL
  body out of log sinks.
- _emitter.py: _test_args_hash now delegates to the shared
  _common.artifact_id seam (handles the 5th custom_sql variant);
  _render_test returns a _SKIP sentinel for custom_sql so the YAML
  emitter cleanly skips it (never crashes); new emit_proposed_test_files
  builds one ProposedTestFile per kept custom_sql via the reused
  anchor_to_filename + _with_marker + shared args-hash.
- _renderers.py: AnsiRenderer + MarkdownRenderer render a proposed
  test-files section (new-file header / fenced sql block) with the same
  unconditional ANSI-strip + dynamic markdown fence on the SQL content
  (DEC-007/008). JsonRenderer/sidecar carry it automatically.
- engine.py: wire emit_proposed_test_files into DiffReport + INFO log.
- Drift detectors (test_drift_detector.py + inline test_models.py) +
  fixtures (diff_report_v1.json) updated; ProposedTestFile exported on
  the public surface; snapshot cases + fixtures regenerated; e2e diff.json
  fixture + docs (diff-ops, audits) bumped to v3.

Tier classification + why-cascade unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ql (US-008)

Verify and pin (via tests) that the prune ENGINE routes custom_sql
business-rule tests through the existing decision matrix unchanged. The
engine's per-test routing is deliberately test-type-agnostic — it
dispatches on the compiler's return shape (str / _InvalidIdentifier /
_RequiresFutureData), the warehouse failure_count, and any raised
WarehouseError — so custom_sql flows through the same paths as the four
built-ins with NO engine source change.

Six new fake-adapter tests in tests/prune/test_engine.py:
- always-passes (failure_count=0) -> dropped
- real failure on untrusted model (failure_count>0) -> kept
- unsupported-Jinja -> _InvalidIdentifier sentinel -> kept-without-evidence
  (no warehouse call)
- WarehouseError during query -> kept-without-evidence (generic per-test why)
- multi-table full-scan over maximum_bytes_billed (BytesBilledExceededError)
  -> kept-without-evidence
- fail-closed audit invariant: one PruneEvent per custom_sql candidate,
  PruneEvent shape unchanged (round-trips through read-back model)

DEC-007 why decision (byte-cap case): use the GENERIC per-test handler why
("Test could not be evaluated: BytesBilledExceededError: ...") rather than
a bespoke locked string. The typed class name is already in the why for
reviewer correlation; a distinct why would require special-casing one
WarehouseError subclass in the otherwise error-type-agnostic handler. The
locked 5-value DropReason literal stays unchanged; no new DropReason.

No audit-shape change -> prune_event_v1.jsonl fixture + drift detector
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wjduenow and others added 6 commits May 23, 2026 10:43
… (US-016)

Complete the operator narrative for the custom singular SQL business-rule
test feature (custom_sql, issue #116) across the user-facing docs. Extends
the lockstep edits already made by implementation beads (diff-ops/audits
audit_schema_version 2→3; cli-ops --force/--tests-dir); does not duplicate.

- docs/draft-ops.md: new "Custom business-rule tests (custom_sql)" section
  — authoring meta.signalforge.business_rules (NL str/list, column + model
  level), inferred-fallback, {{ this }}/{{ ref }}/{{ source }} support with
  control-flow Jinja unsupported, a worked rule→JSON example; CandidateTest
  union + CandidateTestCustomSQL in the API table; exclude_tests exemption.
- docs/prune-ops.md: new "custom_sql evaluation" section — Jinja-resolution
  routing (clean / requires-future-data / kept-without-evidence), single-
  table sampled vs multi-table full-scan, the maximum_bytes_billed cap as
  the only multi-table guardrail + tuning note; folded into the expected-
  drop-rate framing; taxonomy table + v0.2-deferrals updated to five types.
- docs/ingest-ops.md: new "Singular tests/*.sql tests" section for
  read_test_files — ref/source/this resolution, association-to-model,
  unrelated-files-ignored, unsupported-Jinja skip, dedupe + size cap.
- README.md: custom business-rule tests in the feature list; a worked
  rule→generated .sql→kept/dropped example; prune-existing singular .sql
  ingestion note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…itution + multi-table test coverage

P0 (CRITICAL): _compile_custom_sql single-table scope="full" path now
rewrites the model's own qualified name to the substituted table_ref when
the engine handed it a DIFFERENT physical table (the materialised temp
table under sample_strategy="materialised" + scope="sample"). Previously
returned the resolved SQL unchanged, so single-table custom_sql tests
silently full-scanned the production source instead of the materialised
sample — defeating the cost model and risking maximum_bytes_billed over-cap.
Mirrors the built-in compilers, which always FROM table_ref. Multi-table
(DEC-006) and oneshot/full-strategy behaviour unchanged.

P2: single-table self-reference (correlated subquery / self-UNION) now
substitutes ALL occurrences in the sample branch and the full-scope
partition branch (dropped count=1). Multi-table partition replace stays
count=1 (intentional).

Tests (tests/prune/test_engine.py):
- Tightened the over-byte-cap multi-table test to require the full-scan
  JOIN shape and reject a WITH sample CTE; switched to two distinct refs
  ({{ this }} + {{ ref('other_model') }}) so it genuinely exercises the
  multi-table classifier.
- Added engine-level multi-table full-scan tests (failures=0 → dropped/
  always-passes; failures>0 → kept) asserting no sample CTE is dispatched.
- Added a P0-fix test: single-table custom_sql under materialised+sample
  references _SESSION._sf_sample_<run_id> and never the source table.

No DropReason added; no .claude/ edits; no fixture regen needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… + CLAUDE.md surface

New .claude/rules/business-rule-tests.md distilling the #116 conventions
(custom_sql variant, bounded Jinja resolution, materialised-substitution
gotcha, fail-closed .sql writer, conservative-bias routing); CLAUDE.md #116
entry + v0.3 public-API block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements issue #116’s “custom business-rule test generation” by introducing a 5th candidate test variant (custom_sql) representing dbt singular SQL tests, and threading it end-to-end through draft → ingest/prune-existing → prune → diff → CLI write-path, alongside a new bounded dbt-Jinja reference resolver ({{ this }} / ref() / source()) with fail-loud behavior for unsupported Jinja.

Changes:

  • Add CandidateTestCustomSQL to the draft model union, extend prompt/system-prompt content and business-rule meta ingestion (meta.signalforge.business_rules) to drive drafting of singular failing-rows SELECTs.
  • Add manifest-layer bounded Jinja ref resolution and source registry (Source, resolve_ref, resolve_source, resolve_template_refs) for singular-test SQL.
  • Extend ingest to read operator singular tests/*.sql (read_test_files) and extend diff to surface kept custom_sql tests as standalone proposed .sql files (proposed_test_files) with a write-path and schema bump.

Reviewed changes

Copilot reviewed 78 out of 78 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/warehouse/test_models.py Adds test coverage for backtick-aware SQL safety stripping and semicolon/comment detection.
tests/test_audit_completeness.py Updates audit module inventory to include the new fail-closed test-file writer module.
tests/manifest/test_template.py New tests for bounded template ref substitution (this/ref/source) and fail-loud rejection paths.
tests/manifest/test_resolve.py New tests for ref/source/this resolution APIs and loader surfacing of sources.
tests/manifest/test_models.py Adds Source model validation/drift tests (includes a buggy assertion flagged in review).
tests/llm/test_prompt_cache_stability.py Rotates prompt version and documents the #116 prompt change.
tests/ingest/test_test_files.py New end-to-end tests for reading/deduping/classifying singular tests/*.sql.
tests/fixtures/prune/compiled_sql/custom_sql.sql New compiled SQL fixture for custom_sql compilation.
tests/fixtures/prune/compiled_sql/custom_sql_sample.sql New compiled SQL fixture for sampled custom_sql compilation.
tests/fixtures/prune/compiled_sql/custom_sql_fullscan.sql New compiled SQL fixture for join/fullscan custom_sql compilation.
tests/fixtures/ingest/custom_sql_files/assert_orders_with_macro.sql New fixture: unsupported-Jinja singular test to be skip-recorded.
tests/fixtures/ingest/custom_sql_files/assert_orders_amount_positive.sql New fixture: associated singular test for orders.
tests/fixtures/ingest/custom_sql_files/assert_customers_have_email.sql New fixture: unrelated singular test that must be ignored.
tests/fixtures/e2e_helpers/happy/.signalforge/diff.json Updates diff sidecar fixture schema version (audit_schema_version bump).
tests/fixtures/draft/candidate_schema_v1.json Adds a custom_sql row to candidate schema fixture.
tests/fixtures/diff/proposed_test_files.md New snapshot fixture demonstrating proposed singular test files in Markdown.
tests/fixtures/diff/proposed_test_files.ansi New snapshot fixture demonstrating proposed singular test files in ANSI.
tests/fixtures/diff/full_with_grade.json Updates diff fixture for schema bump and proposed_test_files field presence.
tests/fixtures/diff/diff_report_v1.json Updates diff fixture to include proposed_test_files array when present.
tests/draft/test_prompts.py Adds tests for custom_sql prompt catalogue/SCOPE + business_rules meta rendering.
tests/draft/test_parser.py Adds anchor-contract tests for custom_sql (empty SQL, column membership, exclude_tests handling).
tests/draft/test_exclude_tests.py Updates VALID_TEST_TYPES pin to include custom_sql.
tests/draft/test_drift_detector.py Extends strict drift detector union with custom_sql.
tests/diff/test_snapshot_fixtures.py Updates snapshot fixture count to include proposed_test_files cases.
tests/diff/test_renderers.py Adds renderer tests for proposed test files, ANSI stripping, and dynamic fences.
tests/diff/test_public_api.py Exposes ProposedTestFile and new diff writer errors via public API tests.
tests/diff/test_models.py Updates DiffReport audit schema version and adds ProposedTestFile strict mirror/tests.
tests/diff/test_engine.py Adds end-to-end diff test ensuring kept custom_sql becomes a proposed_test_file.
tests/diff/test_emitter.py Adds tests for YAML skipping custom_sql and standalone .sql emission + path safety.
tests/diff/test_drift_detector.py Adds strict mirror parity tests for ProposedTestFile and DiffReport schema bump.
tests/diff/test_artifact_id.py Adds args-hash and artifact-id shape tests for custom_sql.
tests/diff/_snapshot_inputs.py Adds snapshot recipe inputs for proposed test files and updates recipe table.
tests/cli/test_prune_existing.py Extends prune-existing to ingest singular tests via --tests-dir and merge behavior tests.
tests/cli/test_generate.py Adds tests for generate --write writing proposed .sql files and --force overwrite policy.
tests/cli/test_exit_codes.py Adds new diff writer errors to exit-code mapping tests.
tests/cli/test_e2e_helpers.py Adds unit tests for manifest business_rules injection helper.
tests/cli/test_e2e_business_rules.py New gated e2e test validating business-rule drafting+pruning behavior on real services.
tests/cli/test_5_surface_parity_force.py New 5-surface parity test ensuring --force is documented/implemented consistently.
tests/cli/_e2e_helpers.py Adds helper to inject business_rules into a copied manifest fixture.
src/signalforge/warehouse/_sql_safety.py Extends string-literal stripping to include backtick spans for SQL safety checks.
src/signalforge/prune/engine.py Passes model= into prune compilation path (needed for custom_sql compilation).
src/signalforge/manifest/template.py New bounded dbt-Jinja resolver for singular-test SQL (no Jinja engine).
src/signalforge/manifest/models.py Adds Source model, Manifest.sources registry, and resolve_this/resolve methods.
src/signalforge/manifest/loader.py Loads sources registry and adds resolve_ref/resolve_source helper functions.
src/signalforge/manifest/errors.py Adds typed errors for ref/source/template resolution failures.
src/signalforge/manifest/init.py Re-exports new manifest APIs and error types.
src/signalforge/ingest/reader.py Adds read_test_files for singular .sql ingestion + dedupe and size-capped reads.
src/signalforge/ingest/parser.py Adds classify_singular_test using bounded resolver and skip/unrelated/associated routing.
src/signalforge/ingest/init.py Exports read_test_files and updates ingest module docs.
src/signalforge/draft/prompts.py Adds custom_sql catalogue+SCOPE text and renders BUSINESS RULES section from meta.
src/signalforge/draft/parser.py Extends anchor-contract validation to handle custom_sql semantics.
src/signalforge/draft/models.py Adds CandidateTestCustomSQL variant and extends CandidateTest union.
src/signalforge/draft/config.py Adds custom_sql to VALID_TEST_TYPES and updates config docs.
src/signalforge/diff/models.py Adds ProposedTestFile and bumps DiffReport.audit_schema_version to 3 with proposed_test_files.
src/signalforge/diff/errors.py Adds DiffTestFile* writer errors for fail-closed .sql writing.
src/signalforge/diff/engine.py Emits proposed_test_files alongside proposed_yaml into DiffReport.
src/signalforge/diff/_renderers.py Renders proposed standalone test files in ANSI/Markdown outputs with safety stripping.
src/signalforge/diff/_emitter.py Skips custom_sql from YAML and emits ProposedTestFile tuples for kept custom_sql tests.
src/signalforge/diff/init.py Exposes ProposedTestFile and new writer errors as part of public diff API.
src/signalforge/cli/prune_existing.py Adds --tests-dir ingestion of singular tests and merges into prune-existing candidate.
src/signalforge/cli/_helpers.py Maps new manifest and diff writer errors into exit-code taxonomy.
src/signalforge/_common/artifact_id.py Extends args-hash domain to include custom_sql (SQL + column in payload).
README.md Documents custom business-rule tests and how they’re pruned/written.
docs/prune-ops.md Documents pruning semantics for custom_sql including routing and sampling/fullscan rules.
docs/ingest-ops.md Documents singular tests/*.sql ingestion (contains a doc mismatch flagged in review).
docs/draft-ops.md Documents custom_sql drafting and business_rules meta path.
docs/diff-ops.md Documents diff sidecar schema bump and proposed_test_files shape.
docs/cli-ops.md Documents --force overwrite policy and --tests-dir for prune-existing.
docs/audits.md Updates audit schema bump notes for diff sidecar version 3.
CLAUDE.md Updates repo-level shipped-issues notes and public API description for #116.
.claude/rules/business-rule-tests.md New rules doc capturing invariants/DECs for custom_sql end-to-end behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/manifest/test_models.py
Comment thread docs/ingest-ops.md Outdated
Comment thread src/signalforge/draft/prompts.py Outdated
wjduenow and others added 2 commits May 23, 2026 11:57
Add targeted tests exercising the 19 feature-introduced lines Codecov
flagged uncovered on PR #117, all real behaviour assertions (no src
changes, no pragmas):

- draft/models.py:157 — CandidateTestCustomSQL.sql empty-string validator.
- manifest/loader.py:443 — resolve_source missing-schema/identifier branch.
- prune/compiler.py:682 — scope='sample' missing sample_size/bucket guard.
- prune/compiler.py:719-722 — single-table scope='full' + partition-filter
  derived-table wrap of the model's own table.
- diff/_emitter.py:313 — dedupe continue for two kept custom_sql decisions
  resolving to the same path.
- diff/_test_file_writer.py:275 — os.write-returns-0 short-write guard
  (mirrors tests/diff/test_sidecar.py).
- cli/generate.py:457 — _split_marked_sql no-marker fallback.
- cli/generate.py:482-483 — _existing_file_is_signalforge_generated OSError
  branch (unreadable path → False).
- ingest/reader.py:375-376 — read_test_files PathContainmentError wrap.
- ingest/reader.py:396 — non-file *.sql glob match skip.
- ingest/reader.py:432-433,441-442 — stat/read OSError → IngestSchemaParseError.

Full validation green; coverage 96.90%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/diff-ops.md (1)

80-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Update stale audit_schema_version references to 3 in the API/consumer sections.

This file now documents DiffReport.audit_schema_version: 3 later on, but these earlier lines still describe version 2. Please align them to avoid incorrect consumer gating guidance.

Also applies to: 194-195

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/diff-ops.md` at line 80, Update the stale documentation references for
the DiffReport Pydantic model so audit_schema_version is consistently documented
as Literal[3]; locate occurrences describing DiffReport.audit_schema_version
(and any adjacent gating guidance mentioning ">= 2" for consumers) and change
them to reflect audit_schema_version: Literal[3] and the correct consumer gate
(e.g., ">= 3"); ensure any related counts or notes added in issue `#50` that
reference audit_schema_version are updated as well (search for "DiffReport",
"audit_schema_version", and the string ">= 2" to find all spots, including the
other instances noted around lines 194-195).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/draft-ops.md`:
- Around line 295-296: The docs currently reference the internal symbol
signalforge.manifest.template.resolve_template_refs; update the text to
reference the public API symbol signalforge.manifest.resolve_template_refs
instead so users are directed to the supported resolver surface (replace any
occurrences of the internal module path with the public symbol name).

In `@docs/ingest-ops.md`:
- Around line 230-235: The docs state unsupported Jinja in singular `.sql` tests
is recorded with SkipReason `custom-or-generic-test`, but the implementation and
contract use `malformed-supported-test`; update the text to reference the
correct closed literal `malformed-supported-test` and the canonical construct
`SkippedTest(reason="malformed-supported-test")`, and ensure the sentence that
mentions `IngestResult.skipped` and the detail message reflects this exact
SkipReason value rather than `custom-or-generic-test`.

In `@src/signalforge/cli/prune_existing.py`:
- Around line 179-185: The help text for the --tests-dir option is misleading
about missing-directory behavior; update the help string in
src/signalforge/cli/prune_existing.py so it explicitly states that if the
default <project_dir>/tests is absent the CLI will silently ignore test files,
but if a user supplies --tests-dir and that directory does not exist the command
will fail with an error; reference the --tests-dir option name and adjust the
wording to make this distinction clear.

In `@src/signalforge/diff/__init__.py`:
- Around line 21-24: Update the module docstring to match the current public
API: change the Tier description to the current number/values (replace the
outdated "3-value `Tier`" text) and update the DiffError summary to list the
nine exported error classes (instead of the old "seven-class `DiffError`
hierarchy"), and add mention of the `kept-uncertain` export and the changed
behavior of `ProposedTestFile` described in the diff so the bullets accurately
reflect the symbols exported from this module.

In `@src/signalforge/draft/prompts.py`:
- Around line 169-181: The prompt contains a conflicting scope rule: the
sentence "Propose only {allowed_scope} tests" overrides later instructions to
generate `custom_sql` tests and can suppress those even when BUSINESS RULES are
present; change the wording so the allowed-scope constraint is conditional
(e.g., "Propose only {allowed_scope} tests unless BUSINESS RULES are present")
or add an explicit exception that `custom_sql` tests must be produced when a
BUSINESS RULES section exists; ensure the text referencing `custom_sql` tests,
`column`, and the BUSINESS RULES section remains and is applied as an override
to the {allowed_scope} restriction so `custom_sql` generation is not suppressed.

In `@src/signalforge/ingest/reader.py`:
- Around line 439-445: The except block in the reader code currently only
catches OSError when calling sql_path.read_text(encoding="utf-8"), allowing
UnicodeDecodeError to escape; update the exception handling in the
function/method that contains this call so that it also catches
UnicodeDecodeError (or a broader IOError/Exception that includes decoding
failures) and re-raises IngestSchemaParseError with the original exception as
cause (preserving the existing message format and cause=exc, and keeping the
"from exc" chaining) so decoding errors are converted into
IngestSchemaParseError as intended.

In `@src/signalforge/manifest/template.py`:
- Around line 147-155: The current extraction using _QUOTED_ARG_RE on arglist
treats quoted keyword values as positional (e.g., "'orders', version='1'" yields
["orders","1"]); change the parsing so only quoted positional args are captured:
skip quoted fragments that are part of a kwarg assignment (i.e., preceded by an
identifier and '='). Update the extraction around args =
_QUOTED_ARG_RE.findall(arglist) to filter or use a regex that matches quoted
strings not immediately preceded by a word and '=' (so name = first positional
quoted arg, package = second positional quoted arg if present) before calling
manifest.resolve_ref(name, package=package).qualified_name.

In `@src/signalforge/prune/compiler.py`:
- Around line 680-700: The current rewrite only replaces occurrences of
own_qualified in resolved_sql, so when own_qualified is absent the custom SQL
silently runs against the wrong source; update _compile_test to fail-closed by
detecting when scope == "sample" (and similarly materialized path) and
resolved_sql does not contain own_qualified: either (a) substitute any
engine-aliased self-reference by replacing the {{ this }}/own table marker with
the effective table_ref before calling _render_sample_cte, or (b raise a
ValueError if binding cannot be performed; ensure this check references
own_qualified, resolved_sql, table_ref, sampled_sql and calls _render_sample_cte
with own_table_quoted only after successful substitution so the sample CTE is
always prepended to the SQL (apply same logic to the materialized path code that
returns f"{cte} {sampled_sql}").

In `@tests/manifest/test_models.py`:
- Around line 201-208: The test currently picks an arbitrary source via
next(iter(raw["sources"].values())) which makes assertions flaky; instead
iterate raw["sources"].values() and choose the specific source deterministically
(e.g., find the dict where source_name == "raw" and name == "users" or where
unique_id matches the expected pattern) before calling Source.model_validate;
update the variable assignment that uses _load_fixture and src so the test
asserts against that found source rather than an arbitrary first element.

---

Outside diff comments:
In `@docs/diff-ops.md`:
- Line 80: Update the stale documentation references for the DiffReport Pydantic
model so audit_schema_version is consistently documented as Literal[3]; locate
occurrences describing DiffReport.audit_schema_version (and any adjacent gating
guidance mentioning ">= 2" for consumers) and change them to reflect
audit_schema_version: Literal[3] and the correct consumer gate (e.g., ">= 3");
ensure any related counts or notes added in issue `#50` that reference
audit_schema_version are updated as well (search for "DiffReport",
"audit_schema_version", and the string ">= 2" to find all spots, including the
other instances noted around lines 194-195).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1ad77de0-3b39-4f7c-9df8-45e14f462388

📥 Commits

Reviewing files that changed from the base of the PR and between 9f5face and 8cbd573.

📒 Files selected for processing (78)
  • .claude/rules/business-rule-tests.md
  • CLAUDE.md
  • README.md
  • docs/audits.md
  • docs/cli-ops.md
  • docs/diff-ops.md
  • docs/draft-ops.md
  • docs/ingest-ops.md
  • docs/prune-ops.md
  • plans/super/116-business-rule-tests.md
  • src/signalforge/_common/artifact_id.py
  • src/signalforge/cli/_helpers.py
  • src/signalforge/cli/generate.py
  • src/signalforge/cli/prune_existing.py
  • src/signalforge/diff/__init__.py
  • src/signalforge/diff/_emitter.py
  • src/signalforge/diff/_renderers.py
  • src/signalforge/diff/_test_file_writer.py
  • src/signalforge/diff/engine.py
  • src/signalforge/diff/errors.py
  • src/signalforge/diff/models.py
  • src/signalforge/draft/config.py
  • src/signalforge/draft/models.py
  • src/signalforge/draft/parser.py
  • src/signalforge/draft/prompts.py
  • src/signalforge/ingest/__init__.py
  • src/signalforge/ingest/parser.py
  • src/signalforge/ingest/reader.py
  • src/signalforge/manifest/__init__.py
  • src/signalforge/manifest/errors.py
  • src/signalforge/manifest/loader.py
  • src/signalforge/manifest/models.py
  • src/signalforge/manifest/template.py
  • src/signalforge/prune/compiler.py
  • src/signalforge/prune/engine.py
  • src/signalforge/warehouse/_sql_safety.py
  • tests/cli/_e2e_helpers.py
  • tests/cli/test_5_surface_parity_force.py
  • tests/cli/test_e2e_business_rules.py
  • tests/cli/test_e2e_helpers.py
  • tests/cli/test_exit_codes.py
  • tests/cli/test_generate.py
  • tests/cli/test_prune_existing.py
  • tests/diff/_snapshot_inputs.py
  • tests/diff/test_artifact_id.py
  • tests/diff/test_drift_detector.py
  • tests/diff/test_emitter.py
  • tests/diff/test_engine.py
  • tests/diff/test_models.py
  • tests/diff/test_public_api.py
  • tests/diff/test_renderers.py
  • tests/diff/test_snapshot_fixtures.py
  • tests/diff/test_test_file_writer.py
  • tests/draft/test_drift_detector.py
  • tests/draft/test_exclude_tests.py
  • tests/draft/test_parser.py
  • tests/draft/test_prompts.py
  • tests/fixtures/diff/diff_report_v1.json
  • tests/fixtures/diff/full_with_grade.json
  • tests/fixtures/diff/proposed_test_files.ansi
  • tests/fixtures/diff/proposed_test_files.md
  • tests/fixtures/draft/candidate_schema_v1.json
  • tests/fixtures/e2e_helpers/happy/.signalforge/diff.json
  • tests/fixtures/ingest/custom_sql_files/assert_customers_have_email.sql
  • tests/fixtures/ingest/custom_sql_files/assert_orders_amount_positive.sql
  • tests/fixtures/ingest/custom_sql_files/assert_orders_with_macro.sql
  • tests/fixtures/prune/compiled_sql/custom_sql.sql
  • tests/fixtures/prune/compiled_sql/custom_sql_fullscan.sql
  • tests/fixtures/prune/compiled_sql/custom_sql_sample.sql
  • tests/ingest/test_test_files.py
  • tests/llm/test_prompt_cache_stability.py
  • tests/manifest/test_models.py
  • tests/manifest/test_resolve.py
  • tests/manifest/test_template.py
  • tests/prune/test_compiler.py
  • tests/prune/test_engine.py
  • tests/test_audit_completeness.py
  • tests/warehouse/test_models.py

Comment thread docs/draft-ops.md Outdated
Comment thread docs/ingest-ops.md Outdated
Comment thread src/signalforge/cli/prune_existing.py
Comment thread src/signalforge/diff/__init__.py
Comment thread src/signalforge/draft/prompts.py Outdated
Comment thread src/signalforge/ingest/reader.py
Comment thread src/signalforge/manifest/template.py Outdated
Comment thread src/signalforge/prune/compiler.py
Comment thread tests/manifest/test_models.py Outdated
wjduenow and others added 3 commits May 23, 2026 12:09
Fix nine review findings across source, tests, and docs:

1. manifest/template.py: _resolve_ref_args split on commas + drop kwargs so
   ref('m', version='1') resolves model 'm', not '1'.
2. ingest/reader.py: _read_sql_file catches UnicodeDecodeError → typed
   IngestSchemaParseError instead of an untyped escape.
3. draft/prompts.py: custom_sql now participates in exclude_tests filtering;
   catalogue line + SCOPE instruction emitted only when allowed; SCOPE phrase
   reads "..., plus custom_sql". _PROMPT_VERSION rotates to c9e7ee1f6f465933.
4. prune/compiler.py: _compile_custom_sql fails closed (kept-without-evidence)
   when {{ this }} can't bind to the effective sample/materialised table.
5. tests/manifest/test_models.py: deterministic source select + parenthesised
   relation_name assertion (was always-truthy).
6. docs/ingest-ops.md: unsupported-Jinja singular .sql skip reason is
   malformed-supported-test, not custom-or-generic-test.
7. docs/draft-ops.md: public signalforge.manifest.resolve_template_refs.
8. cli/prune_existing.py: --tests-dir help clarifies only the DEFAULT dir is
   silently skipped; explicit missing --tests-dir fails loud.
9. diff/__init__.py: docstring now lists 4-value Tier (kept-uncertain) and the
   nine-class DiffError hierarchy.

Regression tests added for items 1-4; tests adjusted for item 3/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…CI 3.12)

test_read_test_files_stat_oserror_raises_parse_error keyed on the
follow_symlinks kwarg to distinguish is_file()'s stat from the size-check
stat — that distinction is 3.13-only, so the patched OSError escaped during
is_file() on 3.12. Decouple by forcing is_file() True for the target and
letting only the size-check stat() raise. Verified on 3.11/3.12/3.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow

Copy link
Copy Markdown
Owner Author

PR Review Summary

All CodeRabbit + Copilot findings addressed (12 threads). No false positives — every item was a genuine issue and is fixed, with regression tests where applicable. Fixes in ba65e2b (US-021) and a CI follow-up in fd6da2a.

Fixed (12 items)

File Issue Fix
manifest/template.py ref('m', version='1') parsed the kwarg value as positional → resolved name to '1' Split arglist, drop key= kwargs, take only positional quoted args; tests added
ingest/reader.py UnicodeDecodeError from read_text escaped untyped Catch (OSError, UnicodeDecodeError)IngestSchemaParseError; non-UTF-8 test added
draft/prompts.py (×2) exclude_tests:['custom_sql'] still prompted for it (→ parser fail-loud); SCOPE line contradicted the custom_sql instruction custom_sql now participates in exclude_tests filtering; SCOPE reads consistently; _PROMPT_VERSION rotated + cache snapshot updated; tests added
prune/compiler.py sample/materialised paths ran against the wrong table when {{ this }} wasn't in the resolved SQL Fail closed → _InvalidIdentifier (kept-without-evidence); tests added
tests/manifest/test_models.py x == y or z assertion always-truthy + non-deterministic source selection Deterministic source pick + parenthesised assertion pinned to expected
docs/ingest-ops.md Wrong SkipReason (custom-or-generic-test) Corrected to malformed-supported-test
docs/draft-ops.md Referenced internal module path Use public signalforge.manifest.resolve_template_refs
cli/prune_existing.py --tests-dir help implied any missing dir is skipped Clarified: only the default is skipped; explicit missing dir fails loud
diff/__init__.py Stale docstring (3-value Tier, 7 errors) Updated to 4-value Tier + 9-class DiffError
tests/ingest/test_test_files.py CI: 3.12 failure — stat() monkeypatch keyed on a 3.13-only follow_symlinks kwarg Decoupled from is_file()'s stat; verified on 3.11/3.12/3.13 (fd6da2a)

False Positives (0 items)

None.

Full validation green (ruff, pyright, pytest 2173 passed); verified the version-specific fix on Python 3.12 locally.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 79 out of 79 changed files in this pull request and generated 4 comments.

Comment thread docs/draft-ops.md Outdated
Comment thread docs/draft-ops.md Outdated
Comment thread src/signalforge/ingest/parser.py
Comment thread src/signalforge/ingest/parser.py
wjduenow and others added 2 commits May 25, 2026 10:38
Item 1 (Major) — ingest/parser.py classify_singular_test: a singular test
referencing THIS model AND an unresolvable/ambiguous OTHER ref()/source()
was silently dropped when the whole-body resolve raised. Added bounded
per-expression heuristic _raw_sql_references_target (reuses template.py
regexes + _resolve_ref_args; {{ this }} or a ref() resolving to the target
associates). On the resolver-error branch, when this model is referenced we
now carry the RAW unresolved SQL into a CandidateTestCustomSQL so the prune
compiler routes it (requires-future-data / kept-without-evidence, US-019);
only a genuinely-unrelated body returns None. source() is intentionally not
checked — a singular test's target is always a model, never a source.

Item 2 (Minor) — replaced the raw `target not in resolved` substring check
with a word-boundary match (_references_qualified_name) so the target name
inside a comment/string or as a fragment of a longer dotted identifier no
longer false-associates.

Item 3 (Codecov) — added tests/manifest/test_template.py cases for an empty
arg-list fragment (trailing / interior comma) in ref(); template.py now 100%
(the empty-token continue at line 159 is covered).

Item 4 (docs) — docs/draft-ops.md: custom_sql CAN now be excluded via
DraftConfig.exclude_tests (US-021; it is in VALID_TEST_TYPES, the prompt
omits its catalogue/SCOPE blocks when excluded and the parser rejects it
if the LLM defies that). Corrected both the §custom_sql note and the
exclude_tests field description + YAML comment.

Regression tests added in tests/ingest/test_test_files.py (Items 1+2) and
tests/manifest/test_template.py (Item 3). Full suite green (2183 passed),
coverage 96.93%, docs build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow

Copy link
Copy Markdown
Owner Author

PR Review Summary (round 2)

Round-2 CodeRabbit + Copilot findings on the US-021 push, all addressed (8d84455). No false positives.

File Issue Fix
ingest/parser.py A .sql referencing {{ this }} and an unresolvable other ref()/source() was silently dropped at ingest (resolver error → None) When resolution raises Ref/Ambiguous/Source errors, a bounded heuristic checks the raw SQL for a {{ this }} / target-ref() reference; if present, associate it carrying the raw SQL so the prune compiler routes it (requires-future-data / kept-without-evidence per US-019). Tests added.
ingest/parser.py Model association used a raw substring (target not in resolved) — false-matched the qualified name inside comments/strings or longer dotted identifiers Word-boundary match (?<![\w.])<target>(?![\w.]). Tests added.
manifest/template.py Codecov: 1 uncovered line (empty-token continue in _resolve_ref_args) Test exercising trailing/interior empty ref() arglist tokens → 100%.
docs/draft-ops.md (×2) Stale after US-021: claimed custom_sql can't be excluded Corrected — custom_sql is excludable via exclude_tests (in VALID_TEST_TYPES; prompt omits its blocks when excluded).

Full validation green (ruff, pyright, pytest 2183, coverage 96.93%); docs build clean; Codecov patch now ~100%.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants