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 +21 -0
- package/README.md +188 -0
- package/SKILL.md +104 -0
- package/dist/cli.js +144 -0
- package/dist/compile.js +20 -0
- package/dist/ir.js +96 -0
- package/dist/refresh.js +125 -0
- package/dist/render.js +66 -0
- package/dist/renderMd.js +27 -0
- package/dist/scan.js +71 -0
- package/dist/types.js +1 -0
- package/package.json +48 -0
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
|
+
});
|
package/dist/compile.js
ADDED
|
@@ -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
|
+
}
|
package/dist/refresh.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|
package/dist/renderMd.js
ADDED
|
@@ -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
|
+
}
|