watertight 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hosung Kang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # watertight
2
+
3
+ **Reports that hold water — every number carries its receipt, and ungrounded
4
+ claims fail the build.**
5
+
6
+ ```
7
+ $ watertight report.md metrics.json
8
+
9
+ watertight v0.1.0 · 20 grounded metrics · 4 claims · 5 identifiers
10
+ holds water → report.html
11
+ ```
12
+
13
+ A number typed into prose has no memory of where it came from. Two weeks
14
+ later nobody can say which query produced it, whether it was rounded, or
15
+ whether it was ever true — and when an LLM helps write the report, "never
16
+ true" is a live possibility. watertight treats a report like source code:
17
+ the narrative references metrics, the metrics carry provenance, and a
18
+ compiler refuses to build anything it cannot trace.
19
+
20
+ An ungrounded number is a **leak**. A report with zero leaks **holds water**.
21
+
22
+ ## Why this exists
23
+
24
+ This came out of writing a real ad-revenue verification report with an AI
25
+ assistant. Compiling the draft surfaced three distinct failure classes in
26
+ one document:
27
+
28
+ - a human had written **+15%** for a delta that computed to **+15.5%** —
29
+ a rounding transcription error nobody had caught;
30
+ - a stated revenue total did not equal the sum of its per-platform parts;
31
+ - the assistant confidently "remembered" a figure that appeared nowhere in
32
+ any export.
33
+
34
+ All three are the same disease — a number with no receipt — and all three
35
+ are mechanically detectable. So: detect them, every build.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ npm install -g watertight
41
+ # or per-run
42
+ npx watertight report.md metrics.json
43
+ ```
44
+
45
+ Node 18+, zero runtime dependencies.
46
+
47
+ ## How it works
48
+
49
+ You write two files.
50
+
51
+ **`metrics.json` — the IR.** Every value, with its receipt:
52
+
53
+ ```json
54
+ {
55
+ "metrics": {
56
+ "conversion_before": {
57
+ "value": 0.031,
58
+ "unit": "ratio",
59
+ "definition": "purchases / sessions entering checkout",
60
+ "source": { "type": "sql", "query": "reports/checkout.sql" },
61
+ "window": "2026-08-15 ~ 2026-08-31",
62
+ "fetched_at": "2026-09-15"
63
+ },
64
+ "conversion_after": {
65
+ "value": 0.036,
66
+ "unit": "ratio",
67
+ "definition": "purchases / sessions entering checkout",
68
+ "source": { "type": "sql", "query": "reports/checkout.sql" },
69
+ "window": "2026-09-01 ~ 2026-09-14",
70
+ "fetched_at": "2026-09-15"
71
+ },
72
+ "lift": {
73
+ "value": 0.161,
74
+ "unit": "ratio-point",
75
+ "definition": "relative change in conversion",
76
+ "derived": { "op": "pct_change", "before": "conversion_before", "after": "conversion_after" }
77
+ },
78
+ "revenue_total": {
79
+ "value": 1428,
80
+ "unit": "USD",
81
+ "derived": { "op": "sum", "of": ["revenue_ios", "revenue_android"] }
82
+ },
83
+ "revenue_target": {
84
+ "value": [1000, 5000],
85
+ "unit": "USD",
86
+ "source": { "type": "hypothesis", "doc": "PLAN-42" },
87
+ "window": "planning estimate",
88
+ "fetched_at": "-"
89
+ }
90
+ },
91
+ "identifiers": {
92
+ "app_version": "3.2.0",
93
+ "flag": "checkout_v2"
94
+ }
95
+ }
96
+ ```
97
+
98
+ **`report.md` — the narrative.** No literal figures, only references:
99
+
100
+ ```markdown
101
+ Rolled out in `{{id:app_version}}` behind `{{id:flag}}`.
102
+
103
+ Conversion moved from {{m:conversion_before}} to {{m:conversion_after}},
104
+ a lift of {{m:lift}}. Revenue impact was {{m:revenue_total}}, within the
105
+ hypothesised {{m:revenue_target}}.
106
+
107
+ {{claim: the experiment met its success criteria | evidence: lift, revenue_total}}
108
+
109
+ Support runs {{raw:24/7}}.
110
+ ```
111
+
112
+ Compile:
113
+
114
+ ```bash
115
+ watertight report.md metrics.json # → report.html (self-contained, hover for receipts)
116
+ watertight . --format md # → grounded markdown: superscripts + receipts appendix
117
+ watertight . --check # verify only, write nothing (CI)
118
+ watertight . --json # machine-readable result
119
+ ```
120
+
121
+ Any leak fails the build (exit 1) and names the line:
122
+
123
+ ```
124
+ 2 leak(s) — the report does not hold water:
125
+
126
+ ✗ [naked-number] "15%" appears in the narrative without a receipt
127
+ ✗ [derived-mismatch] metric "revenue_total" is 1440, but its parts sum to 1428
128
+ ```
129
+
130
+ ## What it checks
131
+
132
+ | check | catches |
133
+ |---|---|
134
+ | `naked-number` | any digit in prose not covered by a reference (dates, headings, code spans and `{{raw:}}` are exempt) |
135
+ | `unknown-ref` | `{{m:...}}` / `{{id:...}}` pointing at nothing |
136
+ | `missing-field` | a measured metric without `source`, `window`, or `fetched_at` |
137
+ | `definition-required` | a `ratio` / `ratio-point` metric with no stated basis — this is how a fill rate of 107% stays honest |
138
+ | `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 |
139
+ | `claim-without-evidence` | a `{{claim:}}` with no `evidence:` keys, or keys that don't exist |
140
+ | `bad-derived` | derived ops referencing missing or non-numeric inputs |
141
+ | `empty-ir` | a report "grounded" in nothing |
142
+
143
+ ## Re-verification
144
+
145
+ Numbers age. `watertight refresh .` re-fetches every metric whose source it
146
+ can reach, rewrites `value` and `fetched_at`, and recomputes derived values:
147
+
148
+ - `csv` sources — `{ "type": "csv", "file": "data.csv", "cell": "B2" }`
149
+ - `json` sources — `{ "type": "json", "file": "kpi.json", "path": "revenue.total" }`
150
+ - `command` sources — run **only** with the explicit `--allow-commands` flag,
151
+ because refreshing an IR you didn't author must never execute its shell
152
+ commands
153
+ - everything else (dashboards, vendor reports, hypotheses) is **named as
154
+ skipped** — never silently assumed fresh
155
+
156
+ `--dry-run` previews changes without writing.
157
+
158
+ ## For AI-authored reports
159
+
160
+ [`SKILL.md`](./SKILL.md) is an authoring contract for coding agents: gather
161
+ receipts first, build the IR from real sources only, reference — never type —
162
+ figures, and fix leaks by re-fetching, not by weakening the text. The compile
163
+ step turns "please don't hallucinate numbers" from a request into a gate.
164
+
165
+ ## What it deliberately does not do
166
+
167
+ - **Judge whether evidence supports a claim.** `{{claim:}}` proves evidence
168
+ is *attached* and *exists*; whether it actually supports the conclusion is
169
+ the author's judgment. The tool makes that judgment inspectable, not
170
+ automatic.
171
+ - **Verify a source is truthful.** A receipt says where a number came from,
172
+ not that the origin was right.
173
+ - **Use an LLM.** Every check is deterministic. The point is to be the fixed
174
+ ground an LLM-assisted workflow can push against.
175
+
176
+ ## Related work
177
+
178
+ - [Proof-Carrying Numbers (arXiv:2509.06902)](https://arxiv.org/abs/2509.06902)
179
+ proposes numbers as claim-bound tokens verified at render time — the same
180
+ philosophy applied to LLM output streams. watertight applies it to the
181
+ document build step instead.
182
+ - [Evidence](https://evidence.dev) compiles markdown + SQL into reports, and
183
+ proves the "reports as source code" mechanism — but doesn't enforce that
184
+ prose figures stay grounded.
185
+
186
+ ## License
187
+
188
+ MIT © [Hosung Kang](https://github.com/camosss)
package/SKILL.md ADDED
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: watertight
3
+ description: Write reports where every number carries its receipt. Use when authoring any document that states metrics — experiment verifications, incident reviews, performance reports — so that no figure appears without a recorded source, and hallucinated or stale numbers fail the build instead of shipping.
4
+ ---
5
+
6
+ # watertight
7
+
8
+ You are writing a report that must hold water: every number in it either
9
+ carries a receipt or the compile fails. Your job is not to make the compiler
10
+ pass — it is to make the report true, and use the compiler to prove it.
11
+
12
+ ## Setup
13
+
14
+ ```bash
15
+ npm install -g watertight # or: npx watertight
16
+ ```
17
+
18
+ ## The workflow
19
+
20
+ **1. Gather receipts before writing prose.**
21
+ For every figure the report will state, record where it came from *at the
22
+ moment you obtain it* — the query, the export, the dashboard URL, the
23
+ document. If you cannot say where a number came from, you do not have the
24
+ number yet.
25
+
26
+ **2. Write the IR (`metrics.json`).**
27
+
28
+ ```json
29
+ {
30
+ "metrics": {
31
+ "conversion_after": {
32
+ "value": 0.036,
33
+ "unit": "ratio",
34
+ "definition": "purchases / sessions entering checkout",
35
+ "source": { "type": "sql", "query": "reports/checkout.sql" },
36
+ "window": "2026-09-01 ~ 2026-09-14",
37
+ "fetched_at": "2026-09-15"
38
+ },
39
+ "revenue_total": {
40
+ "value": 1428, "unit": "USD",
41
+ "derived": { "op": "sum", "of": ["revenue_ios", "revenue_android"] }
42
+ },
43
+ "lift": {
44
+ "value": 0.161, "unit": "ratio-point",
45
+ "definition": "relative change in conversion",
46
+ "derived": { "op": "pct_change", "before": "conversion_before", "after": "conversion_after" }
47
+ }
48
+ },
49
+ "identifiers": { "app_version": "3.2.0", "flag": "checkout_v2" }
50
+ }
51
+ ```
52
+
53
+ - `source.type` is free-form (`sql`, `mixpanel`, `csv`, `hypothesis`, …);
54
+ put enough alongside it that a stranger could re-fetch the value.
55
+ - Numbers that *name* rather than *measure* (versions, flags, unit IDs) go
56
+ in `identifiers`, not `metrics`.
57
+ - Anything computed from other metrics must be `derived` — the compiler
58
+ recomputes it and rejects mismatches beyond the value's own precision.
59
+ - `ratio` / `ratio-point` metrics require a `definition`. Ratios above 1.0
60
+ are legal but the definition must explain the basis.
61
+ - A hypothesis or plan figure is still a metric — source it as
62
+ `{ "type": "hypothesis", ... }` pointing at the planning doc.
63
+
64
+ **3. Write the narrative (`report.md`).**
65
+ Never type a figure into prose. Reference it:
66
+
67
+ - `{{m:conversion_after}}` — renders the value with its receipt
68
+ - `{{id:app_version}}` — identifier
69
+ - `{{claim: the fallback works on both platforms | evidence: recovery_ios, recovery_android}}`
70
+ — a qualitative conclusion, pinned to the metrics that support it
71
+ - `{{raw:24/7}}` — escape hatch for rhetorical numbers; it is greppable,
72
+ so use it rarely and honestly
73
+
74
+ **4. Compile, and treat every leak as a question about the data.**
75
+
76
+ ```bash
77
+ watertight report.md metrics.json # writes report.html
78
+ watertight . --format md # grounded markdown (Notion / PR / Slack)
79
+ watertight . --check # CI mode: verify, write nothing
80
+ ```
81
+
82
+ Fix leaks by *going and getting the receipt* — running the query, opening
83
+ the export — never by deleting the number, weakening the claim, or wrapping
84
+ a measurement in `{{raw:}}` to silence the checker. A `derived-mismatch` is
85
+ the tool telling you a stated total or delta does not follow from its
86
+ inputs: recompute at the source and correct whichever side is wrong.
87
+
88
+ **5. Re-verify later with `watertight refresh .`** — re-fetches csv/json
89
+ sources, updates `fetched_at`, recomputes derived values, and names every
90
+ metric it could *not* refresh. `command` sources run only under
91
+ `--allow-commands`; never pass that flag on an IR you did not author.
92
+
93
+ ## What stays yours
94
+
95
+ The compiler proves every number has a source and every arithmetic step
96
+ checks out. It does not judge whether the evidence actually supports the
97
+ claim — that judgment is the author's, and pinning claims to named metrics
98
+ exists to make that judgment inspectable, not to automate it away.
99
+
100
+ ## Hard rules
101
+
102
+ - Never invent, estimate, or "recall" a value into the IR. No source, no number.
103
+ - Never edit a `value` to make a `derived-mismatch` pass. Fix the inputs.
104
+ - Real company data stays in private storage; fixtures and examples are fictional.
package/dist/cli.js ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { access, readFile, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { compile } from './compile.js';
5
+ import { refresh } from './refresh.js';
6
+ const USAGE = `watertight — reports that hold water. Every number carries its receipt;
7
+ an ungrounded claim is a leak, and a report with leaks does not build.
8
+
9
+ Usage
10
+ watertight <dir> compile <dir>/report.md + <dir>/metrics.json → <dir>/report.html
11
+ watertight <report> <ir> explicit file paths
12
+ watertight refresh <dir> re-fetch metric values from their sources, update metrics.json
13
+
14
+ Options
15
+ --format <html|md> output format (default: html). md is grounded markdown with a
16
+ receipts appendix — pastes into Notion, PR bodies or Slack intact
17
+ --out <file> where to write the output (default: report.html / report.grounded.md)
18
+ --check verify only, write nothing
19
+ --dry-run refresh only: show what would change, write nothing
20
+ --allow-commands refresh only: let "command" sources run shell (off by default —
21
+ an IR from someone else's repo must not execute code on your machine)
22
+ --json machine-readable result on stdout
23
+ -v, --version print the version
24
+ -h, --help show this message
25
+
26
+ watertight only reads the two input files and writes the one output file — nothing else.
27
+ `;
28
+ function parseArgs(argv) {
29
+ const args = argv.slice(2);
30
+ const positional = [];
31
+ let out;
32
+ let check = false;
33
+ let json = false;
34
+ let dryRun = false;
35
+ let allowCommands = false;
36
+ let format = 'html';
37
+ let help = false;
38
+ let version = false;
39
+ for (let i = 0; i < args.length; i++) {
40
+ const arg = args[i];
41
+ if (arg === '--out')
42
+ out = args[++i];
43
+ else if (arg === '--check')
44
+ check = true;
45
+ else if (arg === '--dry-run')
46
+ dryRun = true;
47
+ else if (arg === '--allow-commands')
48
+ allowCommands = true;
49
+ else if (arg === '--format')
50
+ format = args[++i] === 'md' ? 'md' : 'html';
51
+ else if (arg === '--json')
52
+ json = true;
53
+ else if (arg === '-h' || arg === '--help')
54
+ help = true;
55
+ else if (arg === '-v' || arg === '--version')
56
+ version = true;
57
+ else if (!arg.startsWith('-'))
58
+ positional.push(arg);
59
+ }
60
+ const command = positional[0] === 'refresh' ? 'refresh' : 'compile';
61
+ if (command === 'refresh')
62
+ positional.shift();
63
+ return { command, positional, out, check, json, help, version, dryRun, allowCommands, format };
64
+ }
65
+ async function exists(path) {
66
+ try {
67
+ await access(path);
68
+ return true;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ async function main() {
75
+ const opts = parseArgs(process.argv);
76
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
77
+ if (opts.version)
78
+ return console.log(pkg.version);
79
+ if (opts.help)
80
+ return console.log(USAGE);
81
+ // a mistyped path must fail loudly, never read as an empty report
82
+ let reportPath;
83
+ let irPath;
84
+ if (opts.positional.length === 2) {
85
+ ;
86
+ [reportPath, irPath] = opts.positional.map((p) => resolve(p));
87
+ }
88
+ else {
89
+ const dir = resolve(opts.positional[0] ?? '.');
90
+ reportPath = join(dir, 'report.md');
91
+ irPath = join(dir, 'metrics.json');
92
+ }
93
+ for (const path of [reportPath, irPath]) {
94
+ if (!(await exists(path))) {
95
+ console.error(`Not found: ${path}`);
96
+ console.error('Expected report.md and metrics.json — see --help.');
97
+ process.exit(2);
98
+ }
99
+ }
100
+ if (opts.command === 'refresh') {
101
+ const r = await refresh(irPath, { allowCommands: opts.allowCommands, dryRun: opts.dryRun });
102
+ for (const c of r.changes)
103
+ console.log(` ${c.key}: ${c.before.toLocaleString()} → ${c.after.toLocaleString()}`);
104
+ for (const s of r.skipped)
105
+ console.log(` ~ ${s.key} skipped — ${s.reason}`);
106
+ for (const e of r.errors)
107
+ console.error(` ✗ ${e.key}: ${e.message}`);
108
+ console.log(r.errors.length > 0
109
+ ? `\n${r.errors.length} fetch error(s) — metrics.json ${r.wrote ? 'partially updated' : 'not written'}`
110
+ : `\n${r.changes.length} change(s)${opts.dryRun ? ' (dry run — nothing written)' : r.wrote ? ` — updated ${irPath}` : ''}`);
111
+ process.exit(r.errors.length > 0 ? 1 : 0);
112
+ }
113
+ const result = await compile(reportPath, irPath, opts.format);
114
+ const defaultName = opts.format === 'md' ? 'report.grounded.md' : 'report.html';
115
+ const outPath = resolve(opts.out ?? join(reportPath, '..', defaultName));
116
+ if (opts.json) {
117
+ console.log(JSON.stringify({ version: pkg.version, ...result, output: undefined, wrote: result.output && !opts.check ? outPath : undefined }, null, 2));
118
+ }
119
+ else {
120
+ console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers`);
121
+ if (result.leaks.length > 0) {
122
+ console.log(`\n${result.leaks.length} leak(s) — the report does not hold water:\n`);
123
+ for (const leak of result.leaks) {
124
+ console.log(` ✗ [${leak.rule}] ${leak.message}`);
125
+ if (leak.detail)
126
+ console.log(` ${leak.detail}`);
127
+ }
128
+ console.log();
129
+ }
130
+ }
131
+ if (result.output && !opts.check) {
132
+ await writeFile(outPath, result.output);
133
+ if (!opts.json)
134
+ console.log(`holds water → ${outPath}\n`);
135
+ }
136
+ else if (result.output && opts.check && !opts.json) {
137
+ console.log('holds water (check only — nothing written)\n');
138
+ }
139
+ process.exit(result.leaks.length > 0 ? 1 : 0);
140
+ }
141
+ main().catch((err) => {
142
+ console.error(err instanceof Error ? err.message : err);
143
+ process.exit(2);
144
+ });
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parseIr } from './ir.js';
3
+ import { render } from './render.js';
4
+ import { renderMarkdown } from './renderMd.js';
5
+ import { scanNakedNumbers, scanRefs } from './scan.js';
6
+ export async function compile(reportPath, irPath, format = 'html') {
7
+ const report = await readFile(reportPath, 'utf8');
8
+ const { ir, leaks } = parseIr(JSON.parse(await readFile(irPath, 'utf8')));
9
+ const grounded = {
10
+ metrics: [...report.matchAll(/\{\{m:/g)].length,
11
+ identifiers: [...report.matchAll(/\{\{id:/g)].length,
12
+ claims: [...report.matchAll(/\{\{claim:/g)].length,
13
+ };
14
+ leaks.push(...scanNakedNumbers(report));
15
+ if (ir)
16
+ leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers))));
17
+ if (leaks.length > 0 || !ir)
18
+ return { leaks, grounded };
19
+ return { leaks, output: format === 'md' ? renderMarkdown(report, ir) : render(report, ir), grounded };
20
+ }
package/dist/ir.js ADDED
@@ -0,0 +1,96 @@
1
+ /** Units whose meaning depends on how they were aggregated — a bare value misleads. */
2
+ const DEFINITION_REQUIRED = new Set(['ratio', 'ratio-point']);
3
+ function decimals(n) {
4
+ const s = String(n);
5
+ const dot = s.indexOf('.');
6
+ return dot === -1 ? 0 : s.length - dot - 1;
7
+ }
8
+ /**
9
+ * A stored derived value must be correctly rounded to its own precision: writing 0.15
10
+ * for a computed 0.155 is a mismatch, writing 0.155 for 0.15517 is fine. This is what
11
+ * caught a rounding error in the first real document this schema was tested against.
12
+ */
13
+ function roundsTo(stored, computed) {
14
+ return Math.abs(stored - computed) < 0.5 * 10 ** -decimals(stored);
15
+ }
16
+ export function parseIr(raw) {
17
+ const leaks = [];
18
+ const root = (raw ?? {});
19
+ const identifiers = {};
20
+ for (const [k, v] of Object.entries(root['identifiers'] ?? {})) {
21
+ identifiers[k] = String(v);
22
+ }
23
+ const metrics = root['metrics'] ?? {};
24
+ if (Object.keys(metrics).length === 0) {
25
+ leaks.push({
26
+ severity: 'error',
27
+ rule: 'empty-ir',
28
+ message: 'The IR declares no metrics',
29
+ detail: 'A report with nothing grounded is not a watertight report.',
30
+ });
31
+ return { leaks };
32
+ }
33
+ 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` });
36
+ continue;
37
+ }
38
+ if (typeof m.unit !== 'string' || m.unit.length === 0) {
39
+ leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no unit` });
40
+ }
41
+ if (DEFINITION_REQUIRED.has(m.unit) && !m.definition) {
42
+ leaks.push({
43
+ severity: 'error',
44
+ rule: 'definition-required',
45
+ message: `metric "${key}" is a ${m.unit} without a definition`,
46
+ detail: 'Ratios mislead without their aggregation basis — a fill rate above 100% is either a bug or a definition, and the reader must be told which.',
47
+ });
48
+ }
49
+ if (m.derived) {
50
+ if (Array.isArray(m.value)) {
51
+ leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}": a range cannot be derived` });
52
+ continue;
53
+ }
54
+ if (m.derived.op === 'sum') {
55
+ let computed = 0;
56
+ let broken = false;
57
+ for (const ref of m.derived.of) {
58
+ const part = metrics[ref];
59
+ if (!part || typeof part.value !== 'number') {
60
+ leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}" sums unknown or non-scalar metric "${ref}"` });
61
+ broken = true;
62
+ continue;
63
+ }
64
+ computed += part.value;
65
+ }
66
+ if (!broken && computed !== m.value) {
67
+ leaks.push({
68
+ severity: 'error',
69
+ rule: 'derived-mismatch',
70
+ message: `metric "${key}" is ${m.value}, but its parts sum to ${computed}`,
71
+ });
72
+ }
73
+ }
74
+ else if (m.derived.op === 'pct_change') {
75
+ const computed = (m.derived.after - m.derived.before) / m.derived.before;
76
+ if (!roundsTo(m.value, computed)) {
77
+ leaks.push({
78
+ severity: 'error',
79
+ rule: 'derived-mismatch',
80
+ message: `metric "${key}" is ${m.value}, but ${m.derived.before} → ${m.derived.after} computes to ${computed.toFixed(4)}`,
81
+ detail: 'A derived value must be correctly rounded to its own precision.',
82
+ });
83
+ }
84
+ }
85
+ }
86
+ else {
87
+ // a measured value must say where it came from and when
88
+ for (const field of ['source', 'window', 'fetched_at']) {
89
+ if (m[field] === undefined) {
90
+ leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no ${field}` });
91
+ }
92
+ }
93
+ }
94
+ }
95
+ return { ir: { identifiers, metrics }, leaks };
96
+ }
@@ -0,0 +1,125 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ /** "B4" → row 4, column B. The spreadsheet convention people already know. */
5
+ function cellToIndex(cell) {
6
+ const m = /^([A-Z]+)(\d+)$/i.exec(cell.trim());
7
+ if (!m)
8
+ throw new Error(`not an A1-style cell: "${cell}"`);
9
+ let col = 0;
10
+ for (const ch of m[1].toUpperCase())
11
+ col = col * 26 + (ch.charCodeAt(0) - 64);
12
+ return { row: Number(m[2]) - 1, col: col - 1 };
13
+ }
14
+ /** Minimal CSV: quoted fields with embedded commas supported, embedded newlines not. */
15
+ function parseCsvLine(line) {
16
+ const out = [];
17
+ let field = '';
18
+ let quoted = false;
19
+ for (let i = 0; i < line.length; i++) {
20
+ const ch = line[i];
21
+ if (quoted) {
22
+ if (ch === '"' && line[i + 1] === '"') {
23
+ field += '"';
24
+ i++;
25
+ }
26
+ else if (ch === '"')
27
+ quoted = false;
28
+ else
29
+ field += ch;
30
+ }
31
+ else if (ch === '"')
32
+ quoted = true;
33
+ else if (ch === ',') {
34
+ out.push(field);
35
+ field = '';
36
+ }
37
+ else
38
+ field += ch;
39
+ }
40
+ out.push(field);
41
+ return out;
42
+ }
43
+ function toNumber(raw, where) {
44
+ const n = Number(String(raw).replace(/[,\s]/g, ''));
45
+ if (!Number.isFinite(n))
46
+ throw new Error(`${where} is not a number: "${raw}"`);
47
+ return n;
48
+ }
49
+ async function fetchCsv(source, baseDir) {
50
+ const text = await readFile(resolve(baseDir, String(source['file'])), 'utf8');
51
+ const { row, col } = cellToIndex(String(source['cell']));
52
+ const lines = text.split(/\r?\n/);
53
+ if (row >= lines.length)
54
+ throw new Error(`row ${row + 1} is past the end of ${source['file']}`);
55
+ const fields = parseCsvLine(lines[row]);
56
+ if (col >= fields.length)
57
+ throw new Error(`column ${source['cell']} is past the end of row ${row + 1}`);
58
+ return toNumber(fields[col], `${source['file']}!${source['cell']}`);
59
+ }
60
+ async function fetchJson(source, baseDir) {
61
+ const data = JSON.parse(await readFile(resolve(baseDir, String(source['file'])), 'utf8'));
62
+ let node = data;
63
+ const path = String(source['path']);
64
+ for (const part of path.split(/[.[\]]+/).filter(Boolean)) {
65
+ node = node?.[part];
66
+ }
67
+ return toNumber(node, `${source['file']} → ${path}`);
68
+ }
69
+ function fetchCommand(source, baseDir) {
70
+ const stdout = execSync(String(source['run']), { cwd: baseDir, encoding: 'utf8', timeout: 30_000 });
71
+ return toNumber(stdout.trim().split('\n').at(-1), `command output`);
72
+ }
73
+ export async function refresh(irPath, options) {
74
+ const raw = JSON.parse(await readFile(irPath, 'utf8'));
75
+ const baseDir = dirname(resolve(irPath));
76
+ const result = { changes: [], skipped: [], errors: [], wrote: false };
77
+ const now = new Date().toISOString();
78
+ for (const [key, m] of Object.entries(raw.metrics)) {
79
+ if (m.derived || !m.source || Array.isArray(m.value))
80
+ continue;
81
+ try {
82
+ let value;
83
+ if (m.source.type === 'csv')
84
+ value = await fetchCsv(m.source, baseDir);
85
+ else if (m.source.type === 'json')
86
+ value = await fetchJson(m.source, baseDir);
87
+ else if (m.source.type === 'command') {
88
+ if (!options.allowCommands) {
89
+ result.skipped.push({ key, reason: 'command sources run only with --allow-commands' });
90
+ continue;
91
+ }
92
+ value = fetchCommand(m.source, baseDir);
93
+ }
94
+ else {
95
+ result.skipped.push({ key, reason: `no built-in adapter for source type "${m.source.type}"` });
96
+ continue;
97
+ }
98
+ if (value !== m.value)
99
+ result.changes.push({ key, before: m.value, after: value });
100
+ m.value = value;
101
+ m.fetched_at = now;
102
+ }
103
+ catch (err) {
104
+ result.errors.push({ key, message: err instanceof Error ? err.message : String(err) });
105
+ }
106
+ }
107
+ // parts may have moved, so stored sums are recomputed rather than left to go stale
108
+ for (const [key, m] of Object.entries(raw.metrics)) {
109
+ if (m.derived?.op !== 'sum' || Array.isArray(m.value))
110
+ continue;
111
+ const computed = m.derived.of.reduce((acc, ref) => {
112
+ const part = raw.metrics[ref];
113
+ return acc + (part && typeof part.value === 'number' ? part.value : NaN);
114
+ }, 0);
115
+ if (Number.isFinite(computed) && computed !== m.value) {
116
+ result.changes.push({ key, before: m.value, after: computed });
117
+ m.value = computed;
118
+ }
119
+ }
120
+ if (!options.dryRun && (result.changes.length > 0 || result.errors.length === 0)) {
121
+ await writeFile(irPath, `${JSON.stringify(raw, null, 2)}\n`);
122
+ result.wrote = true;
123
+ }
124
+ return result;
125
+ }
package/dist/render.js ADDED
@@ -0,0 +1,66 @@
1
+ function escapeHtml(s) {
2
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3
+ }
4
+ export function formatValue(m) {
5
+ if (Array.isArray(m.value)) {
6
+ const [lo, hi] = m.value;
7
+ return `${lo.toLocaleString()}~${hi.toLocaleString()}${m.unit === 'ratio' ? '%' : ` ${m.unit}`}`;
8
+ }
9
+ if (m.unit === 'ratio')
10
+ return `${(m.value * 100).toFixed(m.value < 0.01 ? 2 : 1)}%`;
11
+ if (m.unit === 'ratio-point')
12
+ return `${m.value >= 0 ? '+' : ''}${(m.value * 100).toFixed(1)}%p`;
13
+ return `${m.value.toLocaleString()} ${m.unit}`.trim();
14
+ }
15
+ export function receipt(m) {
16
+ const source = m.derived
17
+ ? m.derived.op === 'sum'
18
+ ? `= ${m.derived.of.join(' + ')} (recomputed)`
19
+ : `= ${m.derived.before} → ${m.derived.after} (recomputed)`
20
+ : [m.source?.type, ...Object.entries(m.source ?? {}).filter(([k]) => k !== 'type').map(([, v]) => String(v))].filter(Boolean).join(' · ');
21
+ return [source, m.window, m.fetched_at && `fetched ${m.fetched_at}`, m.definition].filter(Boolean).join(' · ');
22
+ }
23
+ /** Minimal markdown: headings, paragraphs, bold, unordered lists. The narrative layer is deliberately thin. */
24
+ function markdown(src) {
25
+ return src
26
+ .split(/\n{2,}/)
27
+ .map((block) => {
28
+ const h = block.match(/^(#{1,3}) (.*)$/s);
29
+ if (h)
30
+ return `<h${h[1].length}>${h[2]}</h${h[1].length}>`;
31
+ if (/^- /m.test(block)) {
32
+ return `<ul>${block.split('\n').map((l) => `<li>${l.replace(/^- /, '')}</li>`).join('')}</ul>`;
33
+ }
34
+ return `<p>${block.replace(/\n/g, '<br>')}</p>`;
35
+ })
36
+ .join('\n')
37
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
38
+ }
39
+ export function render(report, ir) {
40
+ const grounded = escapeHtml(report)
41
+ .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
42
+ const m = ir.metrics[key];
43
+ return `<b class="w" title="${escapeHtml(receipt(m))}">${formatValue(m)}<sup>†</sup></b>`;
44
+ })
45
+ .replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `<code>${escapeHtml(ir.identifiers[key])}</code>`)
46
+ .replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => {
47
+ const keys = evidence.split(',').map((k) => k.trim()).filter(Boolean);
48
+ const receipts = keys
49
+ .map((k) => `${k} = ${formatValue(ir.metrics[k])} (${receipt(ir.metrics[k])})`)
50
+ .join(' | ');
51
+ return `<span class="c" title="${escapeHtml(receipts)}">${text.trim()}<sup>‡</sup></span>`;
52
+ })
53
+ .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text));
54
+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
55
+ <style>
56
+ body{font:15px/1.75 -apple-system,system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1a1a1a}
57
+ @media(prefers-color-scheme:dark){body{background:#111;color:#ddd}code{background:#222}}
58
+ h1,h2,h3{line-height:1.3}
59
+ .w{border-bottom:2px solid #4a9;cursor:help;font-weight:600}
60
+ .c{border-bottom:2px dotted #4a9;cursor:help}
61
+ code{background:#eee;padding:1px 5px;border-radius:4px;font-size:.9em}
62
+ sup{font-size:.65em;color:#4a9}
63
+ </style>
64
+ <body>${markdown(grounded)}
65
+ <hr><p style="color:#888;font-size:.85em">Compiled by watertight — every underlined figure carries its receipt (hover to see it).</p>`;
66
+ }
@@ -0,0 +1,27 @@
1
+ import { formatValue, receipt } from './render.js';
2
+ /**
3
+ * Grounded markdown: the portable render target. Every figure is bold with a superscript
4
+ * that points into a receipts appendix, so the output pastes into Notion, a PR body, or
5
+ * Slack without losing its provenance. Posting is deliberately left to the caller —
6
+ * keeping publishing out of the tool is what keeps the tool free of anyone's auth.
7
+ */
8
+ export function renderMarkdown(report, ir) {
9
+ const used = [];
10
+ const body = report
11
+ .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
12
+ if (!used.includes(key))
13
+ used.push(key);
14
+ return `**${formatValue(ir.metrics[key])}** ⁽${used.indexOf(key) + 1}⁾`;
15
+ })
16
+ .replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `\`${ir.identifiers[key]}\``)
17
+ .replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => `**${text.trim()}** *(evidence: ${evidence.trim()})*`)
18
+ .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => text);
19
+ const appendix = used
20
+ .map((key, i) => {
21
+ const m = ir.metrics[key];
22
+ const definition = m.definition ? ` — ${m.definition}` : '';
23
+ return `${i + 1}. **${key}** = ${formatValue(m)}${definition}\n ${receipt(m)}`;
24
+ })
25
+ .join('\n');
26
+ return `${body}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}\n`;
27
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Find numbers in the narrative that are not bound to the IR. These are the leaks the
3
+ * tool exists to catch: a typed, transcribed, or hallucinated figure reads exactly like
4
+ * a real one, so none are allowed outside a reference.
5
+ */
6
+ export function scanNakedNumbers(report) {
7
+ const stripped = report
8
+ .replace(/\{\{(m|id):[\w-]+\}\}/g, ' ')
9
+ // a claim's prose stays scanned — only its syntax is removed, so a number
10
+ // smuggled into claim text is still a leak
11
+ .replace(/\{\{claim:([^|}]*)\|[^}]*\}\}/g, (_, text) => ` ${text} `)
12
+ .replace(/\{\{raw:[^}]*\}\}/g, ' ') // explicit, greppable escape hatch
13
+ .replace(/```[\s\S]*?```/g, ' ') // fenced code
14
+ .replace(/`[^`\n]*`/g, ' ') // inline code
15
+ .replace(/\d{4}-\d{2}-\d{2}/g, ' ') // ISO dates locate, they do not measure
16
+ .replace(/^#+ .*$/gm, ' ') // headings
17
+ .replace(/^\s*\d+\.\s/gm, ' '); // ordered-list markers
18
+ const leaks = [];
19
+ for (const m of stripped.matchAll(/\d[\d,.]*\s*(%p?|[가-힣]{1,2})?/g)) {
20
+ const token = m[0].trim();
21
+ if (!token)
22
+ continue;
23
+ leaks.push({
24
+ severity: 'error',
25
+ rule: 'naked-number',
26
+ message: `"${token}" appears in the narrative without a receipt`,
27
+ detail: 'Bind it to the IR as {{m:…}} or {{id:…}}, or mark deliberate prose as {{raw:…}}.',
28
+ });
29
+ }
30
+ return leaks;
31
+ }
32
+ /** Every reference in the narrative must resolve; a dangling one is authoring drift. */
33
+ export function scanRefs(report, metricKeys, idKeys) {
34
+ const leaks = [];
35
+ for (const [, text, evidence] of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
36
+ const keys = evidence.split(',').map((k) => k.trim()).filter(Boolean);
37
+ if (keys.length === 0) {
38
+ leaks.push({
39
+ severity: 'error',
40
+ rule: 'claim-without-evidence',
41
+ message: `claim "${text.trim()}" names no evidence`,
42
+ detail: 'A conclusion must point at the metrics it rests on. Whether they support it stays a human judgement — but they must be attached.',
43
+ });
44
+ }
45
+ for (const key of keys) {
46
+ if (!metricKeys.has(key)) {
47
+ leaks.push({ severity: 'error', rule: 'unknown-ref', message: `claim "${text.trim()}" cites unknown metric "${key}"` });
48
+ }
49
+ }
50
+ }
51
+ // a claim marker missing its evidence clause entirely is malformed, not exempt
52
+ for (const [, text] of report.matchAll(/\{\{claim:([^|}]*)\}\}/g)) {
53
+ leaks.push({
54
+ severity: 'error',
55
+ rule: 'claim-without-evidence',
56
+ message: `claim "${text.trim()}" names no evidence`,
57
+ detail: 'Write it as {{claim: … | evidence: metric_key}}.',
58
+ });
59
+ }
60
+ for (const [, key] of report.matchAll(/\{\{m:([\w-]+)\}\}/g)) {
61
+ if (!metricKeys.has(key)) {
62
+ leaks.push({ severity: 'error', rule: 'unknown-ref', message: `{{m:${key}}} is not in the IR` });
63
+ }
64
+ }
65
+ for (const [, key] of report.matchAll(/\{\{id:([\w-]+)\}\}/g)) {
66
+ if (!idKeys.has(key)) {
67
+ leaks.push({ severity: 'error', rule: 'unknown-ref', message: `{{id:${key}}} is not in the IR` });
68
+ }
69
+ }
70
+ return leaks;
71
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "watertight",
3
+ "version": "0.1.0",
4
+ "description": "Reports that hold water \u2014 every number carries its receipt, and ungrounded claims fail the build.",
5
+ "type": "module",
6
+ "bin": {
7
+ "watertight": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "SKILL.md"
12
+ ],
13
+ "scripts": {
14
+ "dev": "tsx src/cli.ts",
15
+ "build": "tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "node --import tsx --test tests/*.test.ts",
18
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
19
+ },
20
+ "keywords": [
21
+ "report",
22
+ "provenance",
23
+ "grounding",
24
+ "metrics",
25
+ "verification",
26
+ "hallucination",
27
+ "documentation",
28
+ "ci"
29
+ ],
30
+ "author": "Hosung Kang (https://github.com/camosss)",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^26.4.0",
37
+ "tsx": "^4.23.12",
38
+ "typescript": "^7.0.2"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/camosss/watertight.git"
43
+ },
44
+ "homepage": "https://github.com/camosss/watertight#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/camosss/watertight/issues"
47
+ }
48
+ }