watertight 0.5.0 → 0.7.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": {
@@ -122,6 +124,10 @@ hypothesised {{m:revenue_target}}.
122
124
  Support runs {{raw:24/7}}.
123
125
  ```
124
126
 
127
+ `{{raw:}}` is the escape hatch, and it stays visible: the compile header counts
128
+ raw escapes, the markdown render lists them in an **Ungrounded** section, and
129
+ `--max-raw <n>` turns the count into a gate.
130
+
125
131
  Compile:
126
132
 
127
133
  ```bash
@@ -164,22 +170,39 @@ figure shows its receipt.
164
170
 
165
171
  | check | catches |
166
172
  |---|---|
167
- | `naked-number` | any digit in prose not covered by a reference (dates, headings, code spans and `{{raw:}}` are exempt) |
173
+ | `naked-number` | any digit in prose not covered by a reference — heading text included; dates, URLs, code spans and `{{raw:}}` are exempt |
168
174
  | `unknown-ref` | `{{m:...}}` / `{{id:...}}` pointing at nothing |
169
175
  | `missing-field` | a measured metric without `source`, `window`, or `fetched_at` |
170
176
  | `definition-required` | a `ratio` / `ratio-point` metric with no stated basis — this is how a fill rate of 107% stays honest |
171
177
  | `derived-mismatch` | a `sum` that doesn't add up; a `pct_change` that doesn't recompute — to the stored value's own precision, so `0.161` passes as 16.1% but `0.15` for 15.5% fails |
172
178
  | `claim-without-evidence` | a `{{claim:}}` with no `evidence:` keys, or keys that don't exist |
179
+ | `malformed-marker` | anything still marker-shaped after every recognised form — a typo'd `{{claim:…\|evidnce:…}}` must fail, not render verbatim |
173
180
  | `bad-derived` | derived ops referencing missing or non-numeric inputs |
174
181
  | `empty-ir` | a report "grounded" in nothing |
175
182
  | `stale-metric` | with `--max-age <days>`: a receipt whose `fetched_at` is older than the budget — numbers age |
183
+ | `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 |
184
+ | `raw-budget` | with `--max-raw <n>`: more `{{raw:}}` escapes than the budget — the escape hatch stays boundable |
176
185
 
177
186
  <br>
178
187
 
179
188
  ## Re-verification
180
189
 
181
- Numbers age. `watertight refresh .` re-fetches every metric whose source it
182
- can reach, rewrites `value` and `fetched_at`, and recomputes derived values:
190
+ **`watertight verify .`** re-fetches every reachable source and *compares* —
191
+ a stored value the source no longer returns is a `receipt-mismatch`, nothing
192
+ is written, and the run fails. This is the check that catches a plausible
193
+ receipt attached to a wrong value (the "AI remembered a number" failure) and
194
+ the natural CI companion to `--check`.
195
+
196
+ `watertight refresh .` is the writing counterpart: it re-fetches, rewrites
197
+ `value` and `fetched_at`, recomputes derived values — and when a changed
198
+ metric is cited as claim evidence, it says so:
199
+
200
+ ```
201
+ fallback_revenue_total: 1,428 → 45,200
202
+ ⚠ claim "수익 가설 미달" cites fallback_revenue_total — the number moved, review the conclusion
203
+ ```
204
+
205
+ Conclusions age like numbers do. Sources:
183
206
 
184
207
  - `csv` sources — `{ "type": "csv", "file": "data.csv", "cell": "B2" }`
185
208
  - `json` sources — `{ "type": "json", "file": "kpi.json", "path": "revenue.total" }`
package/SKILL.md CHANGED
@@ -53,9 +53,12 @@ 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`, `pct_change`, `ratio`, `diff`. Never hand-compute one of
61
+ these and present it as measured — that is an unverified value.
59
62
  - `ratio` / `ratio-point` metrics require a `definition`. Ratios above 1.0
60
63
  are legal but the definition must explain the basis.
61
64
  - A hypothesis or plan figure is still a metric — source it as
@@ -87,7 +90,10 @@ a measurement in `{{raw:}}` to silence the checker. A `derived-mismatch` is
87
90
  the tool telling you a stated total or delta does not follow from its
88
91
  inputs: recompute at the source and correct whichever side is wrong.
89
92
 
90
- **5. Re-verify later with `watertight refresh .`** — re-fetches csv/json
93
+ **5. Before shipping, run `watertight verify .`** — it re-fetches every
94
+ reachable source and fails on any stored value the source does not return.
95
+ A receipt you attached from memory will not survive this step; that is the
96
+ point. Then re-verify later with `watertight refresh .` — re-fetches csv/json
91
97
  sources, updates `fetched_at`, recomputes derived values, and names every
92
98
  metric it could *not* refresh. `command` sources run only under
93
99
  `--allow-commands`; never pass that flag on an IR you did not author.
