watertight 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,7 +80,9 @@ a measured metric says where it was fetched, over what window, and when:
80
80
  ```
81
81
 
82
82
  A **derived** metric is recomputed on every compile — a total that doesn't add
83
- up, or a delta that doesn't recompute, is a build failure:
83
+ up, or a delta that doesn't recompute, is a build failure. Ops: `sum`, `avg`,
84
+ `pct_change`, `ratio` (a / b), `diff` (a − b) — the arithmetic reports actually
85
+ do, so hand-computed values don't have to masquerade as measured ones:
84
86
 
85
87
  ```json
86
88
  "lift": {
@@ -96,6 +98,20 @@ up, or a delta that doesn't recompute, is a build failure:
96
98
  }
97
99
  ```
98
100
 
101
+ An **assertion** is the arithmetic half of a conclusion — "under target",
102
+ "no cannibalisation" — judged on every compile. When `refresh` moves a number
103
+ far enough to flip it, the build breaks until the conclusion is rewritten:
104
+
105
+ ```json
106
+ "assertions": {
107
+ "met_target": { "op": "gte", "a": "revenue_total", "b": "revenue_target.lo" }
108
+ }
109
+ ```
110
+
111
+ Ops: `lt`, `lte`, `gt`, `gte`. Operands are metric keys, inline numbers, or —
112
+ for range metrics, always explicitly — `key.lo` / `key.hi`. Claims can cite
113
+ assertion keys as evidence.
114
+
99
115
  A **range** states a hypothesis honestly — plans are receipts too:
100
116
 
101
117
  ```json
@@ -117,11 +133,15 @@ Conversion moved from {{m:conversion_before}} to {{m:conversion_after}},
117
133
  a lift of {{m:lift}}. Revenue impact was {{m:revenue_total}}, within the
118
134
  hypothesised {{m:revenue_target}}.
119
135
 
120
- {{claim: the experiment met its success criteria | evidence: lift, revenue_total}}
136
+ {{claim: the experiment met its success criteria | evidence: lift, met_target}}
121
137
 
122
138
  Support runs {{raw:24/7}}.
123
139
  ```
124
140
 
141
+ `{{raw:}}` is the escape hatch, and it stays visible: the compile header counts
142
+ raw escapes, the markdown render lists them in an **Ungrounded** section, and
143
+ `--max-raw <n>` turns the count into a gate.
144
+
125
145
  Compile:
126
146
 
127
147
  ```bash
@@ -129,6 +149,7 @@ watertight report.md metrics.json # → report.html (self-contained, hover fo
129
149
  watertight . --format md # → grounded markdown (below)
130
150
  watertight . --check # verify only, write nothing (CI)
131
151
  watertight . --check --max-age 30 # also fail receipts older than 30 days
152
+ watertight . --strict # promote warnings (worded-number) to errors
132
153
  watertight . --json # machine-readable result
133
154
  ```
134
155
 
@@ -174,13 +195,36 @@ figure shows its receipt.
174
195
  | `bad-derived` | derived ops referencing missing or non-numeric inputs |
175
196
  | `empty-ir` | a report "grounded" in nothing |
176
197
  | `stale-metric` | with `--max-age <days>`: a receipt whose `fetched_at` is older than the budget — numbers age |
198
+ | `identifier-measurement` | an identifier whose value is shaped like a measurement (`"47%"`, `"1,428"`) — a number smuggled past the receipt requirement through the id door |
199
+ | `raw-budget` | with `--max-raw <n>`: more `{{raw:}}` escapes than the budget — the escape hatch stays boundable |
200
+ | `worded-number` | *(warn)* a quantity written in words — "세 배", "절반", "a million" — that no receipt can bind to; `--strict` makes it fail |
201
+ | `assertion-failed` | an `assertions` comparison that is false — the numbers no longer support the conclusion |
202
+ | `bad-assertion` / `duplicate-key` | a malformed assertion (unknown op, missing operand, range without `.lo`/`.hi`), or a key that is both metric and assertion |
203
+ | `unused-metric` | *(info)* a metric nothing references — drift signal, never fails, never promoted |
204
+
205
+ Severities: **error** fails the build and suppresses output; **warn** and **info**
206
+ are reported while the output still renders and the exit code stays 0.
177
207
 
178
208
  <br>
179
209
 
180
210
  ## Re-verification
181
211
 
182
- Numbers age. `watertight refresh .` re-fetches every metric whose source it
183
- can reach, rewrites `value` and `fetched_at`, and recomputes derived values:
212
+ **`watertight verify .`** re-fetches every reachable source and *compares* —
213
+ a stored value the source no longer returns is a `receipt-mismatch`, nothing
214
+ is written, and the run fails. This is the check that catches a plausible
215
+ receipt attached to a wrong value (the "AI remembered a number" failure) and
216
+ the natural CI companion to `--check`.
217
+
218
+ `watertight refresh .` is the writing counterpart: it re-fetches, rewrites
219
+ `value` and `fetched_at`, recomputes derived values — and when a changed
220
+ metric is cited as claim evidence, it says so:
221
+
222
+ ```
223
+ fallback_revenue_total: 1,428 → 45,200
224
+ ⚠ claim "수익 가설 미달" cites fallback_revenue_total — the number moved, review the conclusion
225
+ ```
226
+
227
+ Conclusions age like numbers do. Sources:
184
228
 
185
229
  - `csv` sources — `{ "type": "csv", "file": "data.csv", "cell": "B2" }`
186
230
  - `json` sources — `{ "type": "json", "file": "kpi.json", "path": "revenue.total" }`
package/SKILL.md CHANGED
@@ -53,13 +53,29 @@ number yet.
53
53
  - `source.type` is free-form (`sql`, `mixpanel`, `csv`, `hypothesis`, …);
54
54
  put enough alongside it that a stranger could re-fetch the value.
55
55
  - Numbers that *name* rather than *measure* (versions, flags, unit IDs) go
56
- in `identifiers`, not `metrics`.
56
+ in `identifiers`, not `metrics`. A measurement-shaped identifier ("47%",
57
+ "1,428") is a leak — the id door is not a receipt bypass.
57
58
  - Anything computed from other metrics must be `derived` — the compiler
58
59
  recomputes it and rejects mismatches beyond the value's own precision.
60
+ Ops: `sum`/`avg` (`{ "op": "sum", "of": ["k1", "k2"] }`), `pct_change`
61
+ (`{ "op": "pct_change", "before": "k1", "after": "k2" }`), `ratio`/`diff`
62
+ (`{ "op": "ratio", "a": "k1", "b": "k2" }` — a/b and a−b). Operands are metric
63
+ keys (preferred) or inline numbers. Never hand-compute one of these and
64
+ present it as measured — that is an unverified value.
59
65
  - `ratio` / `ratio-point` metrics require a `definition`. Ratios above 1.0
60
66
  are legal but the definition must explain the basis.
61
67
  - A hypothesis or plan figure is still a metric — source it as
62
- `{ "type": "hypothesis", ... }` pointing at the planning doc.
68
+ `{ "type": "hypothesis", ... }` pointing at the planning doc. It renders
69
+ visibly as an assumption, not a measurement.
70
+ - When a conclusion is a comparison ("under target"), state it as an
71
+ assertion and cite it as evidence — the compiler judges it every build:
72
+
73
+ ```json
74
+ "assertions": { "under_target": { "op": "lt", "a": "revenue_total", "b": "revenue_target.lo" } }
75
+ ```
76
+
77
+ Ops: lt, lte, gt, gte. Range metrics are referenced with an explicit
78
+ `.lo` / `.hi` accessor — never bare.
63
79
 
64
80
  **3. Write the narrative (`report.md`).**
65
81
  Never type a figure into prose. Reference it:
@@ -81,13 +97,19 @@ watertight . --check --max-age 30 # also fail receipts older than 30 d
81
97
  ```
82
98
 
83
99
  Every leak names its line (`report.md:31`), so fix them where they live.
100
+ A `worded-number` warning ("세 배", "half of") means a quantity is written in
101
+ words: if it has a basis, restate it as a metric or derived; if it is
102
+ rhetoric, mark it `{{raw:…}}`. Run with `--strict` to treat these as errors.
84
103
  Fix leaks by *going and getting the receipt* — running the query, opening
85
104
  the export — never by deleting the number, weakening the claim, or wrapping
86
105
  a measurement in `{{raw:}}` to silence the checker. A `derived-mismatch` is
87
106
  the tool telling you a stated total or delta does not follow from its
88
107
  inputs: recompute at the source and correct whichever side is wrong.
89
108
 
90
- **5. Re-verify later with `watertight refresh .`** — re-fetches csv/json
109
+ **5. Before shipping, run `watertight verify .`** — it re-fetches every
110
+ reachable source and fails on any stored value the source does not return.
111
+ A receipt you attached from memory will not survive this step; that is the
112
+ point. Then re-verify later with `watertight refresh .` — re-fetches csv/json
91
113
  sources, updates `fetched_at`, recomputes derived values, and names every
92
114
  metric it could *not* refresh. `command` sources run only under
93
115
  `--allow-commands`; never pass that flag on an IR you did not author.
@@ -105,5 +127,7 @@ exists to make that judgment inspectable, not to automate it away.
105
127
  ## Hard rules
106
128
 
107
129
  - Never invent, estimate, or "recall" a value into the IR. No source, no number.
130
+ - Never wrap a measurement in `{{raw:}}` to pass the compile — raw counts are
131
+ printed, disclosed in the render, and gated by `--max-raw`.
108
132
  - Never edit a `value` to make a `derived-mismatch` pass. Fix the inputs.
109
133
  - Real company data stays in private storage; fixtures and examples are fictional.
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
4
4
  import { compile } from './compile.js';
5
5
  import { basename } from 'node:path';
6
6
  import { refresh } from './refresh.js';
7
+ import { parseIr } from './ir.js';
7
8
  import { init } from './init.js';
8
9
  import { pathToFileURL } from 'node:url';
