watertight 0.7.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
@@ -98,6 +98,20 @@ do, so hand-computed values don't have to masquerade as measured ones:
98
98
  }
99
99
  ```
100
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
+
101
115
  A **range** states a hypothesis honestly — plans are receipts too:
102
116
 
103
117
  ```json
@@ -119,7 +133,7 @@ Conversion moved from {{m:conversion_before}} to {{m:conversion_after}},
119
133
  a lift of {{m:lift}}. Revenue impact was {{m:revenue_total}}, within the
120
134
  hypothesised {{m:revenue_target}}.
121
135
 
122
- {{claim: the experiment met its success criteria | evidence: lift, revenue_total}}
136
+ {{claim: the experiment met its success criteria | evidence: lift, met_target}}
123
137
 
124
138
  Support runs {{raw:24/7}}.
125
139
  ```
@@ -135,6 +149,7 @@ watertight report.md metrics.json # → report.html (self-contained, hover fo
135
149
  watertight . --format md # → grounded markdown (below)
136
150
  watertight . --check # verify only, write nothing (CI)
137
151
  watertight . --check --max-age 30 # also fail receipts older than 30 days
152
+ watertight . --strict # promote warnings (worded-number) to errors
138
153
  watertight . --json # machine-readable result
139
154
  ```
140
155
 
@@ -182,6 +197,13 @@ figure shows its receipt.
182
197
  | `stale-metric` | with `--max-age <days>`: a receipt whose `fetched_at` is older than the budget — numbers age |
183
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 |
184
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.
185
207
 
186
208
  <br>
187
209
 
package/SKILL.md CHANGED
@@ -57,12 +57,25 @@ number yet.
57
57
  "1,428") is a leak — the id door is not a receipt bypass.
58
58
  - Anything computed from other metrics must be `derived` — the compiler
59
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.
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.
62
65
  - `ratio` / `ratio-point` metrics require a `definition`. Ratios above 1.0
63
66
  are legal but the definition must explain the basis.
64
67
  - A hypothesis or plan figure is still a metric — source it as
65
- `{ "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.
66
79
 
67
80
  **3. Write the narrative (`report.md`).**
68
81
  Never type a figure into prose. Reference it:
@@ -84,6 +97,9 @@ watertight . --check --max-age 30 # also fail receipts older than 30 d
84
97
  ```
85
98
 
86
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.
87
103
  Fix leaks by *going and getting the receipt* — running the query, opening
88
104
  the export — never by deleting the number, weakening the claim, or wrapping
89
105
  a measurement in `{{raw:}}` to silence the checker. A `derived-mismatch` is
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;
@@ -22,6 +23,8 @@ Options
22
23
  numbers age, and a stale receipt is quietly becoming a leak
23
24
  --max-raw <n> compile only: fail when the report uses more than n {{raw:}}
24
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
25
28
  --format <html|md> output format (default: html). md is grounded markdown with a
26
29
  receipts appendix — pastes into Notion, PR bodies or Slack intact
27
30
  --out <file> where to write the output (default: report.html / report.grounded.md)
@@ -50,6 +53,7 @@ function parseArgs(argv) {
50
53
  let format = 'html';
51
54
  let maxAgeDays;
52
55
  let maxRaw;
56
+ let strict = false;
53
57
  let help = false;
54
58
  let version = false;
55
59
  for (let i = 0; i < args.length; i++) {
@@ -73,6 +77,8 @@ function parseArgs(argv) {
73
77
  process.exit(2);
74
78
  }
75
79
  }
80
+ else if (arg === '--strict')
81
+ strict = true;
76
82
  else if (arg === '--max-raw') {
77
83
  maxRaw = Number(args[++i]);
78
84
  if (!Number.isFinite(maxRaw) || maxRaw < 0) {
@@ -97,7 +103,7 @@ function parseArgs(argv) {
97
103
  const command = ['refresh', 'init', 'verify'].includes(positional[0]) ? positional[0] : 'compile';
98
104
  if (command !== 'compile')
99
105
  positional.shift();
100
- return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays, maxRaw };
106
+ return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays, maxRaw, strict };
101
107
  }
102
108
  async function exists(path) {
103
109
  try {
@@ -167,13 +173,20 @@ async function main() {
167
173
  fetchers,
168
174
  });
169
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;
170
180
  // 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.
181
+ // the receipt is real but the value is not. Derived drift is covered above.
172
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;
173
184
  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);
185
+ console.log(JSON.stringify({ mismatches, integrity, skipped: r.skipped, errors: r.errors }, null, 2));
186
+ process.exit(failing ? 1 : 0);
176
187
  }
188
+ for (const leak of integrity)
189
+ console.log(` ✗ [${leak.rule}] ${leak.message}`);
177
190
  for (const m of mismatches) {
178
191
  console.log(` ✗ [receipt-mismatch] metric "${m.key}" is ${m.before.toLocaleString()} in the IR, but its source now returns ${m.after.toLocaleString()}`);
179
192
  }