@@ -105,5 +111,7 @@ exists to make that judgment inspectable, not to automate it away.
105
111
  ## Hard rules
106
112
 
107
113
  - Never invent, estimate, or "recall" a value into the IR. No source, no number.
114
+ - Never wrap a measurement in `{{raw:}}` to pass the compile — raw counts are
115
+ printed, disclosed in the render, and gated by `--max-raw`.
108
116
  - Never edit a `value` to make a `derived-mismatch` pass. Fix the inputs.
109
117
  - Real company data stays in private storage; fixtures and examples are fictional.
package/dist/cli.js CHANGED
@@ -13,20 +13,24 @@ Usage
13
13
  watertight <dir> compile <dir>/report.md + <dir>/metrics.json → <dir>/report.html
14
14
  watertight <report> <ir> explicit file paths
15
15
  watertight refresh <dir> re-fetch metric values from their sources, update metrics.json
16
+ watertight verify <dir> re-fetch and COMPARE — a stored value its source no longer
17
+ returns is a receipt-mismatch; nothing is written
16
18
  watertight init [dir] scaffold a report.md + metrics.json pair that already holds water
17
19
 
18
20
  Options
19
21
  --max-age <days> compile only: fail any metric whose fetched_at is older —
20
22
  numbers age, and a stale receipt is quietly becoming a leak
23
+ --max-raw <n> compile only: fail when the report uses more than n {{raw:}}
24
+ escapes — the escape hatch must stay boundable
21
25
  --format <html|md> output format (default: html). md is grounded markdown with a
22
26
  receipts appendix — pastes into Notion, PR bodies or Slack intact
23
27
  --out <file> where to write the output (default: report.html / report.grounded.md)
24
28
  --check verify only, write nothing
25
29
  --dry-run refresh only: show what would change, write nothing
26
- --fetchers <file> refresh only: a JS module of custom source adapters, e.g.
30
+ --fetchers <file> refresh/verify: a JS module of custom source adapters, e.g.
27
31
  export function mixpanel(source) { ... return value }
28
32
  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 —
33
+ --allow-commands refresh/verify: let "command" sources run shell (off by default —
30
34
  an IR from someone else's repo must not execute code on your machine)
31
35
  --json machine-readable result on stdout
32
36
  -v, --version print the version
@@ -45,6 +49,7 @@ function parseArgs(argv) {
45
49
  let fetchersPath;
46
50
  let format = 'html';
47
51
  let maxAgeDays;
52
+ let maxRaw;
48
53
  let help = false;
49
54
  let version = false;
50
55
  for (let i = 0; i < args.length; i++) {
@@ -68,19 +73,31 @@ function parseArgs(argv) {
68
73
  process.exit(2);
69
74
  }
70
75
  }
76
+ else if (arg === '--max-raw') {
77
+ maxRaw = Number(args[++i]);
78
+ if (!Number.isFinite(maxRaw) || maxRaw < 0) {
79
+ console.error(`error: --max-raw needs a number, got "${args[i]}"`);
80
+ process.exit(2);
81
+ }
82
+ }
71
83
  else if (arg === '--json')
72
84
  json = true;
73
85
  else if (arg === '-h' || arg === '--help')
74
86
  help = true;
75
87
  else if (arg === '-v' || arg === '--version')
76
88
  version = true;
77
- else if (!arg.startsWith('-'))
89
+ else if (arg.startsWith('-')) {
90
+ // a strictness tool must not silently ignore a typo'd flag — --chekc writing a file is a betrayal
91
+ console.error(`error: unknown flag "${arg}" — see --help`);
92
+ process.exit(2);
93
+ }
94
+ else
78
95
  positional.push(arg);
79
96
  }
80
- const command = positional[0] === 'refresh' ? 'refresh' : positional[0] === 'init' ? 'init' : 'compile';
97
+ const command = ['refresh', 'init', 'verify'].includes(positional[0]) ? positional[0] : 'compile';
81
98
  if (command !== 'compile')
82
99
  positional.shift();
83
- return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays };
100
+ return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays, maxRaw };
84
101
  }
