Skip to content

Commit 16150f8

Browse files
committed
test(native): count native answers per stage and close the review's gate holes
Every native stage now reports the work it answered, so a stage switched to Python makes its allow-list entries vanish and fails the gate. The scenario expected-model scan, declaration contexts, the macro memo, effective vars, preview stubs and type normalization count their Python paths. Seed goldens are required inside a recorded range, fixture and example goldens are checked by the shipped differential too, golden inputs trigger the differential, and a failing gate says how to record intended changes.
1 parent 3ba8040 commit 16150f8

61 files changed

Lines changed: 1850 additions & 119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/verify.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ jobs:
8181
;;
8282
esac
8383
case "${path}" in
84-
scripts/compiler_differential/*|scripts/run_compiler_differential.py|tests/*/scripts/compiler_differential/*|tests/goldens/*|src/sqlbuild/cli/commands/_helpers/compile/*|.github/workflows/verify.yml|Makefile)
84+
scripts/compiler_differential/*|scripts/run_compiler_differential.py|tests/*/scripts/compiler_differential/*|tests/goldens/*|tests/e2e/fixtures/*|website/examples/*|src/sqlbuild/cli/commands/_helpers/compile/*|.github/workflows/verify.yml|Makefile)
8585
compiler_differential=true
8686
;;
8787
esac
@@ -304,7 +304,7 @@ jobs:
304304
path: ${{ runner.temp }}/compiler-differential-wheel-sites.json
305305
if-no-files-found: warn
306306
retention-days: 30
307-
- name: Compare generated seeds and the failure corpus (python vs shipped native)
307+
- name: Compare fixtures, examples, generated seeds and the failure corpus (python vs shipped native)
308308
run: >-
309309
make compiler-differential-shipped
310310
COMPILER_DIFFERENTIAL_ARGS="--stage-captures --require-discovery-coverage

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,10 @@ compiler-differential:
304304
$(COMPILER_DIFFERENTIAL_GATES) $(COMPILER_DIFFERENTIAL_ARGS)
305305

306306
# python vs native-preview above covers every native stage; this keeps the shipped `native` default
307-
# (native discovery and rendering) covered on a bounded corpus.
307+
# (native discovery and rendering) and its goldens covered on the same corpus.
308308
compiler-differential-shipped:
309309
env PYTHONUNBUFFERED=1 uv run python -m scripts.run_compiler_differential \
310-
--engines python native --corpus seeds failures \
310+
--engines python native --corpus fixtures examples seeds failures \
311311
--jobs $(COMPILER_DIFFERENTIAL_JOBS) --seeds $(COMPILER_DIFFERENTIAL_SHIPPED_SEEDS) \
312312
$(COMPILER_DIFFERENTIAL_GATES) $(COMPILER_DIFFERENTIAL_ARGS)
313313

‎scripts/compiler_differential/README.md‎

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
`make compiler-differential` compiles fixtures, examples, generated seeds and the failure corpus
44
under two compiler engines and fails on the first differing artifact. `make
55
compiler-differential-shipped` does the same for the shipped `native` default. Both also enforce
6-
the two shared gates below.
6+
the shared gates below.
7+
8+
For ad hoc runs (one project, other seeds, other engines), call the script directly, for example
9+
`uv run python -m scripts.run_compiler_differential --corpus --project <dir> --engines python
10+
native`. The make targets fix the corpus the allow-list and goldens are recorded for.
711

812
## Shared gates for native ports
913

@@ -12,11 +16,19 @@ does not add its own parity harness, property suite or E2E file.
1216

1317
### Native-to-Python fallbacks and analysis deferrals
1418

15-
`native_fallbacks.toml` is the one allow-list of all work native code still hands back to Python.
16-
It covers both kinds of record:
17-
- shipped-stage fallbacks, from `report_native_fallback(site=NativeFallbackSite.<SITE>, kind=...)`;
19+
`native_fallbacks.toml` is the one allow-list of all work native code still hands back to Python,
20+
and of the work each native stage answers itself. It covers three kinds of record:
21+
- fallbacks, from `report_native_fallback(site=NativeFallbackSite.<SITE>, kind=...)` on every
22+
path where a native stage hands work to Python, or runs Python with no native path yet;
1823
- preview analysis-stage deferrals: every `analysis-deferrals-*.jsonl` record (analysis session,
19-
lineage, semantic checks, contracts).
24+
lineage, semantic checks, contracts);
25+
- native answers, from `report_native_answer(stage=NativeStage.<STAGE>, kind=...)` where a
26+
stage's native path produced the result: files, models, scans or calls. Their site is
27+
`<stage>.native`.
28+
29+
Counting only fallbacks cannot tell a stage that answers natively from one that no longer runs
30+
natively at all. The answer counts can: if a stage is switched to Python, its `<stage>.native`
31+
entries vanish and the gate says so.
2032

2133
Every entry names an engine, stage, site and kind, with its exact count per corpus. Use
2234
`max_counts` instead of `counts` only for a count that is not stable, and give the reason in a
@@ -26,7 +38,8 @@ The records cost nothing unless `SQLBUILD_ANALYSIS_RECORD_DIR` is set, which onl
2638
sets. `--native-fallbacks check` runs in both make targets and fails CI in both directions:
2739
- an entry, engine or corpus count that is not on the list fails;
2840
- a listed entry whose count changed or that no longer occurs also fails, and the message says to
29-
update or remove it, so the list only shrinks.
41+
update or remove it, so the fallback list only shrinks;
42+
- a vanished native answer fails as work that now runs in Python.
3043

3144
The counts hold for the make targets' corpus (`--seeds 12`); other seed ranges are refused.
3245

@@ -42,19 +55,24 @@ It deletes `tests/goldens/compiler`, then runs the two CI differentials with `--
4255
update --goldens update`. Commit the resulting `native_fallbacks.toml` and `tests/goldens/` diff
4356
in the same PR as the change that caused it.
4457

45-
- A port deletes the Python code it replaces in the same PR. Its entries then disappear from the
46-
list, and that removal is the port's test that the fallbacks are gone. When the last entry for
47-
a `NativeFallbackSite` goes, delete the member and its `report_native_fallback` call too.
58+
- A port deletes the Python code it replaces in the same PR. Its fallback entries then disappear
59+
from the list, and that removal is the port's test that the fallbacks are gone. Its answer
60+
counts usually rise. When the last entry for a `NativeFallbackSite` goes, delete the member
61+
and its `report_native_fallback` call too.
4862
- If the corpus does not reach a site yet, first add a failure case or seed feature that does.
4963
- A PR that merges after a list or golden change on main reruns `make compiler-baselines` after
5064
rebasing and commits the result.
5165

5266
**What a reviewer checks in the diff**
5367

5468
- `native_fallbacks.toml`:
55-
- Removed entries and lower counts are expected from ports.
56-
- Every added entry, higher count or new `max_counts` needs a stated reason in the PR body.
57-
Never add one just to make CI pass.
69+
- Removed fallback entries and lower fallback counts are expected from ports.
70+
- Every added fallback entry, higher fallback count or new `max_counts` needs a stated reason
71+
in the PR body. Never add one just to make CI pass.
72+
- A removed or lower `<stage>.native` answer count means less native work. It needs a reason
73+
in the PR body.
74+
- Code: every new path that hands work to Python calls `report_native_fallback`, and every new
75+
native path calls `report_native_answer` where its result is used.
5876
- Goldens:
5977
- Every changed golden is an intended output change, named in the PR body.
6078
- An output-neutral port changes no golden.
@@ -70,14 +88,20 @@ in the same PR as the change that caused it.
7088
- the compiled SQL, one array item per line;
7189
- the manifest without its volatile metadata and the SQL it repeats.
7290

73-
Project paths, the SQLBuild version, invocation ids and timings are masked.
91+
The project directory is masked as `<project>` and the harness work directory as `<work>`,
92+
whether or not the path is resolved. The SQLBuild version, invocation ids and timings are also
93+
masked.
94+
95+
Goldens hold outputs only, not stage captures: a seed's captures are about 690 KB, too large to
96+
review. Cache facts are covered by the incremental-versus-no-cache tests instead.
7497

7598
- `--goldens check` (in both make targets) compares every engine with the goldens, so an
7699
output-neutral port needs no new parity suite.
77100
- `make compiler-baselines` rewrites them from the python oracle. Run it only when output
78101
changes on purpose, and review the diff like code.
79-
- Seeds outside the recorded range have no golden and are skipped; any other project without a
80-
golden fails.
102+
- `tests/goldens/compiler/seed_range.toml` records the seed range the goldens were written for.
103+
Every seed in that range, and every fixture, example and failure case, must have a golden.
104+
Seeds outside the range (ad hoc runs with more seeds) are skipped.
81105
- Once the Python stages are deleted, goldens are rewritten from `native`.
82106

83107
### All-engine user-facing errors

‎scripts/compiler_differential/_helpers/comparing/project_comparison.py‎

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
from scripts.compiler_differential._helpers.coverage.analysis import project_analysis_kinds
1414
from scripts.compiler_differential._helpers.coverage.discovery import project_discovery_kinds
1515
from scripts.compiler_differential._helpers.coverage.render import project_render_kinds
16-
from scripts.compiler_differential._helpers.goldens.goldens import golden_differences
16+
from scripts.compiler_differential._helpers.goldens.goldens import (
17+
golden_differences,
18+
golden_path_masks,
19+
)
1720
from scripts.compiler_differential._helpers.running.execution import run_engine
1821
from scripts.compiler_differential.constants import (
1922
ERROR_SEVERITY,
2023
LEFT_SIDE,
21-
PROJECT_DIRECTORY,
2224
RIGHT_SIDE,
2325
WARNING_SEVERITY,
2426
)
@@ -72,7 +74,7 @@ def compare_project(*, project: CorpusProject, options: DifferentialOptions) ->
7274
runs=(left, right),
7375
golden_dir=options.golden_dir,
7476
mode=options.golden_mode,
75-
masked_paths=_masked_paths(case_dir=case_dir, work_dir=options.work_dir),
77+
masked_paths=golden_path_masks(case_dir=case_dir, work_dir=options.work_dir),
7678
)
7779
if options.golden_mode is not None and options.golden_dir is not None
7880
else ()
@@ -93,20 +95,6 @@ def compare_project(*, project: CorpusProject, options: DifferentialOptions) ->
9395
)
9496

9597

96-
def _masked_paths(*, case_dir: Path, work_dir: Path) -> tuple[str, ...]:
97-
"""The run's absolute paths, longest first, so goldens never mention a temporary directory."""
98-
99-
workspace: Path = case_dir / PROJECT_DIRECTORY
100-
paths: set[str] = {
101-
str(workspace),
102-
str(workspace.resolve()),
103-
str(work_dir),
104-
str(work_dir.resolve()),
105-
}
106-
ordered: list[str] = sorted(paths, key=lambda path: (-len(path), path))
107-
return tuple(ordered)
108-
109-
11098
def _materialized_source(*, project: CorpusProject, case_dir: Path) -> Path:
11199
if project.source_dir is not None:
112100
return project.source_dir

‎scripts/compiler_differential/_helpers/corpus/emitted_codes.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
first_diagnostic_message,
1313
)
1414
from scripts.compiler_differential._helpers.corpus.corpus import build_corpus
15+
from scripts.compiler_differential._helpers.goldens.goldens import golden_path_masks, masked_text
1516
from scripts.compiler_differential._helpers.running.execution import run_engine
1617
from scripts.compiler_differential.constants import (
1718
ANALYSIS_CODE_SCAN_ROOTS,
@@ -20,9 +21,7 @@
2021
DIAGNOSTIC_CODE_PATTERN,
2122
ERROR_SEVERITY,
2223
EXPECT_SUCCESS,
23-
GOLDEN_PATH_MASK,
2424
LEFT_SIDE,
25-
PROJECT_DIRECTORY,
2625
RENDER_CODE_SCAN_EXCLUDED,
2726
RENDER_RAISED_CODES,
2827
WARNING_SEVERITY,
@@ -201,7 +200,6 @@ def _emitted_codes(*, project: CorpusProject, options: DifferentialOptions) -> E
201200
options=options,
202201
)
203202
first: dict[str, object] = first_diagnostic(outcome=run.outcomes[0], severity=ERROR_SEVERITY)
204-
project_dir: str = str(case_dir / PROJECT_DIRECTORY)
205203
message: str | None = first_diagnostic_message(outcome=run.outcomes[0], severity=ERROR_SEVERITY)
206204
notes: object = first.get("notes")
207205
line: object = first.get("line")
@@ -210,7 +208,12 @@ def _emitted_codes(*, project: CorpusProject, options: DifferentialOptions) -> E
210208
errors=diagnostic_codes(outcome=run.outcomes[0], severity=ERROR_SEVERITY),
211209
warnings=diagnostic_codes(outcome=run.outcomes[0], severity=WARNING_SEVERITY),
212210
first_error_message=(
213-
None if message is None else message.replace(project_dir, GOLDEN_PATH_MASK)
211+
None
212+
if message is None
213+
else masked_text(
214+
text=message,
215+
masks=golden_path_masks(case_dir=case_dir, work_dir=options.work_dir),
216+
)
214217
),
215218
first_error_help=None if first.get("help") is None else str(first.get("help")),
216219
first_error_notes=tuple(str(note) for note in notes) if isinstance(notes, list) else (),

‎scripts/compiler_differential/_helpers/goldens/goldens.py‎

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import tomllib
67
from importlib.metadata import version
78
from pathlib import Path
89

@@ -16,15 +17,19 @@
1617
)
1718
from scripts.compiler_differential.constants import (
1819
GOLDEN_CORPUS_PREFIXES,
20+
GOLDEN_LABEL,
1921
GOLDEN_MANIFEST_DROPPED_KEYS,
2022
GOLDEN_MANIFEST_RESOURCE_SECTIONS,
2123
GOLDEN_MISSING_HINT,
2224
GOLDEN_MODE_CHECK,
23-
GOLDEN_OPTIONAL_CORPORA,
2425
GOLDEN_PATH_MASK,
2526
GOLDEN_RESOURCE_DROPPED_FIELDS,
27+
GOLDEN_SEED_CORPUS,
28+
GOLDEN_SEED_RANGE_FILE,
2629
GOLDEN_SUFFIX,
2730
GOLDEN_VERSION_MASK,
31+
GOLDEN_WORK_MASK,
32+
PROJECT_DIRECTORY,
2833
)
2934
from scripts.compiler_differential.models import (
3035
CommandOutcome,
@@ -43,7 +48,7 @@ def golden_differences(
4348
runs: tuple[EngineRun, ...],
4449
golden_dir: Path,
4550
mode: str,
46-
masked_paths: tuple[str, ...],
51+
masked_paths: tuple[tuple[str, str], ...],
4752
) -> list[Difference]:
4853
"""Check every run against the project's golden, or rewrite it from the first (oracle) run."""
4954

@@ -58,7 +63,9 @@ def golden_differences(
5863
_ = path.write_text(golden_text(payloads[0]), encoding="utf-8")
5964
return []
6065
if not path.is_file():
61-
if path.parent.name in GOLDEN_OPTIONAL_CORPORA:
66+
if path.parent.name == GOLDEN_SEED_CORPUS and not _seed_in_recorded_range(
67+
golden_dir=golden_dir, name=path.stem
68+
):
6269
return []
6370
return [
6471
Difference(
@@ -67,7 +74,7 @@ def golden_differences(
6774
location="file",
6875
left="<missing>",
6976
right=GOLDEN_MISSING_HINT,
70-
labels=("golden", "hint"),
77+
labels=(GOLDEN_LABEL, "hint"),
7178
)
7279
]
7380
golden: object = json.loads(path.read_text(encoding="utf-8"))
@@ -82,7 +89,7 @@ def golden_differences(
8289
location=divergence.location,
8390
left=divergence.left,
8491
right=divergence.right,
85-
labels=("golden", run.engine),
92+
labels=(GOLDEN_LABEL, run.engine),
8693
)
8794
)
8895
return found
@@ -97,7 +104,9 @@ def golden_path(*, golden_dir: Path, project: str) -> Path | None:
97104
return golden_dir / corpus / f"{name}{GOLDEN_SUFFIX}"
98105

99106

100-
def golden_payload(*, run: EngineRun, masked_paths: tuple[str, ...]) -> dict[str, object]:
107+
def golden_payload(
108+
*, run: EngineRun, masked_paths: tuple[tuple[str, str], ...]
109+
) -> dict[str, object]:
101110
"""Return a run's diagnostics, compiled SQL and slimmed manifest with run noise masked."""
102111

103112
return {
@@ -122,7 +131,9 @@ def golden_text(payload: dict[str, object]) -> str:
122131
return json.dumps(payload, indent=1, ensure_ascii=False) + "\n"
123132

124133

125-
def _command(*, outcome: CommandOutcome, masked_paths: tuple[str, ...]) -> dict[str, object]:
134+
def _command(
135+
*, outcome: CommandOutcome, masked_paths: tuple[tuple[str, str], ...]
136+
) -> dict[str, object]:
126137
stdout: str = _masked(text=outcome.stdout, masked_paths=masked_paths)
127138
try:
128139
report: dict[str, object] | None = as_json_object(json.loads(stdout))
@@ -141,7 +152,7 @@ def _command(*, outcome: CommandOutcome, masked_paths: tuple[str, ...]) -> dict[
141152
}
142153

143154

144-
def _manifest(*, text: str | None, masked_paths: tuple[str, ...]) -> object | None:
155+
def _manifest(*, text: str | None, masked_paths: tuple[tuple[str, str], ...]) -> object | None:
145156
if text is None:
146157
return None
147158
manifest: dict[str, object] = (
@@ -165,11 +176,50 @@ def _without_code(resource: object) -> object:
165176
}
166177

167178

168-
def _masked(*, text: str, masked_paths: tuple[str, ...]) -> str:
169-
for path in masked_paths:
170-
text = text.replace(path, GOLDEN_PATH_MASK)
179+
def _masked(*, text: str, masked_paths: tuple[tuple[str, str], ...]) -> str:
180+
text = masked_text(text=text, masks=masked_paths)
171181
return normalize_artifact_text(text).replace(_SQLBUILD_VERSION, GOLDEN_VERSION_MASK)
172182

173183

174184
def _lines(*, text: str) -> list[str]:
175185
return text.split("\n")
186+
187+
188+
def write_golden_seed_range(*, golden_dir: Path, seed_start: int, seeds: int) -> None:
189+
"""Record the seed range the goldens hold; every seed in it must keep its golden."""
190+
191+
golden_dir.mkdir(parents=True, exist_ok=True)
192+
_ = (golden_dir / GOLDEN_SEED_RANGE_FILE).write_text(
193+
f"seed_start = {seed_start}\nseeds = {seeds}\n", encoding="utf-8"
194+
)
195+
196+
197+
def _seed_in_recorded_range(*, golden_dir: Path, name: str) -> bool:
198+
path: Path = golden_dir / GOLDEN_SEED_RANGE_FILE
199+
if not path.is_file():
200+
return False
201+
recorded: dict[str, object] = tomllib.loads(path.read_text(encoding="utf-8"))
202+
start: int = int(str(recorded.get("seed_start", 0)))
203+
index: int = int(name.split("-", 1)[0])
204+
return start <= index < start + int(str(recorded.get("seeds", 0)))
205+
206+
207+
def golden_path_masks(*, case_dir: Path, work_dir: Path) -> tuple[tuple[str, str], ...]:
208+
"""`(path, token)` for the project and work directories, resolved or not, longest first."""
209+
210+
workspace: Path = case_dir / PROJECT_DIRECTORY
211+
masks: set[tuple[str, str]] = {
212+
(str(workspace), GOLDEN_PATH_MASK),
213+
(str(workspace.resolve()), GOLDEN_PATH_MASK),
214+
(str(work_dir), GOLDEN_WORK_MASK),
215+
(str(work_dir.resolve()), GOLDEN_WORK_MASK),
216+
}
217+
return tuple(sorted(masks, key=lambda mask: (-len(mask[0]), mask[0])))
218+
219+
220+
def masked_text(*, text: str, masks: tuple[tuple[str, str], ...]) -> str:
221+
"""Replace every masked path with its token, longest path first."""
222+
223+
for path, token in masks:
224+
text = text.replace(path, token)
225+
return text

0 commit comments

Comments
 (0)