@@ -181,12 +194,12 @@ async function main() {
181
194
  console.log(` ~ ${sk.key} skipped — ${sk.reason}`);
182
195
  for (const e of r.errors)
183
196
  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`);
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`);
186
199
  process.exit(1);
187
200
  }
188
201
  const verified = Object.keys(ir.metrics ?? {}).length - r.skipped.length;
189
- console.log(`\nreceipts verified (${verified} checked, ${r.skipped.length} skipped) — nothing written`);
202
+ console.log(`\nreceipts verified (${verified} checked, ${r.skipped.length} named as skipped) — nothing written`);
190
203
  process.exit(0);
191
204
  }
192
205
  // conclusions age too: a claim citing a metric that just moved needs a re-read
@@ -218,7 +231,7 @@ async function main() {
218
231
  : `\n${r.changes.length} change(s)${opts.dryRun ? ' (dry run — nothing written)' : r.wrote ? ` — updated ${irPath}` : ''}`);
219
232
  process.exit(r.errors.length > 0 ? 1 : 0);
220
233
  }
221
- const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays, maxRaw: opts.maxRaw });
234
+ const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays, maxRaw: opts.maxRaw, strict: opts.strict });
222
235
  const defaultName = opts.format === 'md' ? 'report.grounded.md' : 'report.html';
223
236
  const outPath = resolve(opts.out ?? join(reportPath, '..', defaultName));
224
237
  if (opts.json) {
@@ -228,10 +241,18 @@ async function main() {
228
241
  const rawNote = result.grounded.raw > 0 ? ` · ${result.grounded.raw} raw escape(s)` : '';
229
242
  console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers${rawNote}`);
230
243
  if (result.leaks.length > 0) {
231
- 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: 'ℹ' };
232
253
  for (const leak of result.leaks) {
233
254
  const where = leak.line !== undefined ? `${basename(reportPath)}:${leak.line} — ` : '';
234
- console.log(` [${leak.rule}] ${where}${leak.message}`);
255
+ console.log(` ${ICON[leak.severity]} [${leak.rule}] ${where}${leak.message}`);
235
256
  if (leak.detail)
236
257
  console.log(` ${leak.detail}`);
237
258
  }
@@ -246,7 +267,8 @@ async function main() {
246
267
  else if (result.output && opts.check && !opts.json) {
247
268
  console.log('holds water (check only — nothing written)\n');
248
269
  }
249
- 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);
250
272
  }
251
273
  main().catch((err) => {
252
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,11 +27,14 @@ 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
- raw: [...report.matchAll(/\{\{raw:/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,
35
38
  };
36
39
  // the escape hatch must stay visible and boundable — wrapping everything in raw
37
40
  // is how an agent games the compile instead of grounding the numbers
@@ -45,9 +48,56 @@ export async function compile(reportPath, irPath, options = 'html') {
45
48
  }
46
49
  leaks.push(...scanNakedNumbers(report));
47
50
  leaks.push(...scanMarkers(report));
48
- if (ir)
49
- leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers))));
50
- 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)
51
101
  return { leaks, grounded };
52
102
  return { leaks, output: format === 'md' ? renderMarkdown(report, ir) : render(report, ir), grounded };
53
103
  }
package/dist/ir.js CHANGED
@@ -28,7 +28,7 @@ export function parseIr(raw) {
28
28
  const identifiers = {};
29
29
  // an identifier names a thing (version, flag, unit id); a value shaped like a
30
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+)?$)/;
31
+ const MEASUREMENT_SHAPE = /(%p?$)|(^\d{1,3}(,\d{3})+(\.\d+)?$)|(^\d+(\.\d+)?\s*(원|건|명|회)$)/;
32
32
  for (const [k, v] of Object.entries(root['identifiers'] ?? {})) {
33
33
  identifiers[k] = String(v);
34
34
  if (MEASUREMENT_SHAPE.test(identifiers[k].trim())) {
@@ -84,6 +84,14 @@ export function parseIr(raw) {
84
84
  return ref && typeof ref.value === 'number' ? ref.value : undefined;
85
85
  };
86
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
+ }
87
95
  let total = 0;
88
96
  let broken = false;
89
97
  for (const ref of m.derived.of) {
@@ -165,5 +173,66 @@ export function parseIr(raw) {
165
173
  }
166
174
  }
167
175
  }
168
- 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 };
169
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')
@@ -136,6 +144,10 @@ export async function refresh(irPath, options) {
136
144
  const rounded = (v) => Number(v.toFixed(authorDecimals));
137
145
  let computed;
138
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
+ }
139
151
  const total = m.derived.of.reduce((acc, ref) => {
140
152
  const part = raw.metrics[ref];
141
153
  return acc + (part && typeof part.value === 'number' ? part.value : NaN);
@@ -168,6 +180,10 @@ export async function refresh(irPath, options) {
168
180
  fail(`unknown derived op "${m.derived.op}" — not recomputed`);
169
181
  continue;
170
182
  }
183
+ if (!Number.isFinite(computed)) {
184
+ fail('recomputation produced a non-finite value — the ledger is never overwritten with NaN');
185
+ continue;
186
+ }
171
187
  if (computed !== m.value) {
172
188
  if (!firstBefore.has(key))
173
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.
@@ -58,17 +71,32 @@ function markdown(src) {
58
71
  .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
59
72
  }
60
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
+ };
61
79
  const { text: protectedReport, restore } = protectCode(escapeHtml(report));
62
80
  const grounded = restore(protectedReport
63
81
  .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
82
+ if (!used.includes(key))
83
+ used.push(key);
64
84
  const m = ir.metrics[key];
65
- 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>`;
66
88
  })
