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