9
10
  const USAGE = `watertight — reports that hold water. Every number carries its receipt;
@@ -13,20 +14,26 @@ Usage
13
14
  watertight <dir> compile <dir>/report.md + <dir>/metrics.json → <dir>/report.html
14
15
  watertight <report> <ir> explicit file paths
15
16
  watertight refresh <dir> re-fetch metric values from their sources, update metrics.json
17
+ watertight verify <dir> re-fetch and COMPARE — a stored value its source no longer
18
+ returns is a receipt-mismatch; nothing is written
16
19
  watertight init [dir] scaffold a report.md + metrics.json pair that already holds water
17
20
 
18
21
  Options
19
22
  --max-age <days> compile only: fail any metric whose fetched_at is older —
20
23
  numbers age, and a stale receipt is quietly becoming a leak
24
+ --max-raw <n> compile only: fail when the report uses more than n {{raw:}}
25
+ escapes — the escape hatch must stay boundable
26
+ --strict compile only: promote warnings (worded-number) to errors.
27
+ info (unused-metric) is a drift signal and never promotes
21
28
  --format <html|md> output format (default: html). md is grounded markdown with a
22
29
  receipts appendix — pastes into Notion, PR bodies or Slack intact
23
30
  --out <file> where to write the output (default: report.html / report.grounded.md)
24
31
  --check verify only, write nothing
25
32
  --dry-run refresh only: show what would change, write nothing
26
- --fetchers <file> refresh only: a JS module of custom source adapters, e.g.
33
+ --fetchers <file> refresh/verify: a JS module of custom source adapters, e.g.
27
34
  export function mixpanel(source) { ... return value }
28
35
  Loading a module runs its code — only pass files you wrote or trust.
29
- --allow-commands refresh only: let "command" sources run shell (off by default —
36
+ --allow-commands refresh/verify: let "command" sources run shell (off by default —
30
37
  an IR from someone else's repo must not execute code on your machine)
31
38
  --json machine-readable result on stdout
32
39
  -v, --version print the version
@@ -45,6 +52,8 @@ function parseArgs(argv) {
45
52
  let fetchersPath;
46
53
  let format = 'html';
47
54
  let maxAgeDays;
55
+ let maxRaw;
56
+ let strict = false;
48
57
  let help = false;
49
58
  let version = false;
50
59
  for (let i = 0; i < args.length; i++) {
@@ -68,6 +77,15 @@ function parseArgs(argv) {
68
77
  process.exit(2);
69
78
  }
70
79
  }
80
+ else if (arg === '--strict')
81
+ strict = true;
82
+ else if (arg === '--max-raw') {
83
+ maxRaw = Number(args[++i]);
84
+ if (!Number.isFinite(maxRaw) || maxRaw < 0) {
85
+ console.error(`error: --max-raw needs a number, got "${args[i]}"`);
86
+ process.exit(2);
87
+ }
88
+ }
71
89
  else if (arg === '--json')
72
90
  json = true;
73
91
  else if (arg === '-h' || arg === '--help')
@@ -82,10 +100,10 @@ function parseArgs(argv) {
82
100
  else
83
101
  positional.push(arg);
84
102
  }
85
- const command = positional[0] === 'refresh' ? 'refresh' : positional[0] === 'init' ? 'init' : 'compile';
103
+ const command = ['refresh', 'init', 'verify'].includes(positional[0]) ? positional[0] : 'compile';
86
104
  if (command !== 'compile')
87
105
  positional.shift();
88
- return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays };
106
+ return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays, maxRaw, strict };
89
107
  }
90
108
  async function exists(path) {
91
109
  try {
@@ -129,15 +147,15 @@ async function main() {
129
147
  reportPath = join(dir, 'report.md');
130
148
  irPath = join(dir, 'metrics.json');
131
149
  }
132
- // refresh works on the IR alone — an IR-only directory is a legitimate workspace
133
- for (const path of opts.command === 'refresh' ? [irPath] : [reportPath, irPath]) {
150
+ // refresh/verify work on the IR alone — an IR-only directory is a legitimate workspace
151
+ for (const path of opts.command === 'refresh' || opts.command === 'verify' ? [irPath] : [reportPath, irPath]) {
134
152
  if (!(await exists(path))) {
135
153
  console.error(`Not found: ${path}`);
136
154
  console.error('Expected report.md and metrics.json — see --help.');
137
155
  process.exit(2);
138
156
  }
139
157
  }
140
- if (opts.command === 'refresh') {
158
+ if (opts.command === 'refresh' || opts.command === 'verify') {
141
159
  let fetchers;
142
160
  if (opts.fetchersPath) {
143
161
  const mod = await import(pathToFileURL(resolve(opts.fetchersPath)).href);
@@ -148,13 +166,62 @@ async function main() {
148
166
  process.exit(2);
149
167
  }
150
168
  }
151
- const r = await refresh(irPath, { allowCommands: opts.allowCommands, dryRun: opts.dryRun, fetchers });
169
+ const ir = JSON.parse(await readFile(irPath, 'utf8'));
170
+ const r = await refresh(irPath, {
171
+ allowCommands: opts.allowCommands,
172
+ dryRun: opts.dryRun || opts.command === 'verify',
173
+ fetchers,
174
+ });
175
+ if (opts.command === 'verify') {
176
+ // integrity first: a tampered derived (inputs unchanged, value edited) is not a
177
+ // cascade — parseIr recomputes every derivation, so verify alone catches it even
178
+ // in an IR-only directory where no compile ever runs
179
+ const integrity = parseIr(ir).leaks;
180
+ // a change on a MEASURED metric means the IR no longer matches its source —
181
+ // the receipt is real but the value is not. Derived drift is covered above.
182
+ const mismatches = r.changes.filter((c) => !ir.metrics?.[c.key]?.derived);
183
+ const failing = mismatches.length > 0 || integrity.length > 0 || r.errors.length > 0;
184
+ if (opts.json) {
185
+ console.log(JSON.stringify({ mismatches, integrity, skipped: r.skipped, errors: r.errors }, null, 2));
186
+ process.exit(failing ? 1 : 0);
187
+ }
188
+ for (const leak of integrity)
189
+ console.log(` ✗ [${leak.rule}] ${leak.message}`);
190
+ for (const m of mismatches) {
191
+ console.log(` ✗ [receipt-mismatch] metric "${m.key}" is ${m.before.toLocaleString()} in the IR, but its source now returns ${m.after.toLocaleString()}`);
192
+ }
193
+ for (const sk of r.skipped)
194
+ console.log(` ~ ${sk.key} skipped — ${sk.reason}`);
195
+ for (const e of r.errors)
196
+ console.error(` ✗ ${e.key}: ${e.message}`);
197
+ if (failing) {
198
+ console.log(`\n${mismatches.length} receipt mismatch(es), ${integrity.length} integrity leak(s), ${r.errors.length} fetch error(s) — the IR does not hold water`);
199
+ process.exit(1);
200
+ }
201
+ const verified = Object.keys(ir.metrics ?? {}).length - r.skipped.length;
202
+ console.log(`\nreceipts verified (${verified} checked, ${r.skipped.length} named as skipped) — nothing written`);
203
+ process.exit(0);
204
+ }
205
+ // conclusions age too: a claim citing a metric that just moved needs a re-read
206
+ const changedKeys = new Set(r.changes.map((c) => c.key));
207
+ const reviewClaims = [];
208
+ if (changedKeys.size > 0 && (await exists(reportPath))) {
209
+ const report = await readFile(reportPath, 'utf8');
210
+ for (const [, text, evidence] of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
211
+ const cited = evidence.split(',').map((k) => k.trim()).filter((k) => changedKeys.has(k));
212
+ if (cited.length > 0)
213
+ reviewClaims.push({ claim: text.trim(), evidence: cited });
214
+ }
215
+ }
152
216
  if (opts.json) {
153
- console.log(JSON.stringify(r, null, 2));
217
+ console.log(JSON.stringify({ ...r, reviewClaims }, null, 2));
154
218
  process.exit(r.errors.length > 0 ? 1 : 0);
155
219
  }
156
220
  for (const c of r.changes)
157
221
  console.log(` ${c.key}: ${c.before.toLocaleString()} → ${c.after.toLocaleString()}`);
222
+ for (const rc of reviewClaims) {
223
+ console.log(` ⚠ claim "${rc.claim}" cites ${rc.evidence.join(', ')} — the number moved, review the conclusion`);
224
+ }
158
225
  for (const s of r.skipped)
159
226
  console.log(` ~ ${s.key} skipped — ${s.reason}`);
160
227
  for (const e of r.errors)
@@ -164,19 +231,28 @@ async function main() {
164
231
  : `\n${r.changes.length} change(s)${opts.dryRun ? ' (dry run — nothing written)' : r.wrote ? ` — updated ${irPath}` : ''}`);
165
232
  process.exit(r.errors.length > 0 ? 1 : 0);
166
233
  }
167
- const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays });
234
+ const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays, maxRaw: opts.maxRaw, strict: opts.strict });
168
235
  const defaultName = opts.format === 'md' ? 'report.grounded.md' : 'report.html';
169
236
  const outPath = resolve(opts.out ?? join(reportPath, '..', defaultName));
170
237
  if (opts.json) {
171
238
  console.log(JSON.stringify({ version: pkg.version, ...result, output: undefined, wrote: result.output && !opts.check ? outPath : undefined }, null, 2));
172
239
  }
173
240
  else {
174
- console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers`);
241
+ const rawNote = result.grounded.raw > 0 ? ` · ${result.grounded.raw} raw escape(s)` : '';
242
+ console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers${rawNote}`);
175
243
  if (result.leaks.length > 0) {
176
- console.log(`\n${result.leaks.length} leak(s) the report does not hold water:\n`);
244
+ const count = (sev) => result.leaks.filter((l) => l.severity === sev).length;
245
+ const errors = count('error');
246
+ const parts = [
247
+ errors > 0 && `${errors} error(s)`,
248
+ count('warn') > 0 && `${count('warn')} warning(s)`,
249
+ count('info') > 0 && `${count('info')} info`,
250
+ ].filter(Boolean);
251
+ console.log(`\n${parts.join(', ')}${errors > 0 ? ' — the report does not hold water' : ''}:\n`);
252
+ const ICON = { error: '✗', warn: '⚠', info: 'ℹ' };
177
253
  for (const leak of result.leaks) {
178
254
  const where = leak.line !== undefined ? `${basename(reportPath)}:${leak.line} — ` : '';
179
- console.log(` [${leak.rule}] ${where}${leak.message}`);
255
+ console.log(` ${ICON[leak.severity]} [${leak.rule}] ${where}${leak.message}`);
180
256
  if (leak.detail)
181
257
  console.log(` ${leak.detail}`);
182
258
  }
@@ -191,7 +267,8 @@ async function main() {
191
267
  else if (result.output && opts.check && !opts.json) {
192
268
  console.log('holds water (check only — nothing written)\n');
193
269
  }
194
- process.exit(result.leaks.length > 0 ? 1 : 0);
270
+ // warn and info report without blocking; only an error (or a promoted warn) fails
271
+ process.exit(result.leaks.some((l) => l.severity === 'error') ? 1 : 0);
195
272
  }
196
273
  main().catch((err) => {
197
274
  console.error(err instanceof Error ? err.message : err);
package/dist/compile.js CHANGED
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { parseIr } from './ir.js';
3
3
  import { render } from './render.js';
4
4
  import { renderMarkdown } from './renderMd.js';
5
- import { scanMarkers, scanNakedNumbers, scanRefs } from './scan.js';
5
+ import { blankCode, scanMarkers, scanNakedNumbers, scanRefs, scanWordedNumbers } from './scan.js';
6
6
  export async function compile(reportPath, irPath, options = 'html') {
7
7
  const opts = typeof options === 'string' ? { format: options } : options;
8
8
  const format = opts.format ?? 'html';
@@ -27,16 +27,77 @@ export async function compile(reportPath, irPath, options = 'html') {
27
27
  }
28
28
  }
29
29
  }
30
+ // counted on code-blanked text so a fenced syntax example is neither a grounded
31
+ // metric nor a raw escape — the header, the scanner and the render must agree
32
+ const prose = blankCode(report);
30
33
  const grounded = {
31
- metrics: [...report.matchAll(/\{\{m:/g)].length,
32
- identifiers: [...report.matchAll(/\{\{id:/g)].length,
33
- claims: [...report.matchAll(/\{\{claim:/g)].length,
34
+ metrics: [...prose.matchAll(/\{\{m:/g)].length,
35
+ identifiers: [...prose.matchAll(/\{\{id:/g)].length,
36
+ claims: [...prose.matchAll(/\{\{claim:/g)].length,
37
+ raw: [...prose.matchAll(/\{\{raw:/g)].length,
34
38
  };
39
+ // the escape hatch must stay visible and boundable — wrapping everything in raw
40
+ // is how an agent games the compile instead of grounding the numbers
41
+ if (opts.maxRaw !== undefined && grounded.raw > opts.maxRaw) {
42
+ leaks.push({
43
+ severity: 'error',
44
+ rule: 'raw-budget',
45
+ message: `${grounded.raw} raw escape(s) exceed the budget of ${opts.maxRaw}`,
46
+ detail: 'Ground the numbers instead, or raise --max-raw deliberately.',
47
+ });
48
+ }
35
49
  leaks.push(...scanNakedNumbers(report));
36
50
  leaks.push(...scanMarkers(report));
37
- if (ir)
38
- leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers))));
39
- if (leaks.length > 0 || !ir)
51
+ leaks.push(...scanWordedNumbers(report));
52
+ if (ir) {
53
+ leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers)), new Set(Object.keys(ir.assertions))));
54
+ // a metric nothing references is drift, not a failure — info, and never promoted
55
+ const referenced = new Set();
56
+ for (const m of prose.matchAll(/\{\{m:([\w-]+)\}\}/g))
57
+ referenced.add(m[1]);
58
+ for (const [, , evidence] of prose.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
59
+ for (const k of evidence.split(',').map((k) => k.trim()).filter(Boolean))
60
+ referenced.add(k);
61
+ }
62
+ for (const m of Object.values(ir.metrics)) {
63
+ const d = m?.derived;
64
+ if (!d)
65
+ continue;
66
+ if (d.op === 'sum' || d.op === 'avg') {
67
+ if (Array.isArray(d.of))
68
+ for (const k of d.of)
69
+ referenced.add(k);
70
+ }
71
+ else if (d.op === 'pct_change') {
72
+ for (const k of [d.before, d.after])
73
+ if (typeof k === 'string')
74
+ referenced.add(k);
75
+ }
76
+ else {
77
+ for (const k of [d.a, d.b])
78
+ if (typeof k === 'string')
79
+ referenced.add(k);
80
+ }
81
+ }
82
+ for (const a of Object.values(ir.assertions)) {
83
+ for (const k of [a.a, a.b])
84
+ if (typeof k === 'string')
85
+ referenced.add(k.replace(/\.(lo|hi)$/, ''));
86
+ }
87
+ for (const key of Object.keys(ir.metrics)) {
88
+ if (!referenced.has(key)) {
89
+ leaks.push({ severity: 'info', rule: 'unused-metric', message: `metric "${key}" is in the IR but nothing references it`, detail: 'Drift signal — cite it, or remove it.' });
90
+ }
91
+ }
92
+ }
93
+ if (opts.strict) {
94
+ for (const leak of leaks)
95
+ if (leak.severity === 'warn')
96
+ leak.severity = 'error';
97
+ }
98
+ // only errors stop the build — warn and info are reported, and the output still renders
99
+ const hasErrors = leaks.some((l) => l.severity === 'error');
100
+ if (hasErrors || !ir)
40
101
  return { leaks, grounded };
41
102
  return { leaks, output: format === 'md' ? renderMarkdown(report, ir) : render(report, ir), grounded };
42
103
  }
package/dist/ir.js CHANGED
@@ -26,8 +26,19 @@ export function parseIr(raw) {
26
26
  const leaks = [];
27
27
  const root = (raw ?? {});
28
28
  const identifiers = {};
29
+ // an identifier names a thing (version, flag, unit id); a value shaped like a
30
+ // measurement is a number smuggled past the receipt requirement through the id door
31
+ const MEASUREMENT_SHAPE = /(%p?$)|(^\d{1,3}(,\d{3})+(\.\d+)?$)|(^\d+(\.\d+)?\s*(원|건|명|회)$)/;
29
32
  for (const [k, v] of Object.entries(root['identifiers'] ?? {})) {
30
33
  identifiers[k] = String(v);
34
+ if (MEASUREMENT_SHAPE.test(identifiers[k].trim())) {
35
+ leaks.push({
36
+ severity: 'error',
37
+ rule: 'identifier-measurement',
38
+ message: `identifier "${k}" is "${identifiers[k]}" — that is a measurement, not a name`,
39
+ detail: 'Move it to metrics with a source, or it is a number without a receipt.',
40
+ });
41
+ }
31
42
  }
32
43
  const metrics = root['metrics'] ?? {};
33
44
  if (Object.keys(metrics).length === 0) {
@@ -40,6 +51,10 @@ export function parseIr(raw) {
40
51
  return { leaks };
41
52
  }
42
53
  for (const [key, m] of Object.entries(metrics)) {
54
+ if (!m || typeof m !== 'object') {
55
+ leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" is not an object` });
56
+ continue;
57
+ }
43
58
  const valueOk = isFiniteNumber(m.value) ||
