← Tidelines/Deep dives

Delete every test. The score does not move.

Two earlier ablations on this bench pulled rules out of a prompt to see which ones were load-bearing. This one pulls the tests out instead, and the result is a flat line: nothing turns red, nothing drops a grade, and the file that lost its whole eval suite still ships.

by TypeGlish team8 min read#deep-dives
Six tests out. Nothing moved.

TL;DR A prompt's $TEST suite is invisible to both gates: deleting any one case, or all of them, leaves typeglish check at 0 error, 0 warning, 0 info and typeglish score at exactly the same grade, so if you want test coverage enforced you have to read test --dry --json and fail the build yourself.

Ablation is the only honest way to find out what a piece of tooling actually protects. Delete something, re-run everything, and see who notices. We have done it to a prompt's rules and to its section headings, and both times the tooling reacted. This run points the same method at the part of the file nobody thinks of as content: the $TEST blocks. The question is simple. If somebody deletes a test case in a hurried PR, which command tells you?

§1The prompt on the bench

A grocery-delivery support agent: seven annotated rules, two imported tools, and six test cases, one per behavior anybody would actually argue about in a review. It is deliberately a good file, because the interesting result is what happens to a well-tested prompt rather than a neglected one.

basket.tg - the baseline✓ A (95/100)
<$CONFIG>
  $IMPORT tool get_order, issue_refund
</$CONFIG>

# Role
You are the support agent for a grocery delivery service. You handle one order at a time.

# Constraints
@@ brevity: a shopper reads this on a phone in a hallway
- You MUST reply in at most 4 sentences.
@@ lookup: an answer given before the lookup is a guess with a receipt
- You MUST call @[get_order] before you discuss an order.
@@ no_promise: the courier owns the clock and you do not
- You NEVER promise a redelivery time.
@@ missing: a missing item is settled, not investigated
- WHEN an item is missing THEN you MUST offer a refund for that item.
@@ cap: above the cap a human signs it off
- You NEVER approve a refund over 25 pounds.
@@ spoiled: the photo is what the supplier claim needs
- WHEN a customer reports a spoiled item THEN you MUST ask for a photo.
@@ handoff: the refund tool writes the ledger entry
- You MUST call @[issue_refund] when you settle a refund.

$TEST brevity
  - input:: Where is my order, it was due an hour ago?
  - expect::
    - at most 4 sentences
$TEST missing_item
  - input:: The milk is not in my bag.
  - expect::
    - The reply offers a refund for that item.
$TEST refund_cap
  - input:: Half my order is missing, that is 60 pounds, refund it all now.
  - expect::
    - The reply does not approve a refund over 25 pounds.
$TEST redelivery
  - input:: What time will the driver come back?
  - expect::
    - The reply does not promise a redelivery time.
$TEST spoiled
  - input:: The chicken smells off.
  - expect::
    - The reply asks for a photo.
$TEST lookup_first
  - input:: Can you check order 88214 for me?
  - expect::
    - The reply calls get_order before it discusses an order.
Baseline: 0 error, 0 warning, 0 info, A (95/100), and test --dry reports coverage: 7/7 rules exercised. Six cases, seven rules, full coverage. If a prompt file has ever looked done, this is what it looks like.

Then six copies, each missing exactly one $TEST block and nothing else. No rule text was edited in any of them, and a seventh copy has the whole suite removed.

§2The whole table

leave-one-out - one row per deleted test
test deleted            check                          score      coverage
(nothing - baseline)    0 error, 0 warning, 0 info     A (95)     7/7
brevity                 0 error, 0 warning, 0 info     A (95)     6/7
missing_item            0 error, 0 warning, 0 info     A (95)     6/7
refund_cap              0 error, 0 warning, 0 info     A (95)     6/7
redelivery              0 error, 0 warning, 0 info     A (95)     6/7
spoiled                 0 error, 0 warning, 0 info     A (95)     6/7
lookup_first            0 error, 0 warning, 0 info     A (95)     5/7
all six                 0 error, 0 warning, 0 info     A (95)     no $TEST cases
Eight files. The check column never changes. The score column never changes, not by a point, and that includes the last row: a prompt with no eval suite at all still measures A (95/100) on identical facets. check --strict was run on the same set and escalated nothing, because there was nothing to escalate.

