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