44
59
  (Array.isArray(m.value) && m.value.length === 2 && m.value.every(isFiniteNumber));
45
60
  if (!valueOk) {
@@ -62,33 +77,42 @@ export function parseIr(raw) {
62
77
  leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}": a range cannot be derived` });
63
78
  continue;
64
79
  }
65
- if (m.derived.op === 'sum') {
66
- let computed = 0;
80
+ const endpoint = (v) => {
81
+ if (typeof v === 'number')
82
+ return v;
83
+ const ref = metrics[v];
84
+ return ref && typeof ref.value === 'number' ? ref.value : undefined;
85
+ };
86
+ if (m.derived.op === 'sum' || m.derived.op === 'avg') {
87
+ if (!Array.isArray(m.derived.of) || m.derived.of.length === 0) {
88
+ leaks.push({
89
+ severity: 'error',
90
+ rule: 'bad-derived',
91
+ message: `metric "${key}": ${m.derived.op} needs a non-empty "of" array of metric keys`,
92
+ });
93
+ continue;
94
+ }
95
+ let total = 0;
67
96
  let broken = false;
68
97
  for (const ref of m.derived.of) {
69
98
  const part = metrics[ref];
70
99
  if (!part || typeof part.value !== 'number') {
71
- leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}" sums unknown or non-scalar metric "${ref}"` });
100
+ leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}" ${m.derived.op}s unknown or non-scalar metric "${ref}"` });
72
101
  broken = true;
73
102
  continue;
74
103
  }
75
- computed += part.value;
104
+ total += part.value;
76
105
  }
106
+ const computed = m.derived.op === 'avg' ? total / m.derived.of.length : total;
77
107
  if (!broken && !roundsTo(m.value, computed)) {
78
108
  leaks.push({
79
109
  severity: 'error',
80
110
  rule: 'derived-mismatch',
81
- message: `metric "${key}" is ${m.value}, but its parts sum to ${computed}`,
111
+ message: `metric "${key}" is ${m.value}, but its parts ${m.derived.op === 'avg' ? 'average' : 'sum'} to ${computed}`,
82
112
  });
83
113
  }
84
114
  }
85
115
  else if (m.derived.op === 'pct_change') {
86
- const endpoint = (v) => {
87
- if (typeof v === 'number')
88
- return v;
89
- const ref = metrics[v];
90
- return ref && typeof ref.value === 'number' ? ref.value : undefined;
91
- };
92
116
  const before = endpoint(m.derived.before);
93
117
  const after = endpoint(m.derived.after);
94
118
  if (before === undefined || after === undefined || before === 0) {
@@ -109,6 +133,27 @@ export function parseIr(raw) {
109
133
  });
110
134
  }
111
135
  }
136
+ else if (m.derived.op === 'ratio' || m.derived.op === 'diff') {
137
+ const a = endpoint(m.derived.a);
138
+ const b = endpoint(m.derived.b);
139
+ if (a === undefined || b === undefined || (m.derived.op === 'ratio' && b === 0)) {
140
+ leaks.push({
141
+ severity: 'error',
142
+ rule: 'bad-derived',
143
+ message: `metric "${key}": ${m.derived.op} operands must be numbers or scalar metric keys (got ${m.derived.a}, ${m.derived.b})`,
144
+ });
145
+ continue;
146
+ }
147
+ const computed = m.derived.op === 'ratio' ? a / b : a - b;
148
+ if (!roundsTo(m.value, computed)) {
149
+ leaks.push({
150
+ severity: 'error',
151
+ rule: 'derived-mismatch',
152
+ message: `metric "${key}" is ${m.value}, but ${m.derived.a} ${m.derived.op === 'ratio' ? '/' : '−'} ${m.derived.b} computes to ${computed.toFixed(4)}`,
153
+ detail: 'A derived value must be correctly rounded to its own precision.',
154
+ });
155
+ }
156
+ }
112
157
  else {
113
158
  // an op the verifier cannot recompute must never pass as verified
114
159
  leaks.push({
@@ -128,5 +173,66 @@ export function parseIr(raw) {
128
173
  }
129
174
  }
130
175
  }
131
- return { ir: { identifiers, metrics }, leaks };
176
+ // assertions: the comparisons conclusions rest on, judged on every compile.
177
+ // Malformed input is a leak, never a crash — the derived.of lesson applies here too.
178
+ const OPS = { lt: (a, b) => a < b, lte: (a, b) => a <= b, gt: (a, b) => a > b, gte: (a, b) => a >= b };
179
+ const assertions = {};
180
+ for (const [key, raw] of Object.entries(root['assertions'] ?? {})) {
181
+ if (metrics[key]) {
182
+ leaks.push({ severity: 'error', rule: 'duplicate-key', message: `"${key}" is both a metric and an assertion — one namespace, one meaning` });
183
+ continue;
184
+ }
185
+ const a = raw;
186
+ const validOperand = (v) => (typeof v === 'number' && Number.isFinite(v)) || typeof v === 'string';
187
+ if (!a || typeof a !== 'object' || typeof a.op !== 'string' || !validOperand(a.a) || !validOperand(a.b)) {
188
+ // null, booleans, objects — a guard that only checks undefined lets a serialisation
189
+ // slip silently disarm the very device meant to guard the conclusion
190
+ leaks.push({ severity: 'error', rule: 'bad-assertion', message: `assertion "${key}" needs op, and operands that are numbers or metric keys` });
191
+ continue;
192
+ }
193
+ if (!(a.op in OPS)) {
194
+ leaks.push({ severity: 'error', rule: 'bad-assertion', message: `assertion "${key}" has unknown op "${a.op}" — supported: lt, lte, gt, gte` });
195
+ continue;
196
+ }
197
+ const operand = (v, side) => {
198
+ if (typeof v === 'number' && Number.isFinite(v))
199
+ return v;
200
+ if (typeof v !== 'string')
201
+ return undefined;
202
+ const accessor = /^([\w-]+)\.(lo|hi)$/.exec(v);
203
+ if (accessor) {
204
+ const m = metrics[accessor[1]];
205
+ if (!m || !Array.isArray(m.value)) {
206
+ leaks.push({ severity: 'error', rule: 'bad-assertion', message: `assertion "${key}": ${side} "${v}" needs "${accessor[1]}" to be a range metric` });
207
+ return undefined;
208
+ }
209
+ return accessor[2] === 'lo' ? m.value[0] : m.value[1];
210
+ }
211
+ const m = metrics[v];
212
+ if (!m) {
213
+ leaks.push({ severity: 'error', rule: 'bad-assertion', message: `assertion "${key}": ${side} "${v}" is not a metric` });
214
+ return undefined;
215
+ }
216
+ if (Array.isArray(m.value)) {
217
+ // no magic default — the author says which end of the range they mean
218
+ leaks.push({ severity: 'error', rule: 'bad-assertion', message: `assertion "${key}": "${v}" is a range — say ${v}.lo or ${v}.hi` });
219
+ return undefined;
220
+ }
221
+ return m.value;
222
+ };
223
+ const left = operand(a.a, 'a');
224
+ const right = operand(a.b, 'b');
225
+ if (left === undefined || right === undefined)
226
+ continue;
227
+ assertions[key] = a;
228
+ if (!OPS[a.op](left, right)) {
229
+ leaks.push({
230
+ severity: 'error',
231
+ rule: 'assertion-failed',
232
+ message: `assertion "${key}" does not hold: ${left.toLocaleString()} ${a.op} ${right.toLocaleString()} is false`,
233
+ detail: 'The numbers no longer support this conclusion — rewrite it, or fix the inputs.',
234
+ });
235
+ }
236
+ }
237
+ return { ir: { identifiers, metrics, assertions }, leaks };
132
238
  }
package/dist/refresh.js CHANGED
@@ -77,8 +77,16 @@ export async function refresh(irPath, options) {
77
77
  // date-only, matching the style people write by hand — receipts should look uniform
78
78
  const now = new Date().toISOString().slice(0, 10);
79
79
  for (const [key, m] of Object.entries(raw.metrics)) {
80
- if (m.derived || !m.source || Array.isArray(m.value))
80
+ if (m.derived)
81
+ continue; // recomputed below, and verified by the compile pass
82
+ if (Array.isArray(m.value)) {
83
+ result.skipped.push({ key, reason: 'range values are not re-fetched' });
81
84
  continue;
85
+ }
86
+ if (!m.source) {
87
+ result.skipped.push({ key, reason: 'no source to fetch from' });
88
+ continue;
89
+ }
82
90
  try {
83
91
  let value;
84
92
  if (m.source.type === 'csv')
@@ -124,38 +132,58 @@ export async function refresh(irPath, options) {
124
132
  result.errors.push({ key, message });
125
133
  }
126
134
  };
135
+ const endpoint = (v) => {
136
+ if (typeof v === 'number')
137
+ return v;
138
+ const ref = raw.metrics[v];
139
+ return ref && typeof ref.value === 'number' ? ref.value : undefined;
140
+ };
141
+ // every recomputed value keeps the author's stated precision — refresh must not
142
+ // turn 0.155 into 0.1551724 or write float noise over an exact 0.3
143
+ const authorDecimals = (String(m.value).split('.')[1] ?? '').length;
144
+ const rounded = (v) => Number(v.toFixed(authorDecimals));
127
145
  let computed;
128
- if (m.derived.op === 'sum') {
129
- computed = m.derived.of.reduce((acc, ref) => {
146
+ if (m.derived.op === 'sum' || m.derived.op === 'avg') {
147
+ if (!Array.isArray(m.derived.of) || m.derived.of.length === 0) {
148
+ fail(`${m.derived.op} needs a non-empty "of" array — not recomputed`);
149
+ continue;
150
+ }
151
+ const total = m.derived.of.reduce((acc, ref) => {
130
152
  const part = raw.metrics[ref];
131
153
  return acc + (part && typeof part.value === 'number' ? part.value : NaN);
132
154
  }, 0);
133
- if (!Number.isFinite(computed)) {
134
- fail('sum references unknown or non-scalar metrics — not recomputed');
155
+ if (!Number.isFinite(total)) {
156
+ fail(`${m.derived.op} references unknown or non-scalar metrics — not recomputed`);
135
157
  continue;
136
158
  }
159
+ computed = rounded(m.derived.op === 'avg' ? total / m.derived.of.length : total);
137
160
  }
138
161
  else if (m.derived.op === 'pct_change') {
139
- const endpoint = (v) => {
140
- if (typeof v === 'number')
141
- return v;
142
- const ref = raw.metrics[v];
143
- return ref && typeof ref.value === 'number' ? ref.value : undefined;
144
- };
145
162
  const before = endpoint(m.derived.before);
146
163
  const after = endpoint(m.derived.after);
147
164
  if (before === undefined || after === undefined || before === 0) {
148
165
  fail('pct_change endpoints are unresolvable or zero — not recomputed');
149
166
  continue;
150
167
  }
151
- // keep the author's stated precision — refresh must not turn 0.155 into 0.1551724
152
- const decimals = (String(m.value).split('.')[1] ?? '').length;
153
- computed = Number(((after - before) / before).toFixed(decimals));
168
+ computed = rounded((after - before) / before);
169
+ }
170
+ else if (m.derived.op === 'ratio' || m.derived.op === 'diff') {
171
+ const a = endpoint(m.derived.a);
172
+ const b = endpoint(m.derived.b);
173
+ if (a === undefined || b === undefined || (m.derived.op === 'ratio' && b === 0)) {
174
+ fail(`${m.derived.op} operands are unresolvable — not recomputed`);
175
+ continue;
176
+ }
177
+ computed = rounded(m.derived.op === 'ratio' ? a / b : a - b);
154
178
  }
155
179
  else {
156
180
  fail(`unknown derived op "${m.derived.op}" — not recomputed`);
157
181
  continue;
158
182
  }
183
+ if (!Number.isFinite(computed)) {
184
+ fail('recomputation produced a non-finite value — the ledger is never overwritten with NaN');
185
+ continue;
186
+ }
159
187
  if (computed !== m.value) {
160
188
  if (!firstBefore.has(key))
161
189
  firstBefore.set(key, m.value);
package/dist/render.js CHANGED
@@ -1,3 +1,16 @@
1
+ /** How much a reader should trust the number: measured from a source, recomputed, or assumed. */
2
+ export function metricKind(m) {
3
+ if (m.derived)
4
+ return 'derived';
5
+ if (m.source?.type === 'hypothesis')
6
+ return 'assumption';
7
+ return 'measured';
8
+ }
9
+ const URL_RE = /https?:\/\/[^\s<>"')]+/g;
10
+ /** Conservative autolinks for markdown receipts — scheme-prefixed URLs only. */
11
+ export function linkifyMd(text) {
12
+ return text.replace(URL_RE, (url) => `<${url}>`);
13
+ }
1
14
  /**
2
15
  * Code shows syntax, it does not state facts — a fenced example of {{m:…}} must render
3
16
  * verbatim, not substitute. Stash code regions before substitution, restore after.
@@ -33,7 +46,11 @@ export function receipt(m, includeDefinition = true) {
33
46
  const source = m.derived
34
47
  ? m.derived.op === 'sum'
35
48
  ? `= ${m.derived.of.join(' + ')} (recomputed)`
36
- : `= ${m.derived.before} ${m.derived.after} (recomputed)`
49
+ : m.derived.op === 'avg'
50
+ ? `= avg(${m.derived.of.join(', ')}) (recomputed)`
51
+ : m.derived.op === 'pct_change'
52
+ ? `= ${m.derived.before} → ${m.derived.after} (recomputed)`
53
+ : `= ${m.derived.a} ${m.derived.op === 'ratio' ? '/' : '−'} ${m.derived.b} (recomputed)`
37
54
  : [m.source?.type, ...Object.entries(m.source ?? {}).filter(([k]) => k !== 'type').map(([, v]) => String(v))].filter(Boolean).join(' · ');
38
55
  return [source, m.window, m.fetched_at && `fetched ${m.fetched_at}`, includeDefinition && m.definition].filter(Boolean).join(' · ');
39
56
  }
@@ -54,17 +71,32 @@ function markdown(src) {
54
71
  .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
55
72
  }
56
73
  export function render(report, ir) {
74
+ const used = [];
75
+ const keysUsed = (k) => {
76
+ if (ir.metrics[k] && !used.includes(k))
77
+ used.push(k);
78
+ };
57
79
  const { text: protectedReport, restore } = protectCode(escapeHtml(report));
58
80
  const grounded = restore(protectedReport
59
81
  .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
82
+ if (!used.includes(key))
83
+ used.push(key);
60
84
  const m = ir.metrics[key];
61
- return `<b class="w" title="${escapeHtml(receipt(m))}">${formatValue(m)}<sup>†</sup></b>`;
85
+ const assumption = metricKind(m) === 'assumption';
86
+ // an assumption is a different grade of receipt — the reader sees it without hovering
87
+ return `<b class="w${assumption ? ' a' : ''}" title="${escapeHtml(receipt(m))}">${formatValue(m)}<sup>${assumption ? '†ᵃ' : '†'}</sup></b>`;
62
88
  })
63
89
  .replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `<code>${escapeHtml(ir.identifiers[key])}</code>`)
64
90
  .replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => {
65
91
  const keys = evidence.split(',').map((k) => k.trim()).filter(Boolean);
66
92
  const receipts = keys
67
- .map((k) => `${k} = ${formatValue(ir.metrics[k])} (${receipt(ir.metrics[k])})`)
93
+ .map((k) => {
94
+ const a = ir.assertions[k];
95
+ if (a)
96
+ return `${k} = assertion (${a.a} ${a.op} ${a.b} — holds)`;
97
+ keysUsed(k);
98
+ return `${k} = ${formatValue(ir.metrics[k])} (${receipt(ir.metrics[k])})`;
99
+ })
68
100
  .join(' | ');
69
101
  return `<span class="c" title="${escapeHtml(receipts)}">${text.trim()}<sup>‡</sup></span>`;
70
102
  })
@@ -75,10 +107,23 @@ export function render(report, ir) {
75
107
  @media(prefers-color-scheme:dark){body{background:#111;color:#ddd}code{background:#222}}
76
108
  h1,h2,h3{line-height:1.3}
77
109
  .w{border-bottom:2px solid #4a9;cursor:help;font-weight:600}
110
+ .w.a{border-bottom-style:dotted}
78
111
  .c{border-bottom:2px dotted #4a9;cursor:help}
79
112
  code{background:#eee;padding:1px 5px;border-radius:4px;font-size:.9em}
80
113
  sup{font-size:.65em;color:#4a9}
81
114
  </style>
82
115
  <body>${markdown(grounded)}
83
- <hr><p style="color:#888;font-size:.85em">Compiled by watertight — every underlined figure carries its receipt (hover to see it).</p>`;
116
+ <hr><h3>Receipts (${used.length} metrics)</h3>
117
+ <ol style="font-size:.9em">
118
+ ${used
119
+ .map((key) => {
120
+ const m = ir.metrics[key];
121
+ const kind = metricKind(m);
122
+ const tag = kind === 'assumption' ? ' <em>(assumption — not measured)</em>' : '';
123
+ const line = escapeHtml(receipt(m)).replace(/https?:\/\/[^\s<>"')]+/g, (url) => `<a href="${url}">${url}</a>`);
124
+ return `<li><b>${escapeHtml(key)}</b> = ${formatValue(m)}${tag}<br><span style="color:#888">${line}</span></li>`;
125
+ })
126
+ .join('\n')}
127
+ </ol>
128
+ <p style="color:#888;font-size:.85em">Compiled by watertight — every underlined figure carries its receipt (hover to see it).</p>`;
84
129
  }
package/dist/renderMd.js CHANGED
@@ -1,4 +1,4 @@
1
- import { formatValue, protectCode, receipt } from './render.js';
1
+ import { formatValue, linkifyMd, metricKind, protectCode, receipt } from './render.js';
2
2
  const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹';
3
3
  const sup = (n) => `⁽${String(n).split('').map((d) => SUPERSCRIPT[Number(d)]).join('')}⁾`;
4
4
  /**
@@ -14,7 +14,10 @@ export function renderMarkdown(report, ir) {
14
14
  .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
15
15
  if (!used.includes(key))
16
16
  used.push(key);
17
- return `**${formatValue(ir.metrics[key])}** ${sup(used.indexOf(key) + 1)}`;
17
+ const m = ir.metrics[key];
18
+ const marker = sup(used.indexOf(key) + 1);
19
+ // an assumption reads differently at a glance: ⁽³ᵃ⁾, and the appendix says why
20
+ return `**${formatValue(m)}** ${metricKind(m) === 'assumption' ? marker.replace('⁾', 'ᵃ⁾') : marker}`;
18
21
  })
19
22
  .replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `\`${ir.identifiers[key]}\``)
20
23
  .replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => `**${text.trim()}** *(evidence: ${evidence.trim()})*`)
@@ -26,12 +29,44 @@ export function renderMarkdown(report, ir) {
26
29
  used.push(key);
27
30
  }
28
31
  }
32
+ // the report discloses its own escape hatches — a reader (and a reviewer) sees
33
+ // exactly which prose numbers carry no receipt
34
+ const raws = [...protectedReport.matchAll(/\{\{raw:([^}]*)\}\}/g)].map((m) => m[1].trim());
35
+ const rawSection = raws.length === 0
36
+ ? ''
37
+ : `\n\n### Ungrounded (${raws.length} raw escape${raws.length === 1 ? '' : 's'})\n\n${raws
38
+ .map((r) => `- ${r}`)
39
+ .join('\n')}`;
40
+ // assertions render their judgement, not just their name — the html reader gets it
41
+ // on hover, the markdown reader gets it here
42
+ const resolveOperand = (v) => {
43
+ if (typeof v === 'number')
44
+ return v;
45
+ const accessor = /^([\w-]+)\.(lo|hi)$/.exec(v);
46
+ const m = ir.metrics[accessor ? accessor[1] : v];
47
+ if (!m)
48
+ return undefined;
49
+ if (accessor && Array.isArray(m.value))
50
+ return accessor[2] === 'lo' ? m.value[0] : m.value[1];
51
+ return typeof m.value === 'number' ? m.value : undefined;
52
+ };
53
+ const assertionEntries = Object.entries(ir.assertions);
54
+ const assertionSection = assertionEntries.length === 0
55
+ ? ''
56
+ : `\n\n### Assertions (${assertionEntries.length})\n\n${assertionEntries
57
+ .map(([key, a]) => {
58
+ const left = resolveOperand(a.a);
59
+ const right = resolveOperand(a.b);
60
+ return `- **${key}**: ${left?.toLocaleString() ?? a.a} ${a.op} ${right?.toLocaleString() ?? a.b} — holds`;
61
+ })
62
+ .join('\n')}`;
29
63
  const appendix = used
30
64
  .map((key, i) => {
31
65
  const m = ir.metrics[key];
32
66
  const definition = m.definition ? ` — ${m.definition}` : '';
33
- return `${i + 1}. **${key}** = ${formatValue(m)}${definition}\n ${receipt(m, false)}`;
67
+ const tag = metricKind(m) === 'assumption' ? ' *(assumption — not measured)*' : '';
68
+ return `${i + 1}. **${key}** = ${formatValue(m)}${definition}${tag}\n ${linkifyMd(receipt(m, false))}`;
34
69
  })
35
70
  .join('\n');
36
- return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}\n`;
71
+ return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}${assertionSection}${rawSection}\n`;
37
72
  }
package/dist/scan.js CHANGED
@@ -2,14 +2,15 @@
2
2
  const blank = (s) => s.replace(/[^\n]/g, ' ');
3
3
  const lineAt = (text, index) => text.slice(0, index).split('\n').length;
4
4
  /** Code shows syntax, it does not state facts — refs and numbers inside it are exempt */
5
- const blankCode = (report) => report.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank);
5
+ export const blankCode = (report) => report.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank);
6
6
  /**
7
7
  * Find numbers in the narrative that are not bound to the IR. These are the leaks the
8
8
  * tool exists to catch: a typed, transcribed, or hallucinated figure reads exactly like
9
9
  * a real one, so none are allowed outside a reference.
10
10
  */
11
- export function scanNakedNumbers(report) {
12
- const stripped = report
11
+ /** The prose that remains once every non-statement region is blanked in place. */
12
+ function strippedProse(report) {
13
+ return report
13
14
  .replace(/\{\{(m|id):[\w-]+\}\}/g, blank)
14
15
  // a claim's prose stays scanned — only its syntax is blanked, so a number
15
16
  // smuggled into claim text is still a leak, at its true position
@@ -23,8 +24,12 @@ export function scanNakedNumbers(report) {
23
24
  .replace(/\]\([^)\s]*\)/g, blank) // markdown link targets — URLs locate, they do not measure
24
25
  .replace(/https?:\/\/\S+/g, blank) // bare URLs, same reason
25
26
  .replace(/\d{4}-\d{2}-\d{2}/g, blank) // ISO dates locate, they do not measure
27
+ .replace(/(?<![A-Za-z0-9가-힣])[A-Za-z]+\d[\w.\-]*/g, blank) // Q3, v6.109.0, iOS15 — names, not measurements
26
28
  .replace(/^#{1,6}(?= )/gm, blank) // heading markers only — heading TEXT is scanned, people summarise numbers there
27
29
  .replace(/^\s*\d+\.\s/gm, blank); // ordered-list markers
30
+ }
31
+ export function scanNakedNumbers(report) {
32
+ const stripped = strippedProse(report);
28
33
  const leaks = [];
29
34
  for (const m of stripped.matchAll(/\d[\d,.]*\s*(%p?|[가-힣]{1,2})?/g)) {
30
35
  const token = m[0].trim().replace(/[.,]+$/, '');
@@ -41,7 +46,7 @@ export function scanNakedNumbers(report) {
41
46
  return leaks;
42
47
  }
43
48
  /** Every reference in the narrative must resolve; a dangling one is authoring drift. */
44
- export function scanRefs(rawReport, metricKeys, idKeys) {
49
+ export function scanRefs(rawReport, metricKeys, idKeys, assertionKeys = new Set()) {
45
50
  const leaks = [];
46
51
  const report = blankCode(rawReport);
47
52
  for (const m of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
@@ -58,7 +63,7 @@ export function scanRefs(rawReport, metricKeys, idKeys) {
58
63
  });
59
64
  }
60
65
  for (const key of keys) {
61
- if (!metricKeys.has(key)) {
66
+ if (!metricKeys.has(key) && !assertionKeys.has(key)) {
62
67
  leaks.push({ severity: 'error', rule: 'unknown-ref', line, message: `claim "${text.trim()}" cites unknown metric "${key}"` });
63
68
  }
64
69
  }
@@ -107,3 +112,27 @@ export function scanMarkers(rawReport) {
107
112
  }
108
113
  return leaks;
109
114
  }
115
+ // Narrow on purpose: a false worded-number on 배송 or 건물 costs more trust than a
116
+ // missed 수사 표현. Longest alternatives first; boundaries block compound words.
117
+ // "한 번" is far more often rhetoric ("한 번 더", "한 번에") than a count — excluded;
118
+ // "두 번" and "몇 번" stay in
119
+ const WORDED_KO_COUNTED = /(?<![가-힣])((두|세|네|다섯|여섯|일곱|여덟|아홉|열|몇)\s?(배|건|명|번|곳|개)|한\s?(배|건|명|곳|개))(?![가-힣])/g;
120
+ const WORDED_KO_STANDALONE = /(?<![가-힣])(절반|과반|대다수|수십만|수백만|수천만|수억|수십|수백|수천|수만)(\s?(배|건|명|번|곳|개|원|회))?(?![가-힣])/g;
121
+ const WORDED_EN = /\b(doubled?|tripled?|halved)(?![\w-])|\b(half of|a million|millions of|thousands of|dozens of)\b/gi;
122
+ /** Quantities written in words — the digit scanner cannot see them, but a reader does. */
123
+ export function scanWordedNumbers(report) {
124
+ const stripped = strippedProse(report);
125
+ const leaks = [];
126
+ for (const re of [WORDED_KO_COUNTED, WORDED_KO_STANDALONE, WORDED_EN]) {
127
+ for (const m of stripped.matchAll(re)) {
128
+ leaks.push({
129
+ severity: 'warn',
130
+ rule: 'worded-number',
131
+ line: lineAt(stripped, m.index),
132
+ message: `"${m[0].trim()}" is a quantity in words — no receipt can bind to it`,
133
+ detail: 'If it has a basis, state it as {{m:…}} or a derived metric; if it is rhetoric, mark it {{raw:…}}.',
134
+ });
135
+ }
136
+ }
137
+ return leaks.sort((x, y) => (x.line ?? 0) - (y.line ?? 0));
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watertight",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Reports that hold water — every number carries its receipt, and ungrounded claims fail the build.",
5
5
  "type": "module",
6
6
  "bin": {