watertight 0.6.0 → 0.7.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 +25 -3
- package/SKILL.md +10 -2
- package/dist/cli.js +66 -11
- package/dist/compile.js +11 -0
- package/dist/ir.js +48 -11
- package/dist/refresh.js +25 -13
- package/dist/render.js +5 -1
- package/dist/renderMd.js +9 -1
- package/dist/scan.js +1 -0
- 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": {
|
|
@@ -122,6 +124,10 @@ hypothesised {{m:revenue_target}}.
|
|
|
122
124
|
Support runs {{raw:24/7}}.
|
|
123
125
|
```
|
|
124
126
|
|
|
127
|
+
`{{raw:}}` is the escape hatch, and it stays visible: the compile header counts
|
|
128
|
+
raw escapes, the markdown render lists them in an **Ungrounded** section, and
|
|
129
|
+
`--max-raw <n>` turns the count into a gate.
|
|
130
|
+
|
|
125
131
|
Compile:
|
|
126
132
|
|
|
127
133
|
```bash
|
|
@@ -174,13 +180,29 @@ figure shows its receipt.
|
|
|
174
180
|
| `bad-derived` | derived ops referencing missing or non-numeric inputs |
|
|
175
181
|
| `empty-ir` | a report "grounded" in nothing |
|
|
176
182
|
| `stale-metric` | with `--max-age <days>`: a receipt whose `fetched_at` is older than the budget — numbers age |
|
|
183
|
+
| `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
|
+
| `raw-budget` | with `--max-raw <n>`: more `{{raw:}}` escapes than the budget — the escape hatch stays boundable |
|
|
177
185
|
|
|
178
186
|
<br>
|
|
179
187
|
|
|
180
188
|
## Re-verification
|
|
181
189
|
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
**`watertight verify .`** re-fetches every reachable source and *compares* —
|
|
191
|
+
a stored value the source no longer returns is a `receipt-mismatch`, nothing
|
|
192
|
+
is written, and the run fails. This is the check that catches a plausible
|
|
193
|
+
receipt attached to a wrong value (the "AI remembered a number" failure) and
|
|
194
|
+
the natural CI companion to `--check`.
|
|
195
|
+
|
|
196
|
+
`watertight refresh .` is the writing counterpart: it re-fetches, rewrites
|
|
197
|
+
`value` and `fetched_at`, recomputes derived values — and when a changed
|
|
198
|
+
metric is cited as claim evidence, it says so:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
fallback_revenue_total: 1,428 → 45,200
|
|
202
|
+
⚠ claim "수익 가설 미달" cites fallback_revenue_total — the number moved, review the conclusion
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Conclusions age like numbers do. Sources:
|
|
184
206
|
|
|
185
207
|
- `csv` sources — `{ "type": "csv", "file": "data.csv", "cell": "B2" }`
|
|
186
208
|
- `json` sources — `{ "type": "json", "file": "kpi.json", "path": "revenue.total" }`
|
package/SKILL.md
CHANGED
|
@@ -53,9 +53,12 @@ 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`, `pct_change`, `ratio`, `diff`. Never hand-compute one of
|
|
61
|
+
these and present it as measured — that is an unverified value.
|
|
59
62
|
- `ratio` / `ratio-point` metrics require a `definition`. Ratios above 1.0
|
|
60
63
|
are legal but the definition must explain the basis.
|
|
61
64
|
- A hypothesis or plan figure is still a metric — source it as
|
|
@@ -87,7 +90,10 @@ a measurement in `{{raw:}}` to silence the checker. A `derived-mismatch` is
|
|
|
87
90
|
the tool telling you a stated total or delta does not follow from its
|
|
88
91
|
inputs: recompute at the source and correct whichever side is wrong.
|
|
89
92
|
|
|
90
|
-
**5.
|
|
93
|
+
**5. Before shipping, run `watertight verify .`** — it re-fetches every
|
|
94
|
+
reachable source and fails on any stored value the source does not return.
|
|
95
|
+
A receipt you attached from memory will not survive this step; that is the
|
|
96
|
+
point. Then re-verify later with `watertight refresh .` — re-fetches csv/json
|
|
91
97
|
sources, updates `fetched_at`, recomputes derived values, and names every
|
|
92
98
|
metric it could *not* refresh. `command` sources run only under
|
|
93
99
|
`--allow-commands`; never pass that flag on an IR you did not author.
|
|
@@ -105,5 +111,7 @@ exists to make that judgment inspectable, not to automate it away.
|
|
|
105
111
|
## Hard rules
|
|
106
112
|
|
|
107
113
|
- Never invent, estimate, or "recall" a value into the IR. No source, no number.
|
|
114
|
+
- Never wrap a measurement in `{{raw:}}` to pass the compile — raw counts are
|
|
115
|
+
printed, disclosed in the render, and gated by `--max-raw`.
|
|
108
116
|
- Never edit a `value` to make a `derived-mismatch` pass. Fix the inputs.
|
|
109
117
|
- Real company data stays in private storage; fixtures and examples are fictional.
|
package/dist/cli.js
CHANGED
|
@@ -13,20 +13,24 @@ Usage
|
|
|
13
13
|
watertight <dir> compile <dir>/report.md + <dir>/metrics.json → <dir>/report.html
|
|
14
14
|
watertight <report> <ir> explicit file paths
|
|
15
15
|
watertight refresh <dir> re-fetch metric values from their sources, update metrics.json
|
|
16
|
+
watertight verify <dir> re-fetch and COMPARE — a stored value its source no longer
|
|
17
|
+
returns is a receipt-mismatch; nothing is written
|
|
16
18
|
watertight init [dir] scaffold a report.md + metrics.json pair that already holds water
|
|
17
19
|
|
|
18
20
|
Options
|
|
19
21
|
--max-age <days> compile only: fail any metric whose fetched_at is older —
|
|
20
22
|
numbers age, and a stale receipt is quietly becoming a leak
|
|
23
|
+
--max-raw <n> compile only: fail when the report uses more than n {{raw:}}
|
|
24
|
+
escapes — the escape hatch must stay boundable
|
|
21
25
|
--format <html|md> output format (default: html). md is grounded markdown with a
|
|
22
26
|
receipts appendix — pastes into Notion, PR bodies or Slack intact
|
|
23
27
|
--out <file> where to write the output (default: report.html / report.grounded.md)
|
|
24
28
|
--check verify only, write nothing
|
|
25
29
|
--dry-run refresh only: show what would change, write nothing
|
|
26
|
-
--fetchers <file> refresh
|
|
30
|
+
--fetchers <file> refresh/verify: a JS module of custom source adapters, e.g.
|
|
27
31
|
export function mixpanel(source) { ... return value }
|
|
28
32
|
Loading a module runs its code — only pass files you wrote or trust.
|
|
29
|
-
--allow-commands refresh
|
|
33
|
+
--allow-commands refresh/verify: let "command" sources run shell (off by default —
|
|
30
34
|
an IR from someone else's repo must not execute code on your machine)
|
|
31
35
|
--json machine-readable result on stdout
|
|
32
36
|
-v, --version print the version
|
|
@@ -45,6 +49,7 @@ function parseArgs(argv) {
|
|
|
45
49
|
let fetchersPath;
|
|
46
50
|
let format = 'html';
|
|
47
51
|
let maxAgeDays;
|
|
52
|
+
let maxRaw;
|
|
48
53
|
let help = false;
|
|
49
54
|
let version = false;
|
|
50
55
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -68,6 +73,13 @@ function parseArgs(argv) {
|
|
|
68
73
|
process.exit(2);
|
|
69
74
|
}
|
|
70
75
|
}
|
|
76
|
+
else if (arg === '--max-raw') {
|
|
77
|
+
maxRaw = Number(args[++i]);
|
|
78
|
+
if (!Number.isFinite(maxRaw) || maxRaw < 0) {
|
|
79
|
+
console.error(`error: --max-raw needs a number, got "${args[i]}"`);
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
71
83
|
else if (arg === '--json')
|
|
72
84
|
json = true;
|
|
73
85
|
else if (arg === '-h' || arg === '--help')
|
|
@@ -82,10 +94,10 @@ function parseArgs(argv) {
|
|
|
82
94
|
else
|
|
83
95
|
positional.push(arg);
|
|
84
96
|
}
|
|
85
|
-
const command =
|
|
97
|
+
const command = ['refresh', 'init', 'verify'].includes(positional[0]) ? positional[0] : 'compile';
|
|
86
98
|
if (command !== 'compile')
|
|
87
99
|
positional.shift();
|
|
88
|
-
return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays };
|
|
100
|
+
return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath, maxAgeDays, maxRaw };
|
|
89
101
|
}
|
|
90
102
|
async function exists(path) {
|
|
91
103
|
try {
|
|
@@ -129,15 +141,15 @@ async function main() {
|
|
|
129
141
|
reportPath = join(dir, 'report.md');
|
|
130
142
|
irPath = join(dir, 'metrics.json');
|
|
131
143
|
}
|
|
132
|
-
// refresh
|
|
133
|
-
for (const path of opts.command === 'refresh' ? [irPath] : [reportPath, irPath]) {
|
|
144
|
+
// refresh/verify work on the IR alone — an IR-only directory is a legitimate workspace
|
|
145
|
+
for (const path of opts.command === 'refresh' || opts.command === 'verify' ? [irPath] : [reportPath, irPath]) {
|
|
134
146
|
if (!(await exists(path))) {
|
|
135
147
|
console.error(`Not found: ${path}`);
|
|
136
148
|
console.error('Expected report.md and metrics.json — see --help.');
|
|
137
149
|
process.exit(2);
|
|
138
150
|
}
|
|
139
151
|
}
|
|
140
|
-
if (opts.command === 'refresh') {
|
|
152
|
+
if (opts.command === 'refresh' || opts.command === 'verify') {
|
|
141
153
|
let fetchers;
|
|
142
154
|
if (opts.fetchersPath) {
|
|
143
155
|
const mod = await import(pathToFileURL(resolve(opts.fetchersPath)).href);
|
|
@@ -148,13 +160,55 @@ async function main() {
|
|
|
148
160
|
process.exit(2);
|
|
149
161
|
}
|
|
150
162
|
}
|
|
151
|
-
const
|
|
163
|
+
const ir = JSON.parse(await readFile(irPath, 'utf8'));
|
|
164
|
+
const r = await refresh(irPath, {
|
|
165
|
+
allowCommands: opts.allowCommands,
|
|
166
|
+
dryRun: opts.dryRun || opts.command === 'verify',
|
|
167
|
+
fetchers,
|
|
168
|
+
});
|
|
169
|
+
if (opts.command === 'verify') {
|
|
170
|
+
// a change on a MEASURED metric means the IR no longer matches its source —
|
|
171
|
+
// the receipt is real but the value is not. Derived changes just cascade.
|
|
172
|
+
const mismatches = r.changes.filter((c) => !ir.metrics?.[c.key]?.derived);
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
console.log(JSON.stringify({ mismatches, skipped: r.skipped, errors: r.errors }, null, 2));
|
|
175
|
+
process.exit(mismatches.length > 0 || r.errors.length > 0 ? 1 : 0);
|
|
176
|
+
}
|
|
177
|
+
for (const m of mismatches) {
|
|
178
|
+
console.log(` ✗ [receipt-mismatch] metric "${m.key}" is ${m.before.toLocaleString()} in the IR, but its source now returns ${m.after.toLocaleString()}`);
|
|
179
|
+
}
|
|
180
|
+
for (const sk of r.skipped)
|
|
181
|
+
console.log(` ~ ${sk.key} skipped — ${sk.reason}`);
|
|
182
|
+
for (const e of r.errors)
|
|
183
|
+
console.error(` ✗ ${e.key}: ${e.message}`);
|
|
184
|
+
if (mismatches.length > 0 || r.errors.length > 0) {
|
|
185
|
+
console.log(`\n${mismatches.length} receipt mismatch(es), ${r.errors.length} fetch error(s) — the IR does not match its sources`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
const verified = Object.keys(ir.metrics ?? {}).length - r.skipped.length;
|
|
189
|
+
console.log(`\nreceipts verified (${verified} checked, ${r.skipped.length} skipped) — nothing written`);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
// conclusions age too: a claim citing a metric that just moved needs a re-read
|
|
193
|
+
const changedKeys = new Set(r.changes.map((c) => c.key));
|
|
194
|
+
const reviewClaims = [];
|
|
195
|
+
if (changedKeys.size > 0 && (await exists(reportPath))) {
|
|
196
|
+
const report = await readFile(reportPath, 'utf8');
|
|
197
|
+
for (const [, text, evidence] of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
|
|
198
|
+
const cited = evidence.split(',').map((k) => k.trim()).filter((k) => changedKeys.has(k));
|
|
199
|
+
if (cited.length > 0)
|
|
200
|
+
reviewClaims.push({ claim: text.trim(), evidence: cited });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
152
203
|
if (opts.json) {
|
|
153
|
-
console.log(JSON.stringify(r, null, 2));
|
|
204
|
+
console.log(JSON.stringify({ ...r, reviewClaims }, null, 2));
|
|
154
205
|
process.exit(r.errors.length > 0 ? 1 : 0);
|
|
155
206
|
}
|
|
156
207
|
for (const c of r.changes)
|
|
157
208
|
console.log(` ${c.key}: ${c.before.toLocaleString()} → ${c.after.toLocaleString()}`);
|
|
209
|
+
for (const rc of reviewClaims) {
|
|
210
|
+
console.log(` ⚠ claim "${rc.claim}" cites ${rc.evidence.join(', ')} — the number moved, review the conclusion`);
|
|
211
|
+
}
|
|
158
212
|
for (const s of r.skipped)
|
|
159
213
|
console.log(` ~ ${s.key} skipped — ${s.reason}`);
|
|
160
214
|
for (const e of r.errors)
|
|
@@ -164,14 +218,15 @@ async function main() {
|
|
|
164
218
|
: `\n${r.changes.length} change(s)${opts.dryRun ? ' (dry run — nothing written)' : r.wrote ? ` — updated ${irPath}` : ''}`);
|
|
165
219
|
process.exit(r.errors.length > 0 ? 1 : 0);
|
|
166
220
|
}
|
|
167
|
-
const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays });
|
|
221
|
+
const result = await compile(reportPath, irPath, { format: opts.format, maxAgeDays: opts.maxAgeDays, maxRaw: opts.maxRaw });
|
|
168
222
|
const defaultName = opts.format === 'md' ? 'report.grounded.md' : 'report.html';
|
|
169
223
|
const outPath = resolve(opts.out ?? join(reportPath, '..', defaultName));
|
|
170
224
|
if (opts.json) {
|
|
171
225
|
console.log(JSON.stringify({ version: pkg.version, ...result, output: undefined, wrote: result.output && !opts.check ? outPath : undefined }, null, 2));
|
|
172
226
|
}
|
|
173
227
|
else {
|
|
174
|
-
|
|
228
|
+
const rawNote = result.grounded.raw > 0 ? ` · ${result.grounded.raw} raw escape(s)` : '';
|
|
229
|
+
console.log(`\nwatertight v${pkg.version} · ${result.grounded.metrics} grounded metrics · ${result.grounded.claims} claims · ${result.grounded.identifiers} identifiers${rawNote}`);
|
|
175
230
|
if (result.leaks.length > 0) {
|
|
176
231
|
console.log(`\n${result.leaks.length} leak(s) — the report does not hold water:\n`);
|
|
177
232
|
for (const leak of result.leaks) {
|
package/dist/compile.js
CHANGED
|
@@ -31,7 +31,18 @@ export async function compile(reportPath, irPath, options = 'html') {
|
|
|
31
31
|
metrics: [...report.matchAll(/\{\{m:/g)].length,
|
|
32
32
|
identifiers: [...report.matchAll(/\{\{id:/g)].length,
|
|
33
33
|
claims: [...report.matchAll(/\{\{claim:/g)].length,
|
|
34
|
+
raw: [...report.matchAll(/\{\{raw:/g)].length,
|
|
34
35
|
};
|
|
36
|
+
// the escape hatch must stay visible and boundable — wrapping everything in raw
|
|
37
|
+
// is how an agent games the compile instead of grounding the numbers
|
|
38
|
+
if (opts.maxRaw !== undefined && grounded.raw > opts.maxRaw) {
|
|
39
|
+
leaks.push({
|
|
40
|
+
severity: 'error',
|
|
41
|
+
rule: 'raw-budget',
|
|
42
|
+
message: `${grounded.raw} raw escape(s) exceed the budget of ${opts.maxRaw}`,
|
|
43
|
+
detail: 'Ground the numbers instead, or raise --max-raw deliberately.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
35
46
|
leaks.push(...scanNakedNumbers(report));
|
|
36
47
|
leaks.push(...scanMarkers(report));
|
|
37
48
|
if (ir)
|
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+)?$)/;
|
|
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,34 @@ 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
|
+
let total = 0;
|
|
67
88
|
let broken = false;
|
|
68
89
|
for (const ref of m.derived.of) {
|
|
69
90
|
const part = metrics[ref];
|
|
70
91
|
if (!part || typeof part.value !== 'number') {
|
|
71
|
-
leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}"
|
|
92
|
+
leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}" ${m.derived.op}s unknown or non-scalar metric "${ref}"` });
|
|
72
93
|
broken = true;
|
|
73
94
|
continue;
|
|
74
95
|
}
|
|
75
|
-
|
|
96
|
+
total += part.value;
|
|
76
97
|
}
|
|
98
|
+
const computed = m.derived.op === 'avg' ? total / m.derived.of.length : total;
|
|
77
99
|
if (!broken && !roundsTo(m.value, computed)) {
|
|
78
100
|
leaks.push({
|
|
79
101
|
severity: 'error',
|
|
80
102
|
rule: 'derived-mismatch',
|
|
81
|
-
message: `metric "${key}" is ${m.value}, but its parts sum to ${computed}`,
|
|
103
|
+
message: `metric "${key}" is ${m.value}, but its parts ${m.derived.op === 'avg' ? 'average' : 'sum'} to ${computed}`,
|
|
82
104
|
});
|
|
83
105
|
}
|
|
84
106
|
}
|
|
85
107
|
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
108
|
const before = endpoint(m.derived.before);
|
|
93
109
|
const after = endpoint(m.derived.after);
|
|
94
110
|
if (before === undefined || after === undefined || before === 0) {
|
|
@@ -109,6 +125,27 @@ export function parseIr(raw) {
|
|
|
109
125
|
});
|
|
110
126
|
}
|
|
111
127
|
}
|
|
128
|
+
else if (m.derived.op === 'ratio' || m.derived.op === 'diff') {
|
|
129
|
+
const a = endpoint(m.derived.a);
|
|
130
|
+
const b = endpoint(m.derived.b);
|
|
131
|
+
if (a === undefined || b === undefined || (m.derived.op === 'ratio' && b === 0)) {
|
|
132
|
+
leaks.push({
|
|
133
|
+
severity: 'error',
|
|
134
|
+
rule: 'bad-derived',
|
|
135
|
+
message: `metric "${key}": ${m.derived.op} operands must be numbers or scalar metric keys (got ${m.derived.a}, ${m.derived.b})`,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const computed = m.derived.op === 'ratio' ? a / b : a - b;
|
|
140
|
+
if (!roundsTo(m.value, computed)) {
|
|
141
|
+
leaks.push({
|
|
142
|
+
severity: 'error',
|
|
143
|
+
rule: 'derived-mismatch',
|
|
144
|
+
message: `metric "${key}" is ${m.value}, but ${m.derived.a} ${m.derived.op === 'ratio' ? '/' : '−'} ${m.derived.b} computes to ${computed.toFixed(4)}`,
|
|
145
|
+
detail: 'A derived value must be correctly rounded to its own precision.',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
112
149
|
else {
|
|
113
150
|
// an op the verifier cannot recompute must never pass as verified
|
|
114
151
|
leaks.push({
|
package/dist/refresh.js
CHANGED
|
@@ -124,33 +124,45 @@ export async function refresh(irPath, options) {
|
|
|
124
124
|
result.errors.push({ key, message });
|
|
125
125
|
}
|
|
126
126
|
};
|
|
127
|
+
const endpoint = (v) => {
|
|
128
|
+
if (typeof v === 'number')
|
|
129
|
+
return v;
|
|
130
|
+
const ref = raw.metrics[v];
|
|
131
|
+
return ref && typeof ref.value === 'number' ? ref.value : undefined;
|
|
132
|
+
};
|
|
133
|
+
// every recomputed value keeps the author's stated precision — refresh must not
|
|
134
|
+
// turn 0.155 into 0.1551724 or write float noise over an exact 0.3
|
|
135
|
+
const authorDecimals = (String(m.value).split('.')[1] ?? '').length;
|
|
136
|
+
const rounded = (v) => Number(v.toFixed(authorDecimals));
|
|
127
137
|
let computed;
|
|
128
|
-
if (m.derived.op === 'sum') {
|
|
129
|
-
|
|
138
|
+
if (m.derived.op === 'sum' || m.derived.op === 'avg') {
|
|
139
|
+
const total = m.derived.of.reduce((acc, ref) => {
|
|
130
140
|
const part = raw.metrics[ref];
|
|
131
141
|
return acc + (part && typeof part.value === 'number' ? part.value : NaN);
|
|
132
142
|
}, 0);
|
|
133
|
-
if (!Number.isFinite(
|
|
134
|
-
fail(
|
|
143
|
+
if (!Number.isFinite(total)) {
|
|
144
|
+
fail(`${m.derived.op} references unknown or non-scalar metrics — not recomputed`);
|
|
135
145
|
continue;
|
|
136
146
|
}
|
|
147
|
+
computed = rounded(m.derived.op === 'avg' ? total / m.derived.of.length : total);
|
|
137
148
|
}
|
|
138
149
|
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
150
|
const before = endpoint(m.derived.before);
|
|
146
151
|
const after = endpoint(m.derived.after);
|
|
147
152
|
if (before === undefined || after === undefined || before === 0) {
|
|
148
153
|
fail('pct_change endpoints are unresolvable or zero — not recomputed');
|
|
149
154
|
continue;
|
|
150
155
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
computed = rounded((after - before) / before);
|
|
157
|
+
}
|
|
158
|
+
else if (m.derived.op === 'ratio' || m.derived.op === 'diff') {
|
|
159
|
+
const a = endpoint(m.derived.a);
|
|
160
|
+
const b = endpoint(m.derived.b);
|
|
161
|
+
if (a === undefined || b === undefined || (m.derived.op === 'ratio' && b === 0)) {
|
|
162
|
+
fail(`${m.derived.op} operands are unresolvable — not recomputed`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
computed = rounded(m.derived.op === 'ratio' ? a / b : a - b);
|
|
154
166
|
}
|
|
155
167
|
else {
|
|
156
168
|
fail(`unknown derived op "${m.derived.op}" — not recomputed`);
|
package/dist/render.js
CHANGED
|
@@ -33,7 +33,11 @@ export function receipt(m, includeDefinition = true) {
|
|
|
33
33
|
const source = m.derived
|
|
34
34
|
? m.derived.op === 'sum'
|
|
35
35
|
? `= ${m.derived.of.join(' + ')} (recomputed)`
|
|
36
|
-
:
|
|
36
|
+
: m.derived.op === 'avg'
|
|
37
|
+
? `= avg(${m.derived.of.join(', ')}) (recomputed)`
|
|
38
|
+
: m.derived.op === 'pct_change'
|
|
39
|
+
? `= ${m.derived.before} → ${m.derived.after} (recomputed)`
|
|
40
|
+
: `= ${m.derived.a} ${m.derived.op === 'ratio' ? '/' : '−'} ${m.derived.b} (recomputed)`
|
|
37
41
|
: [m.source?.type, ...Object.entries(m.source ?? {}).filter(([k]) => k !== 'type').map(([, v]) => String(v))].filter(Boolean).join(' · ');
|
|
38
42
|
return [source, m.window, m.fetched_at && `fetched ${m.fetched_at}`, includeDefinition && m.definition].filter(Boolean).join(' · ');
|
|
39
43
|
}
|
package/dist/renderMd.js
CHANGED
|
@@ -26,6 +26,14 @@ export function renderMarkdown(report, ir) {
|
|
|
26
26
|
used.push(key);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
// the report discloses its own escape hatches — a reader (and a reviewer) sees
|
|
30
|
+
// exactly which prose numbers carry no receipt
|
|
31
|
+
const raws = [...protectedReport.matchAll(/\{\{raw:([^}]*)\}\}/g)].map((m) => m[1].trim());
|
|
32
|
+
const rawSection = raws.length === 0
|
|
33
|
+
? ''
|
|
34
|
+
: `\n\n### Ungrounded (${raws.length} raw escape${raws.length === 1 ? '' : 's'})\n\n${raws
|
|
35
|
+
.map((r) => `- ${r}`)
|
|
36
|
+
.join('\n')}`;
|
|
29
37
|
const appendix = used
|
|
30
38
|
.map((key, i) => {
|
|
31
39
|
const m = ir.metrics[key];
|
|
@@ -33,5 +41,5 @@ export function renderMarkdown(report, ir) {
|
|
|
33
41
|
return `${i + 1}. **${key}** = ${formatValue(m)}${definition}\n ${receipt(m, false)}`;
|
|
34
42
|
})
|
|
35
43
|
.join('\n');
|
|
36
|
-
return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}\n`;
|
|
44
|
+
return `${body.trimEnd()}\n\n---\n\n### Receipts (${used.length} metrics)\n\n${appendix}${rawSection}\n`;
|
|
37
45
|
}
|
package/dist/scan.js
CHANGED
|
@@ -23,6 +23,7 @@ export function scanNakedNumbers(report) {
|
|
|
23
23
|
.replace(/\]\([^)\s]*\)/g, blank) // markdown link targets — URLs locate, they do not measure
|
|
24
24
|
.replace(/https?:\/\/\S+/g, blank) // bare URLs, same reason
|
|
25
25
|
.replace(/\d{4}-\d{2}-\d{2}/g, blank) // ISO dates locate, they do not measure
|
|
26
|
+
.replace(/(?<![A-Za-z0-9가-힣])[A-Za-z]+\d[\w.\-]*/g, blank) // Q3, v6.109.0, iOS15 — names, not measurements
|
|
26
27
|
.replace(/^#{1,6}(?= )/gm, blank) // heading markers only — heading TEXT is scanned, people summarise numbers there
|
|
27
28
|
.replace(/^\s*\d+\.\s/gm, blank); // ordered-list markers
|
|
28
29
|
const leaks = [];
|