85
102
  async function exists(path) {
86
103
  try {
@@ -124,15 +141,15 @@ async function main() {
124
141
  reportPath = join(dir, 'report.md');
125
142
  irPath = join(dir, 'metrics.json');
126
143
  }
127
- // refresh works on the IR alone — an IR-only directory is a legitimate workspace
128
- for (const path of opts.command === 'refresh' ? [irPath] : [reportPath, irPath]) {
144
+ // refresh/verify work on the IR alone — an IR-only directory is a legitimate workspace
145
+ for (const path of opts.command === 'refresh' || opts.command === 'verify' ? [irPath] : [reportPath, irPath]) {
129
146
  if (!(await exists(path))) {
130
147
  console.error(`Not found: ${path}`);
131
148
  console.error('Expected report.md and metrics.json — see --help.');
132
149
  process.exit(2);
133
150
  }
134
151
  }
135
- if (opts.command === 'refresh') {
152
+ if (opts.command === 'refresh' || opts.command === 'verify') {
136
153
  let fetchers;
137
154
  if (opts.fetchersPath) {
138
155
  const mod = await import(pathToFileURL(resolve(opts.fetchersPath)).href);
@@ -143,13 +160,55 @@ async function main() {
143
160
  process.exit(2);
144
161
  }
145
162
  }
146
- const r = await refresh(irPath, { allowCommands: opts.allowCommands, dryRun: opts.dryRun, fetchers });
163
+ const ir = JSON.parse(await readFile(irPath, 'utf8'));
164
+ const r = await refresh(irPath, {
165
+ allowCommands: opts.allowCommands,
166
+ dryRun: opts.dryRun || opts.command === 'verify',
167
+ fetchers,
168
+ });
169
+ if (opts.command === 'verify') {
170
+ // a change on a MEASURED metric means the IR no longer matches its source —
171
+ // the receipt is real but the value is not. Derived changes just cascade.
172
+ const mismatches = r.changes.filter((c) => !ir.metrics?.[c.key]?.derived);
173
+ if (opts.json) {
174
+ console.log(JSON.stringify({ mismatches, skipped: r.skipped, errors: r.errors }, null, 2));
175
+ process.exit(mismatches.length > 0 || r.errors.length > 0 ? 1 : 0);
176
+ }
177
+ for (const m of mismatches) {
178
+ console.log(` ✗ [receipt-mismatch] metric "${m.key}" is ${m.before.toLocaleString()} in the IR, but its source now returns ${m.after.toLocaleString()}`);
179
+ }
180
+ for (const sk of r.skipped)
181
+ console.log(` ~ ${sk.key} skipped — ${sk.reason}`);
182
+ for (const e of r.errors)
183
+ console.error(` ✗ ${e.key}: ${e.message}`);
184
+ if (mismatches.length > 0 || r.errors.length > 0) {
185
+ console.log(`\n${mismatches.length} receipt mismatch(es), ${r.errors.length} fetch error(s) — the IR does not match its sources`);
186
+ process.exit(1);
187
+ }
188
+ const verified = Object.keys(ir.metrics ?? {}).length - r.skipped.length;
189
+ console.log(`\nreceipts verified (${verified} checked, ${r.skipped.length} skipped) — nothing written`);
190
+ process.exit(0);
191
+ }
192
+ // conclusions age too: a claim citing a metric that just moved needs a re-read
193
+ const changedKeys = new Set(r.changes.map((c) => c.key));
194
+ const reviewClaims = [];
195
+ if (changedKeys.size > 0 && (await exists(reportPath))) {
196
+ const report = await readFile(reportPath, 'utf8');
197
+ for (const [, text, evidence] of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
198
+ const cited = evidence.split(',').map((k) => k.trim()).filter((k) => changedKeys.has(k));
199
+ if (cited.length > 0)
200
+ reviewClaims.push({ claim: text.trim(), evidence: cited });
201
+ }
202
+ }
147
203
  if (opts.json) {
148
- console.log(JSON.stringify(r, null, 2));
204
+ console.log(JSON.stringify({ ...r, reviewClaims }, null, 2));
149
205
  process.exit(r.errors.length > 0 ? 1 : 0);
150
206
  }
151
207
  for (const c of r.changes)
152
208
  console.log(` ${c.key}: ${c.before.toLocaleString()} → ${c.after.toLocaleString()}`);
209
+ for (const rc of reviewClaims) {
210
+ console.log(` ⚠ claim "${rc.claim}" cites ${rc.evidence.join(', ')} — the number moved, review the conclusion`);
211
+ }
153
212
  for (const s of r.skipped)
154
213
  console.log(` ~ ${s.key} skipped — ${s.reason}`);
155
214
  for (const e of r.errors)
@@ -159,14 +218,15 @@ async function main() {
159
218
  : `\n${r.changes.length} change(s)${opts.dryRun ? ' (dry run — nothing written)' : r.wrote ? ` — updated ${irPath}` : ''}`);
160
219
  process.exit(r.errors.length > 0 ? 1 : 0);
161
220
  }
162
- const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays });
221
+ const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays, maxRaw: opts.maxRaw });
163
222
  const defaultName = opts.format === 'md' ? 'report.grounded.md' : 'report.html';
164
223
  const outPath = resolve(opts.out ?? join(reportPath, '..', defaultName));
165
224
  if (opts.json) {
166
225
  console.log(JSON.stringify({ version: pkg.version, ...result, output: undefined, wrote: result.output && !opts.check ? outPath : undefined }, null, 2));
167
226
  }