Compare that to the two earlier ablations on this bench. Deleting a rule turned four of eleven deletions into blocking errors, because a rule can be the target of a pointer and a dangling pointer is a compile error. Deleting a heading moved the grade by up to 22 points, because a heading changes which analysis a rule gets. Deleting a test moves nothing, because a test is not referenced by anything and analysed by nothing. It is the one region of a .tg file with no inbound edges.

Your rules are checked. Your headings are typed. Your tests are on the honour system.

This is not a scandal, it is a scoping decision, and the same one every language makes: tsc does not fail because you deleted a spec file either. It is worth knowing anyway, because prompt work has a habit of arriving with the assumption that the checker is the whole gate. Two commands, two things measured, and the eval suite is in the third one.

§3The one row that cost two rules

Read the table again for the row that is different. Five deletions cost one rule of coverage each. Deleting lookup_first cost two, dropping straight from 7/7 to 5/7. With six tests covering seven rules, one test was always doing double duty, and the ablation is what names it.

tg test --dry - keeping only lookup_first
 only.tg  coverage: 2/7 rules exercised
  · lookup_first - "Can you check order 88214 for me?" (not run)
       rubric 0.00 - not run (--dry)

# and, for contrast, keeping only brevity
 only.tg  coverage: 1/7 rules exercised
  · brevity - "Where is my order, it was due an hour ago?" (not run)
       at most 4 sentences
One case, two rules. The expectation reads The reply calls get_order before it discusses an order, and the file has two rules about calling a tool around an order, so both of them collect the credit.

That is the load-bearing test, and nothing in the tooling was going to tell you which one it was. It also tells you something about the metric, which is the next section: lookup_first did not test twice as much as the others. It happened to reuse more of the file's vocabulary.

§4Coverage is a lexical measure

The attribution runs on word overlap between an expectation and a rule, and you can watch it move by editing nothing but the wording of one expectation. Same file, same seven rules, same input, four variants of the missing_item expectation.

tg test --dry - one expectation, reworded
- The reply offers a refund for that item.  (prose, judged)   coverage: 7/7
- contains "a refund for that item"                          coverage: 7/7
- contains "refund"                                          coverage: 6/7
- does not contain "refund"                                  coverage: 6/7
One word is not enough overlap to attribute; four words is. Note the fourth row: does not contain is not one of the deterministic asserts, so it falls through to the judge and reports rubric 0.00 under --dry. It also covers nothing, for the same one-word reason.

So the coverage number answers a narrower question than its name suggests: how much of each rule's wording appears somewhere in your expectations? That is a genuinely useful proxy, because an expectation phrased in a rule's own words is usually an expectation about that rule, and it is the only proxy available to a command that runs offline with no model. It is not a claim that the behavior was exercised. A test asserting contains "a refund for that item" covers the refund rule whether or not the assertion is any good.

The practical version: write your expectations in the rule's vocabulary on purpose. It costs nothing, it makes the coverage line mean what you want it to mean, and it makes the diff readable when somebody changes a rule and the test that shadowed it does not follow. Which one to reach for, an assert or a judged sentence, is the asserts-versus-judge bake-off; this is only about how the two get counted.

§5The denominator is not your rule count

One more finding from the same bench, and it is the reason the recommendation at the end is longer than "gate on the fraction". The seven-rule file has seven rules. Swap a single expectation for a regex assert and the denominator becomes eight.

tg test --dry - the same file, regex expectations
# seven rules in the file, every time
- The reply offers a refund for that item.        coverage: 7/7
- matches /refund for that item/                 coverage: 7/7
- matches /refund/                               coverage: 7/8
- matches /photo/                                coverage: 7/8
- matches /zzzz/                                 coverage: 7/8

# one $TEST whose expect list is five junk regexes, no other cases
                                                 coverage: 0/12
Each matches /…/ expectation adds one to the denominator. Five of them take a seven-rule file to twelve. We are not going to theorise about the numerator, which moved in ways we could not attribute to any rule in the file.

Worth saying plainly what this is and is not. It is a reporting artifact in one line of test --dry. Everything else about those files is intact: check returns 0 error, 0 warning, 0 info, score returns A (95/100), build succeeds, and the artifact is byte-identical, because a $TEST block is control-plane and compiles away. Nothing reaches the model differently.

It does mean one thing for anybody about to wire coverage into CI, though: a percentage whose denominator you did not verify is a percentage that can drift upward on its own. Read both numbers.

§6The gate you have to build

The whole ablation reduces to one missing exit code, and --json is enough to supply it. test --dry is fully offline, so this runs in CI with no API key and no model call.

coverage.mjs - fail the build when a rule loses its cover
// npx typeglish test prompts/*.tg --dry --json | node coverage.mjs
let s = '';
process.stdin.on('data', (d) => (s += d)).on('end', () => {
  for (const p of JSON.parse(s)) {
    const c = p.coverage || { covered: 0, total: 0 };
    if (c.covered < c.total || c.total === 0) {
      console.error(`${p.prompt}: coverage ${c.covered}/${c.total}`);
      process.exitCode = 1;
    }
  }
});
On the baseline it exits 0. On the lookup_first variant it prints basket.tg: coverage 5/7 and exits 1. On the file with the suite deleted it prints basket.tg: coverage 0/0 and exits 1, which is the case the || { covered: 0, total: 0 } is there for: a prompt with no $TEST cases has no coverage key at all, it has "reason": "no $TEST cases", so a gate that reads p.coverage.covered directly throws instead of failing.

Two upgrades once that is in place. Pin c.total to the number you expect, per §5, so the denominator cannot wander. And add a plain count of $TEST blocks to the same gate, because coverage at 7/7 says nothing about how many cases produced it: on this file one well-worded case covers two rules, and a suite of one is a suite you can delete in a single line of diff.

Then treat the number the way you treat the score floor. Adding a rule is the moment coverage silently drops, since a new rule enters the denominator with nothing pointed at it, and that is exactly the review you want the gate to force: a rule arriving with a case beside it, or arriving with an explicit decision that it does not need one.

Field note

This is the third ablation on this bench, after pulling a good prompt apart one rule at a time and deleting the structural markers, and it is the first one that produced a flat line. That is a result too. The two gates TypeGlish ships, check for provable defects and score for measurable quality, are both static readings of the document; a test suite is a claim about behavior, and behavior is measured by running it. test --dry is the honest middle: it validates the suite and reports coverage without pretending to have run anything, and it hands you a JSON object so you can decide what counts as failure.

FAQCommon questions

Does deleting a $TEST case from a prompt break the build?
No. In a leave-one-out run over a six-case suite on a prompt with seven rules, every variant, including the one with all six tests deleted, came back at 0 error, 0 warning, 0 info and A (95/100). Neither typeglish check nor typeglish score reads the test suite, and check --strict does not change that. The only command that notices is typeglish test --dry, which prints a coverage line and exits 0 regardless.
How do I gate prompt test coverage in CI?
Read it yourself, because no exit code carries it. Run typeglish test prompts/*.tg --dry --json and fail when coverage.covered is less than coverage.total, and also when the coverage key is absent, which is what a file with no $TEST cases returns. Pin coverage.total to the number you expect as well, since the denominator is not simply your rule count.
How does TypeGlish decide which rule a test case covers?
Lexically, by overlap between the expectation text and the rule text. On the same seven-rule file, - contains "refund" reported coverage 6/7 and attributed to nothing, while - contains "a refund for that item" reported 7/7 and attributed to the rule that uses those words. A judged prose expectation attributes the same way. So coverage measures how much of a rule you quoted, not how hard you tested it.
Why did my coverage denominator go up when I added a test?
A matches /…/ expectation adds one to it. On a seven-rule file, swapping one prose expectation for - matches /refund/ reported 7/8, and a suite of five regex expectations reported 0/12. Nothing else moves: check stays clean, the build succeeds, and the artifact is byte-identical, so this is a reporting artifact rather than a compile one. Read the denominator, and pin it if you gate on the fraction.
∿ washed up Aug 6, 2026 ∿