67
89
  .replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `<code>${escapeHtml(ir.identifiers[key])}</code>`)
68
90
  .replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => {
69
91
  const keys = evidence.split(',').map((k) => k.trim()).filter(Boolean);
70
92
  const receipts = keys
71
- .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
+ })
72
100
  .join(' | ');
73
101
  return `<span class="c" title="${escapeHtml(receipts)}">${text.trim()}<sup>‡</sup></span>`;
74
102
  })
@@ -79,10 +107,23 @@ export function render(report, ir) {
79
107
  @media(prefers-color-scheme:dark){body{background:#111;color:#ddd}code{background:#222}}
80
108
  h1,h2,h3{line-height:1.3}
81
109
  .w{border-bottom:2px solid #4a9;cursor:help;font-weight:600}
110
+ .w.a{border-bottom-style:dotted}
82
111
  .c{border-bottom:2px dotted #4a9;cursor:help}
83
112
  code{background:#eee;padding:1px 5px;border-radius:4px;font-size:.9em}
84
113
  sup{font-size:.65em;color:#4a9}
85
114
  </style>
86
115
  <body>${markdown(grounded)}
87
- <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>`;
88
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()})*`)
@@ -34,12 +37,36 @@ export function renderMarkdown(report, ir) {
34
37
  : `\n\n### Ungrounded (${raws.length} raw escape${raws.length === 1 ? '' : 's'})\n\n${raws
35
38
  .map((r) => `- ${r}`)
36
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')}`;
37
63
  const appendix = used
38
64
  .map((key, i) => {
39
65
  const m = ir.metrics[key];
40
66
  const definition = m.definition ? ` — ${m.definition}` : '';
41
- 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))}`;
42
69
  })
43
70
  .join('\n');
44
- return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}${rawSection}\n`;
71
+ return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}${assertionSection}${rawSection}\n`;
45
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
@@ -26,6 +27,9 @@ export function scanNakedNumbers(report) {
26
27
  .replace(/(?<![A-Za-z0-9가-힣])[A-Za-z]+\d[\w.\-]*/g, blank) // Q3, v6.109.0, iOS15 — names, not measurements
27
28
  .replace(/^#{1,6}(?= )/gm, blank) // heading markers only — heading TEXT is scanned, people summarise numbers there
28
29
  .replace(/^\s*\d+\.\s/gm, blank); // ordered-list markers
30
+ }
31
+ export function scanNakedNumbers(report) {
32
+ const stripped = strippedProse(report);
29
33
  const leaks = [];
30
34
  for (const m of stripped.matchAll(/\d[\d,.]*\s*(%p?|[가-힣]{1,2})?/g)) {
31
35
  const token = m[0].trim().replace(/[.,]+$/, '');
@@ -42,7 +46,7 @@ export function scanNakedNumbers(report) {
42
46
  return leaks;
43
47
  }
44
48
  /** Every reference in the narrative must resolve; a dangling one is authoring drift. */
45
- export function scanRefs(rawReport, metricKeys, idKeys) {
49
+ export function scanRefs(rawReport, metricKeys, idKeys, assertionKeys = new Set()) {
46
50
  const leaks = [];
47
51
  const report = blankCode(rawReport);
48
52
  for (const m of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
@@ -59,7 +63,7 @@ export function scanRefs(rawReport, metricKeys, idKeys) {
59
63
  });
60
64
  }
61
65
  for (const key of keys) {
62
- if (!metricKeys.has(key)) {
66
+ if (!metricKeys.has(key) && !assertionKeys.has(key)) {
63
67
  leaks.push({ severity: 'error', rule: 'unknown-ref', line, message: `claim "${text.trim()}" cites unknown metric "${key}"` });
64
68
  }
65
69
  }
@@ -108,3 +112,27 @@ export function scanMarkers(rawReport) {
108
112
  }
109
113
  return leaks;
110
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.7.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": {