168
227
  else {
169
- console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers`);
228
+ const rawNote = result.grounded.raw > 0 ? ` · ${result.grounded.raw} raw escape(s)` : '';
229
+ console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers${rawNote}`);
170
230
  if (result.leaks.length > 0) {
171
231
  console.log(`\n${result.leaks.length} leak(s) — the report does not hold water:\n`);
172
232
  for (const leak of result.leaks) {
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 { scanNakedNumbers, scanRefs } from './scan.js';
5
+ import { scanMarkers, scanNakedNumbers, scanRefs } 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';
@@ -31,8 +31,20 @@ export async function compile(reportPath, irPath, options = 'html') {
31
31
  metrics: [...report.matchAll(/\{\{m:/g)].length,
32
32
  identifiers: [...report.matchAll(/\{\{id:/g)].length,
33
33
  claims: [...report.matchAll(/\{\{claim:/g)].length,
34
+ raw: [...report.matchAll(/\{\{raw:/g)].length,
34
35
  };
36
+ // the escape hatch must stay visible and boundable — wrapping everything in raw
37
+ // is how an agent games the compile instead of grounding the numbers
38
+ if (opts.maxRaw !== undefined && grounded.raw > opts.maxRaw) {
39
+ leaks.push({
40
+ severity: 'error',
41
+ rule: 'raw-budget',
42
+ message: `${grounded.raw} raw escape(s) exceed the budget of ${opts.maxRaw}`,
43
+ detail: 'Ground the numbers instead, or raise --max-raw deliberately.',
44
+ });
45
+ }
35
46
  leaks.push(...scanNakedNumbers(report));
47
+ leaks.push(...scanMarkers(report));
36
48
  if (ir)
37
49
  leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers))));
38
50
  if (leaks.length > 0 || !ir)
package/dist/init.js CHANGED
@@ -1,4 +1,4 @@
1
- import { writeFile } from 'node:fs/promises';
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  /**
@@ -58,6 +58,7 @@ export async function init(dir) {
58
58
  if (existsSync(path))
59
59
  throw new Error(`refusing to overwrite ${path}`);
60
60
  }
61
+ await mkdir(dir, { recursive: true });
61
62
  for (const [path, content] of files)
62
63
  await writeFile(path, content);
63
64
  return { written: files.map(([p]) => p) };
package/dist/ir.js CHANGED
@@ -2,9 +2,18 @@
2
2
  const DEFINITION_REQUIRED = new Set(['ratio', 'ratio-point']);
3
3
  function decimals(n) {
4
4
  const s = String(n);
5
+ const e = s.indexOf('e');
6
+ if (e !== -1) {
7
+ // 1e-7 has 7 decimals, 1.5e-7 has 8 — exponential notation must not disarm the precision check
8
+ const exp = Number(s.slice(e + 1));
9
+ const mantissa = s.slice(0, e);
10
+ const dot = mantissa.indexOf('.');
11
+ return Math.max(0, (dot === -1 ? 0 : mantissa.length - dot - 1) - exp);
12
+ }
5
13
  const dot = s.indexOf('.');
6
14
  return dot === -1 ? 0 : s.length - dot - 1;
7
15
  }
16
+ const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
8
17
  /**
9
18
  * A stored derived value must be correctly rounded to its own precision: writing 0.15
10
19
  * for a computed 0.155 is a mismatch, writing 0.155 for 0.15517 is fine. This is what
@@ -17,8 +26,19 @@ export function parseIr(raw) {
17
26
  const leaks = [];
18
27
  const root = (raw ?? {});
19
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+)?$)/;
20
32
  for (const [k, v] of Object.entries(root['identifiers'] ?? {})) {
21
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
+ }
22
42
  }
23
43
  const metrics = root['metrics'] ?? {};
24
44
  if (Object.keys(metrics).length === 0) {
@@ -31,8 +51,14 @@ export function parseIr(raw) {
31
51
  return { leaks };
32
52
  }
33
53
  for (const [key, m] of Object.entries(metrics)) {
34
- if (m.value === undefined) {
35
- leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no value` });
54
+ if (!m || typeof m !== 'object') {
55
+ leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" is not an object` });
56
+ continue;
57
+ }
58
+ const valueOk = isFiniteNumber(m.value) ||
59
+ (Array.isArray(m.value) && m.value.length === 2 && m.value.every(isFiniteNumber));
60
+ if (!valueOk) {
61
+ leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no usable numeric value` });
36
62
  continue;
37
63
  }
38
64
  if (typeof m.unit !== 'string' || m.unit.length === 0) {
@@ -51,33 +77,34 @@ export function parseIr(raw) {
51
77
  leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}": a range cannot be derived` });
52
78
  continue;
53
79
  }
54
- if (m.derived.op === 'sum') {
55
- 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
+ let total = 0;
56
88
  let broken = false;
57
89
  for (const ref of m.derived.of) {
58
90
  const part = metrics[ref];
59
91
  if (!part || typeof part.value !== 'number') {
60
- leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}" sums unknown or non-scalar metric "${ref}"` });
92
+ leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}" ${m.derived.op}s unknown or non-scalar metric "${ref}"` });
61
93
  broken = true;
62
94
  continue;
63
95
  }
64
- computed += part.value;
96
+ total += part.value;
65
97
  }
66
- if (!broken && computed !== m.value) {
98
+ const computed = m.derived.op === 'avg' ? total / m.derived.of.length : total;
99
+ if (!broken && !roundsTo(m.value, computed)) {
67
100
  leaks.push({
68
101
  severity: 'error',
69
102
  rule: 'derived-mismatch',
70
- message: `metric "${key}" is ${m.value}, but its parts sum to ${computed}`,
103
+ message: `metric "${key}" is ${m.value}, but its parts ${m.derived.op === 'avg' ? 'average' : 'sum'} to ${computed}`,
71
104
  });
72
105
  }
73
106
  }
74
107
  else if (m.derived.op === 'pct_change') {
75
- const endpoint = (v) => {
76
- if (typeof v === 'number')
77
- return v;
78
- const ref = metrics[v];
79
- return ref && typeof ref.value === 'number' ? ref.value : undefined;
80
- };
81
108
  const before = endpoint(m.derived.before);
82
109
  const after = endpoint(m.derived.after);
83
110
  if (before === undefined || after === undefined || before === 0) {
@@ -98,6 +125,36 @@ export function parseIr(raw) {
98
125
  });
99
126
  }
100
127
  }
128
+ else if (m.derived.op === 'ratio' || m.derived.op === 'diff') {
129
+ const a = endpoint(m.derived.a);
130
+ const b = endpoint(m.derived.b);
131
+ if (a === undefined || b === undefined || (m.derived.op === 'ratio' && b === 0)) {
132
+ leaks.push({
133
+ severity: 'error',
134
+ rule: 'bad-derived',
135
+ message: `metric "${key}": ${m.derived.op} operands must be numbers or scalar metric keys (got ${m.derived.a}, ${m.derived.b})`,
136
+ });
137
+ continue;
138
+ }
139
+ const computed = m.derived.op === 'ratio' ? a / b : a - b;
140
+ if (!roundsTo(m.value, computed)) {
141
+ leaks.push({
142
+ severity: 'error',
143
+ rule: 'derived-mismatch',
144
+ message: `metric "${key}" is ${m.value}, but ${m.derived.a} ${m.derived.op === 'ratio' ? '/' : '−'} ${m.derived.b} computes to ${computed.toFixed(4)}`,
145
+ detail: 'A derived value must be correctly rounded to its own precision.',
146
+ });
147
+ }
148
+ }
149
+ else {
150
+ // an op the verifier cannot recompute must never pass as verified
151
+ leaks.push({
152
+ severity: 'error',
153
+ rule: 'bad-derived',
154
+ message: `metric "${key}" has unknown derived op "${m.derived.op}"`,
155
+ detail: 'Supported ops: sum, pct_change. A derivation the compiler cannot recompute cannot hold water.',
156
+ });
157
+ }
101
158
  }
102
159
  else {
103
160
  // a measured value must say where it came from and when
package/dist/refresh.js CHANGED
@@ -74,7 +74,8 @@ export async function refresh(irPath, options) {
74
74
  const raw = JSON.parse(await readFile(irPath, 'utf8'));
75
75
  const baseDir = dirname(resolve(irPath));
76
76
  const result = { changes: [], skipped: [], errors: [], wrote: false };
77
- const now = new Date().toISOString();
77
+ // date-only, matching the style people write by hand — receipts should look uniform
78
+ const now = new Date().toISOString().slice(0, 10);
78
79
  for (const [key, m] of Object.entries(raw.metrics)) {
79
80
  if (m.derived || !m.source || Array.isArray(m.value))
80
81
  continue;
@@ -107,37 +108,77 @@ export async function refresh(irPath, options) {
107
108
  result.errors.push({ key, message: err instanceof Error ? err.message : String(err) });
108
109
  }
109
110
  }
110
- // inputs may have moved, so derived values are recomputed rather than left to go stale
111
- for (const [key, m] of Object.entries(raw.metrics)) {
112
- if (!m.derived || Array.isArray(m.value))
113
- continue;
114
- let computed;
115
- if (m.derived.op === 'sum') {
116
- computed = m.derived.of.reduce((acc, ref) => {
117
- const part = raw.metrics[ref];
118
- return acc + (part && typeof part.value === 'number' ? part.value : NaN);
119
- }, 0);
120
- }
121
- else {
111
+ // inputs may have moved, so derived values are recomputed rather than left to go stale.
112
+ // Iterated to a fixpoint so a derived that reads another derived settles too; a derivation
113
+ // that cannot be recomputed is a named error — silence is impossible here as everywhere.
114
+ const derivedErrors = new Set();
115
+ const firstBefore = new Map();
116
+ for (let pass = 0, moved = true; moved && pass < 10; pass++) {
117
+ moved = false;
118
+ for (const [key, m] of Object.entries(raw.metrics)) {
119
+ if (!m.derived || Array.isArray(m.value))
120
+ continue;
121
+ const fail = (message) => {
122
+ if (!derivedErrors.has(key)) {
123
+ derivedErrors.add(key);
124
+ result.errors.push({ key, message });
125
+ }
126
+ };
122
127
  const endpoint = (v) => {
123
128
  if (typeof v === 'number')
124
129
  return v;
125
130
  const ref = raw.metrics[v];
126
131
  return ref && typeof ref.value === 'number' ? ref.value : undefined;
127
132
  };
128
- const before = endpoint(m.derived.before);
129
- const after = endpoint(m.derived.after);
130
- if (before === undefined || after === undefined || before === 0)
133
+ // every recomputed value keeps the author's stated precision — refresh must not
134
+ // turn 0.155 into 0.1551724 or write float noise over an exact 0.3
135
+ const authorDecimals = (String(m.value).split('.')[1] ?? '').length;
136
+ const rounded = (v) => Number(v.toFixed(authorDecimals));
137
+ let computed;
138
+ if (m.derived.op === 'sum' || m.derived.op === 'avg') {
139
+ const total = m.derived.of.reduce((acc, ref) => {
140
+ const part = raw.metrics[ref];
141
+ return acc + (part && typeof part.value === 'number' ? part.value : NaN);
142
+ }, 0);
143
+ if (!Number.isFinite(total)) {
144
+ fail(`${m.derived.op} references unknown or non-scalar metrics — not recomputed`);
145
+ continue;
146
+ }
147
+ computed = rounded(m.derived.op === 'avg' ? total / m.derived.of.length : total);
148
+ }
149
+ else if (m.derived.op === 'pct_change') {
150
+ const before = endpoint(m.derived.before);
151
+ const after = endpoint(m.derived.after);
152
+ if (before === undefined || after === undefined || before === 0) {
153
+ fail('pct_change endpoints are unresolvable or zero — not recomputed');
154
+ continue;
155
+ }
156
+ computed = rounded((after - before) / before);
157
+ }
158
+ else if (m.derived.op === 'ratio' || m.derived.op === 'diff') {
159
+ const a = endpoint(m.derived.a);
160
+ const b = endpoint(m.derived.b);
161
+ if (a === undefined || b === undefined || (m.derived.op === 'ratio' && b === 0)) {
162
+ fail(`${m.derived.op} operands are unresolvable — not recomputed`);
163
+ continue;
164
+ }
165
+ computed = rounded(m.derived.op === 'ratio' ? a / b : a - b);
166
+ }
167
+ else {
168
+ fail(`unknown derived op "${m.derived.op}" — not recomputed`);
131
169
  continue;
132
- // keep the author's stated precision — refresh must not turn 0.155 into 0.1551724
133
- const decimals = (String(m.value).split('.')[1] ?? '').length;
134
- computed = Number(((after - before) / before).toFixed(decimals));
135
- }
136
- if (Number.isFinite(computed) && computed !== m.value) {
137
- result.changes.push({ key, before: m.value, after: computed });
138
- m.value = computed;
170
+ }
171
+ if (computed !== m.value) {
172
+ if (!firstBefore.has(key))
173
+ firstBefore.set(key, m.value);
174
+ m.value = computed;
175
+ moved = true;
176
+ }
139
177
  }
140
178
  }
179
+ for (const [key, before] of firstBefore) {
180
+ result.changes.push({ key, before, after: raw.metrics[key].value });
181
+ }
141
182
  if (!options.dryRun && (result.changes.length > 0 || result.errors.length === 0)) {
142
183
  await writeFile(irPath, `${JSON.stringify(raw, null, 2)}\n`);
143
184
  result.wrote = true;
package/dist/render.js CHANGED
@@ -1,13 +1,30 @@
1
+ /**
2
+ * Code shows syntax, it does not state facts — a fenced example of {{m:…}} must render
3
+ * verbatim, not substitute. Stash code regions before substitution, restore after.
4
+ */
5
+ export function protectCode(text) {
6
+ const stash = [];
7
+ const protectedText = text.replace(/```[\s\S]*?```|`[^`\n]*`/g, (m) => {
8
+ stash.push(m);
9
+ return `\u0000${stash.length - 1}\u0000`;
10
+ });
11
+ return { text: protectedText, restore: (s) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]) };
12
+ }
1
13
  function escapeHtml(s) {
2
14
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3
15
  }
4
16
  export function formatValue(m) {
5
17
  if (Array.isArray(m.value)) {
6
18
  const [lo, hi] = m.value;
7
- return `${lo.toLocaleString()}~${hi.toLocaleString()}${m.unit === 'ratio' ? '%' : ` ${m.unit}`}`;
19
+ // a range keeps the same unit conversion as a scalar [0.3, 0.5] ratio is 30~50%, not 0.3~0.5%
20
+ if (m.unit === 'ratio' || m.unit === 'ratio-point') {
21
+ const pct = (v) => (v * 100).toFixed(1);
22
+ return `${pct(lo)}~${pct(hi)}${m.unit === 'ratio' ? '%' : '%p'}`;
23
+ }
24
+ return `${lo.toLocaleString()}~${hi.toLocaleString()} ${m.unit}`.trim();
8
25
  }
9
26
  if (m.unit === 'ratio')
10
- return `${(m.value * 100).toFixed(m.value < 0.01 ? 2 : 1)}%`;
27
+ return `${(m.value * 100).toFixed(Math.abs(m.value) < 0.01 ? 2 : 1)}%`;
11
28
  if (m.unit === 'ratio-point')
12
29
  return `${m.value >= 0 ? '+' : ''}${(m.value * 100).toFixed(1)}%p`;
13
30
  return `${m.value.toLocaleString()} ${m.unit}`.trim();
@@ -16,7 +33,11 @@ export function receipt(m, includeDefinition = true) {
16
33
  const source = m.derived
17
34
  ? m.derived.op === 'sum'
18
35
  ? `= ${m.derived.of.join(' + ')} (recomputed)`
19
- : `= ${m.derived.before} ${m.derived.after} (recomputed)`
36
+ : m.derived.op === 'avg'
37
+ ? `= avg(${m.derived.of.join(', ')}) (recomputed)`
38
+ : m.derived.op === 'pct_change'
39
+ ? `= ${m.derived.before} → ${m.derived.after} (recomputed)`
40
+ : `= ${m.derived.a} ${m.derived.op === 'ratio' ? '/' : '−'} ${m.derived.b} (recomputed)`
20
41
  : [m.source?.type, ...Object.entries(m.source ?? {}).filter(([k]) => k !== 'type').map(([, v]) => String(v))].filter(Boolean).join(' · ');
21
42
  return [source, m.window, m.fetched_at && `fetched ${m.fetched_at}`, includeDefinition && m.definition].filter(Boolean).join(' · ');
22
43
  }
@@ -37,7 +58,8 @@ function markdown(src) {
37
58
  .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
38
59
  }
39
60
  export function render(report, ir) {
40
- const grounded = escapeHtml(report)
61
+ const { text: protectedReport, restore } = protectCode(escapeHtml(report));
62
+ const grounded = restore(protectedReport
41
63
  .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
42
64
  const m = ir.metrics[key];
43
65
  return `<b class="w" title="${escapeHtml(receipt(m))}">${formatValue(m)}<sup>†</sup></b>`;
@@ -50,7 +72,7 @@ export function render(report, ir) {
50
72
  .join(' | ');
51
73
  return `<span class="c" title="${escapeHtml(receipts)}">${text.trim()}<sup>‡</sup></span>`;
52
74
  })
53
- .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text));
75
+ .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text)));
54
76
  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
55
77
  <style>
56
78
  body{font:15px/1.75 -apple-system,system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1a1a1a}
package/dist/renderMd.js CHANGED
@@ -1,4 +1,4 @@
1
- import { formatValue, receipt } from './render.js';
1
+ import { formatValue, 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
  /**
@@ -9,7 +9,8 @@ const sup = (n) => `⁽${String(n).split('').map((d) => SUPERSCRIPT[Number(d)]).
9
9
  */
10
10
  export function renderMarkdown(report, ir) {
11
11
  const used = [];
12
- const body = report
12
+ const { text: protectedReport, restore } = protectCode(report);
13
+ const body = restore(protectedReport
13
14
  .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
14
15
  if (!used.includes(key))
15
16
  used.push(key);
@@ -17,7 +18,22 @@ export function renderMarkdown(report, ir) {
17
18
  })
18
19
  .replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `\`${ir.identifiers[key]}\``)
19
20
  .replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => `**${text.trim()}** *(evidence: ${evidence.trim()})*`)
20
- .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => text);
21
+ .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => text));
22
+ // evidence-only metrics get receipts too — a claim's reader must be able to check its keys
23
+ for (const [, , evidence] of protectedReport.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
24
+ for (const key of evidence.split(',').map((k) => k.trim()).filter(Boolean)) {
25
+ if (ir.metrics[key] && !used.includes(key))
26
+ used.push(key);
27
+ }
28
+ }
29
+ // the report discloses its own escape hatches — a reader (and a reviewer) sees
30
+ // exactly which prose numbers carry no receipt
31
+ const raws = [...protectedReport.matchAll(/\{\{raw:([^}]*)\}\}/g)].map((m) => m[1].trim());
32
+ const rawSection = raws.length === 0
33
+ ? ''
34
+ : `\n\n### Ungrounded (${raws.length} raw escape${raws.length === 1 ? '' : 's'})\n\n${raws
35
+ .map((r) => `- ${r}`)
36
+ .join('\n')}`;
21
37
  const appendix = used
22
38
  .map((key, i) => {
23
39
  const m = ir.metrics[key];
@@ -25,5 +41,5 @@ export function renderMarkdown(report, ir) {
25
41
  return `${i + 1}. **${key}** = ${formatValue(m)}${definition}\n ${receipt(m, false)}`;
26
42
  })
27
43
  .join('\n');
28
- return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}\n`;
44
+ return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}${rawSection}\n`;
29
45
  }
package/dist/scan.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Same-length whitespace, newlines kept — stripping must not move anything */
2
2
  const blank = (s) => s.replace(/[^\n]/g, ' ');
3
3
  const lineAt = (text, index) => text.slice(0, index).split('\n').length;
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);
4
6
  /**
5
7
  * Find numbers in the narrative that are not bound to the IR. These are the leaks the
6
8
  * tool exists to catch: a typed, transcribed, or hallucinated figure reads exactly like
@@ -18,12 +20,15 @@ export function scanNakedNumbers(report) {
18
20
  .replace(/\{\{raw:[^}]*\}\}/g, blank) // explicit, greppable escape hatch
19
21
  .replace(/```[\s\S]*?```/g, blank) // fenced code
20
22
  .replace(/`[^`\n]*`/g, blank) // inline code
23
+ .replace(/\]\([^)\s]*\)/g, blank) // markdown link targets — URLs locate, they do not measure
24
+ .replace(/https?:\/\/\S+/g, blank) // bare URLs, same reason
21
25
  .replace(/\d{4}-\d{2}-\d{2}/g, blank) // ISO dates locate, they do not measure
22
- .replace(/^#+ .*$/gm, blank) // headings
26
+ .replace(/(?<![A-Za-z0-9가-힣])[A-Za-z]+\d[\w.\-]*/g, blank) // Q3, v6.109.0, iOS15 — names, not measurements
27
+ .replace(/^#{1,6}(?= )/gm, blank) // heading markers only — heading TEXT is scanned, people summarise numbers there
23
28
  .replace(/^\s*\d+\.\s/gm, blank); // ordered-list markers
24
29
  const leaks = [];
25
30
  for (const m of stripped.matchAll(/\d[\d,.]*\s*(%p?|[가-힣]{1,2})?/g)) {
26
- const token = m[0].trim();
31
+ const token = m[0].trim().replace(/[.,]+$/, '');
27
32
  if (!token)
28
33
  continue;
29
34
  leaks.push({
@@ -37,8 +42,9 @@ export function scanNakedNumbers(report) {
37
42
  return leaks;
38
43
  }
39
44
  /** Every reference in the narrative must resolve; a dangling one is authoring drift. */
40
- export function scanRefs(report, metricKeys, idKeys) {
45
+ export function scanRefs(rawReport, metricKeys, idKeys) {
41
46
  const leaks = [];
47
+ const report = blankCode(rawReport);
42
48
  for (const m of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
43
49
  const [, text, evidence] = m;
44
50
  const line = lineAt(report, m.index);
@@ -80,3 +86,25 @@ export function scanRefs(report, metricKeys, idKeys) {
80
86
  }
81
87
  return leaks;
82
88
  }
89
+ /**
90
+ * Anything still shaped like a marker after every recognised form is removed was a typo —
91
+ * and a typo'd marker must be a leak, or it renders verbatim and its number sails through.
92
+ */
93
+ export function scanMarkers(rawReport) {
94
+ const known = blankCode(rawReport)
95
+ .replace(/\{\{(m|id):[\w-]+\}\}/g, blank)
96
+ .replace(/\{\{raw:[^}]*\}\}/g, blank)
97
+ .replace(/\{\{claim:[^|}]*\|\s*evidence:[^}]*\}\}/g, blank)
98
+ .replace(/\{\{claim:[^|}]*\}\}/g, blank); // no-pipe form is already claim-without-evidence
99
+ const leaks = [];
100
+ for (const m of known.matchAll(/\{\{[^}]*\}\}?/g)) {
101
+ leaks.push({
102
+ severity: 'error',
103
+ rule: 'malformed-marker',
104
+ line: lineAt(known, m.index),
105
+ message: `"${m[0]}" is not a recognised marker`,
106
+ detail: 'Valid forms: {{m:key}}, {{id:key}}, {{raw:…}}, {{claim: text | evidence: keys}}.',
107
+ });
108
+ }
109
+ return leaks;
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watertight",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {