watertight 0.5.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 +27 -4
- package/SKILL.md +10 -2
- package/dist/cli.js +72 -12
- package/dist/compile.js +13 -1
- package/dist/init.js +2 -1
- package/dist/ir.js +71 -14
- package/dist/refresh.js +64 -23
- package/dist/render.js +27 -5
- package/dist/renderMd.js +20 -4
- package/dist/scan.js +31 -3
- 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
|
|
@@ -164,22 +170,39 @@ figure shows its receipt.
|
|
|
164
170
|
|
|
165
171
|
| check | catches |
|
|
166
172
|
|---|---|
|
|
167
|
-
| `naked-number` | any digit in prose not covered by a reference
|
|
173
|
+
| `naked-number` | any digit in prose not covered by a reference — heading text included; dates, URLs, code spans and `{{raw:}}` are exempt |
|
|
168
174
|
| `unknown-ref` | `{{m:...}}` / `{{id:...}}` pointing at nothing |
|
|
169
175
|
| `missing-field` | a measured metric without `source`, `window`, or `fetched_at` |
|
|
170
176
|
| `definition-required` | a `ratio` / `ratio-point` metric with no stated basis — this is how a fill rate of 107% stays honest |
|
|
171
177
|
| `derived-mismatch` | a `sum` that doesn't add up; a `pct_change` that doesn't recompute — to the stored value's own precision, so `0.161` passes as 16.1% but `0.15` for 15.5% fails |
|
|
172
178
|
| `claim-without-evidence` | a `{{claim:}}` with no `evidence:` keys, or keys that don't exist |
|
|
179
|
+
| `malformed-marker` | anything still marker-shaped after every recognised form — a typo'd `{{claim:…\|evidnce:…}}` must fail, not render verbatim |
|
|
173
180
|
| `bad-derived` | derived ops referencing missing or non-numeric inputs |
|
|
174
181
|
| `empty-ir` | a report "grounded" in nothing |
|
|
175
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 |
|
|
176
185
|
|
|
177
186
|
<br>
|
|
178
187
|
|
|
179
188
|
## Re-verification
|
|
180
189
|
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
183
206
|
|
|
184
207
|
- `csv` sources — `{ "type": "csv", "file": "data.csv", "cell": "B2" }`
|
|
185
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,19 +73,31 @@ 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')
|
|
74
86
|
help = true;
|
|
75
87
|
else if (arg === '-v' || arg === '--version')
|
|
76
88
|
version = true;
|
|
77
|
-
else if (
|
|
89
|
+
else if (arg.startsWith('-')) {
|
|
90
|
+
// a strictness tool must not silently ignore a typo'd flag — --chekc writing a file is a betrayal
|
|
91
|
+
console.error(`error: unknown flag "${arg}" — see --help`);
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
else
|
|
78
95
|
positional.push(arg);
|
|
79
96
|
}
|
|
80
|
-
const command =
|
|
97
|
+
const command = ['refresh', 'init', 'verify'].includes(positional[0]) ? positional[0] : 'compile';
|
|
81
98
|
if (command !== 'compile')
|
|
82
99
|
positional.shift();
|
|
83
|
-
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 };
|
|
84
101
|
}
|
|
85
102
|
async function exists(path) {
|
|
86
103
|
try {
|
|
@@ -124,15 +141,15 @@ async function main() {
|
|
|
124
141
|
reportPath = join(dir, 'report.md');
|
|
125
142
|
irPath = join(dir, 'metrics.json');
|
|
126
143
|
}
|
|
127
|
-
// refresh
|
|
128
|
-
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]) {
|
|
129
146
|
if (!(await exists(path))) {
|
|
130
147
|
console.error(`Not found: ${path}`);
|
|
131
148
|
console.error('Expected report.md and metrics.json — see --help.');
|
|
132
149
|
process.exit(2);
|
|
133
150
|
}
|
|
134
151
|
}
|
|
135
|
-
if (opts.command === 'refresh') {
|
|
152
|
+
if (opts.command === 'refresh' || opts.command === 'verify') {
|
|
136
153
|
let fetchers;
|
|
137
154
|
if (opts.fetchersPath) {
|
|
138
155
|
const mod = await import(pathToFileURL(resolve(opts.fetchersPath)).href);
|
|
@@ -143,13 +160,55 @@ async function main() {
|
|
|
143
160
|
process.exit(2);
|
|
144
161
|
}
|
|
145
162
|
}
|
|
146
|
-
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
|
+
}
|
|
147
203
|
if (opts.json) {
|
|
148
|
-
console.log(JSON.stringify(r, null, 2));
|
|
204
|
+
console.log(JSON.stringify({ ...r, reviewClaims }, null, 2));
|
|
149
205
|
process.exit(r.errors.length > 0 ? 1 : 0);
|
|
150
206
|
}
|
|
151
207
|
for (const c of r.changes)
|
|
152
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
|
+
}
|
|
153
212
|
for (const s of r.skipped)
|
|
154
213
|
console.log(` ~ ${s.key} skipped — ${s.reason}`);
|
|
155
214
|
for (const e of r.errors)
|
|
@@ -159,14 +218,15 @@ async function main() {
|
|
|
159
218
|
: `\n${r.changes.length} change(s)${opts.dryRun ? ' (dry run — nothing written)' : r.wrote ? ` — updated ${irPath}` : ''}`);
|
|
160
219
|
process.exit(r.errors.length > 0 ? 1 : 0);
|
|
161
220
|
}
|
|
162
|
-
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 });
|
|
163
222
|
const defaultName = opts.format === 'md' ? 'report.grounded.md' : 'report.html';
|
|
164
223
|
const outPath = resolve(opts.out ?? join(reportPath, '..', defaultName));
|
|
165
224
|
if (opts.json) {
|
|
166
225
|
console.log(JSON.stringify({ version: pkg.version, ...result, output: undefined, wrote: result.output && !opts.check ? outPath : undefined }, null, 2));
|
|
167
226
|
}
|
|
168
227
|
else {
|
|
169
|
-
|
|
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}`);
|
|
170
230
|
if (result.leaks.length > 0) {
|
|
171
231
|
console.log(`\n${result.leaks.length} leak(s) — the report does not hold water:\n`);
|
|
172
232
|
for (const leak of result.leaks) {
|
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 { scanNakedNumbers, scanRefs } from './scan.js';
|
|
5
|
+
import { scanMarkers, scanNakedNumbers, scanRefs } 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';
|
|
@@ -31,8 +31,20 @@ 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));
|
|
47
|
+
leaks.push(...scanMarkers(report));
|
|
36
48
|
if (ir)
|
|
37
49
|
leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers))));
|
|
38
50
|
if (leaks.length > 0 || !ir)
|
package/dist/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
/**
|
|
@@ -58,6 +58,7 @@ export async function init(dir) {
|
|
|
58
58
|
if (existsSync(path))
|
|
59
59
|
throw new Error(`refusing to overwrite ${path}`);
|
|
60
60
|
}
|
|
61
|
+
await mkdir(dir, { recursive: true });
|
|
61
62
|
for (const [path, content] of files)
|
|
62
63
|
await writeFile(path, content);
|
|
63
64
|
return { written: files.map(([p]) => p) };
|
package/dist/ir.js
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
const DEFINITION_REQUIRED = new Set(['ratio', 'ratio-point']);
|
|
3
3
|
function decimals(n) {
|
|
4
4
|
const s = String(n);
|
|
5
|
+
const e = s.indexOf('e');
|
|
6
|
+
if (e !== -1) {
|
|
7
|
+
// 1e-7 has 7 decimals, 1.5e-7 has 8 — exponential notation must not disarm the precision check
|
|
8
|
+
const exp = Number(s.slice(e + 1));
|
|
9
|
+
const mantissa = s.slice(0, e);
|
|
10
|
+
const dot = mantissa.indexOf('.');
|
|
11
|
+
return Math.max(0, (dot === -1 ? 0 : mantissa.length - dot - 1) - exp);
|
|
12
|
+
}
|
|
5
13
|
const dot = s.indexOf('.');
|
|
6
14
|
return dot === -1 ? 0 : s.length - dot - 1;
|
|
7
15
|
}
|
|
16
|
+
const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
8
17
|
/**
|
|
9
18
|
* A stored derived value must be correctly rounded to its own precision: writing 0.15
|
|
10
19
|
* for a computed 0.155 is a mismatch, writing 0.155 for 0.15517 is fine. This is what
|
|
@@ -17,8 +26,19 @@ export function parseIr(raw) {
|
|
|
17
26
|
const leaks = [];
|
|
18
27
|
const root = (raw ?? {});
|
|
19
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+)?$)/;
|
|
20
32
|
for (const [k, v] of Object.entries(root['identifiers'] ?? {})) {
|
|
21
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
|
+
}
|
|
22
42
|
}
|
|
23
43
|
const metrics = root['metrics'] ?? {};
|
|
24
44
|
if (Object.keys(metrics).length === 0) {
|
|
@@ -31,8 +51,14 @@ export function parseIr(raw) {
|
|
|
31
51
|
return { leaks };
|
|
32
52
|
}
|
|
33
53
|
for (const [key, m] of Object.entries(metrics)) {
|
|
34
|
-
if (m
|
|
35
|
-
leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}"
|
|
54
|
+
if (!m || typeof m !== 'object') {
|
|
55
|
+
leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" is not an object` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const valueOk = isFiniteNumber(m.value) ||
|
|
59
|
+
(Array.isArray(m.value) && m.value.length === 2 && m.value.every(isFiniteNumber));
|
|
60
|
+
if (!valueOk) {
|
|
61
|
+
leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no usable numeric value` });
|
|
36
62
|
continue;
|
|
37
63
|
}
|
|
38
64
|
if (typeof m.unit !== 'string' || m.unit.length === 0) {
|
|
@@ -51,33 +77,34 @@ export function parseIr(raw) {
|
|
|
51
77
|
leaks.push({ severity: 'error', rule: 'bad-derived', message: `metric "${key}": a range cannot be derived` });
|
|
52
78
|
continue;
|
|
53
79
|
}
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
56
88
|
let broken = false;
|
|
57
89
|
for (const ref of m.derived.of) {
|
|
58
90
|
const part = metrics[ref];
|
|
59
91
|
if (!part || typeof part.value !== 'number') {
|
|
60
|
-
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}"` });
|
|
61
93
|
broken = true;
|
|
62
94
|
continue;
|
|
63
95
|
}
|
|
64
|
-
|
|
96
|
+
total += part.value;
|
|
65
97
|
}
|
|
66
|
-
|
|
98
|
+
const computed = m.derived.op === 'avg' ? total / m.derived.of.length : total;
|
|
99
|
+
if (!broken && !roundsTo(m.value, computed)) {
|
|
67
100
|
leaks.push({
|
|
68
101
|
severity: 'error',
|
|
69
102
|
rule: 'derived-mismatch',
|
|
70
|
-
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}`,
|
|
71
104
|
});
|
|
72
105
|
}
|
|
73
106
|
}
|
|
74
107
|
else if (m.derived.op === 'pct_change') {
|
|
75
|
-
const endpoint = (v) => {
|
|
76
|
-
if (typeof v === 'number')
|
|
77
|
-
return v;
|
|
78
|
-
const ref = metrics[v];
|
|
79
|
-
return ref && typeof ref.value === 'number' ? ref.value : undefined;
|
|
80
|
-
};
|
|
81
108
|
const before = endpoint(m.derived.before);
|
|
82
109
|
const after = endpoint(m.derived.after);
|
|
83
110
|
if (before === undefined || after === undefined || before === 0) {
|
|
@@ -98,6 +125,36 @@ export function parseIr(raw) {
|
|
|
98
125
|
});
|
|
99
126
|
}
|
|
100
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
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// an op the verifier cannot recompute must never pass as verified
|
|
151
|
+
leaks.push({
|
|
152
|
+
severity: 'error',
|
|
153
|
+
rule: 'bad-derived',
|
|
154
|
+
message: `metric "${key}" has unknown derived op "${m.derived.op}"`,
|
|
155
|
+
detail: 'Supported ops: sum, pct_change. A derivation the compiler cannot recompute cannot hold water.',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
101
158
|
}
|
|
102
159
|
else {
|
|
103
160
|
// a measured value must say where it came from and when
|
package/dist/refresh.js
CHANGED
|
@@ -74,7 +74,8 @@ export async function refresh(irPath, options) {
|
|
|
74
74
|
const raw = JSON.parse(await readFile(irPath, 'utf8'));
|
|
75
75
|
const baseDir = dirname(resolve(irPath));
|
|
76
76
|
const result = { changes: [], skipped: [], errors: [], wrote: false };
|
|
77
|
-
|
|
77
|
+
// date-only, matching the style people write by hand — receipts should look uniform
|
|
78
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
78
79
|
for (const [key, m] of Object.entries(raw.metrics)) {
|
|
79
80
|
if (m.derived || !m.source || Array.isArray(m.value))
|
|
80
81
|
continue;
|
|
@@ -107,37 +108,77 @@ export async function refresh(irPath, options) {
|
|
|
107
108
|
result.errors.push({ key, message: err instanceof Error ? err.message : String(err) });
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
// inputs may have moved, so derived values are recomputed rather than left to go stale
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
111
|
+
// inputs may have moved, so derived values are recomputed rather than left to go stale.
|
|
112
|
+
// Iterated to a fixpoint so a derived that reads another derived settles too; a derivation
|
|
113
|
+
// that cannot be recomputed is a named error — silence is impossible here as everywhere.
|
|
114
|
+
const derivedErrors = new Set();
|
|
115
|
+
const firstBefore = new Map();
|
|
116
|
+
for (let pass = 0, moved = true; moved && pass < 10; pass++) {
|
|
117
|
+
moved = false;
|
|
118
|
+
for (const [key, m] of Object.entries(raw.metrics)) {
|
|
119
|
+
if (!m.derived || Array.isArray(m.value))
|
|
120
|
+
continue;
|
|
121
|
+
const fail = (message) => {
|
|
122
|
+
if (!derivedErrors.has(key)) {
|
|
123
|
+
derivedErrors.add(key);
|
|
124
|
+
result.errors.push({ key, message });
|
|
125
|
+
}
|
|
126
|
+
};
|
|
122
127
|
const endpoint = (v) => {
|
|
123
128
|
if (typeof v === 'number')
|
|
124
129
|
return v;
|
|
125
130
|
const ref = raw.metrics[v];
|
|
126
131
|
return ref && typeof ref.value === 'number' ? ref.value : undefined;
|
|
127
132
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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));
|
|
137
|
+
let computed;
|
|
138
|
+
if (m.derived.op === 'sum' || m.derived.op === 'avg') {
|
|
139
|
+
const total = m.derived.of.reduce((acc, ref) => {
|
|
140
|
+
const part = raw.metrics[ref];
|
|
141
|
+
return acc + (part && typeof part.value === 'number' ? part.value : NaN);
|
|
142
|
+
}, 0);
|
|
143
|
+
if (!Number.isFinite(total)) {
|
|
144
|
+
fail(`${m.derived.op} references unknown or non-scalar metrics — not recomputed`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
computed = rounded(m.derived.op === 'avg' ? total / m.derived.of.length : total);
|
|
148
|
+
}
|
|
149
|
+
else if (m.derived.op === 'pct_change') {
|
|
150
|
+
const before = endpoint(m.derived.before);
|
|
151
|
+
const after = endpoint(m.derived.after);
|
|
152
|
+
if (before === undefined || after === undefined || before === 0) {
|
|
153
|
+
fail('pct_change endpoints are unresolvable or zero — not recomputed');
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
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);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
fail(`unknown derived op "${m.derived.op}" — not recomputed`);
|
|
131
169
|
continue;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
170
|
+
}
|
|
171
|
+
if (computed !== m.value) {
|
|
172
|
+
if (!firstBefore.has(key))
|
|
173
|
+
firstBefore.set(key, m.value);
|
|
174
|
+
m.value = computed;
|
|
175
|
+
moved = true;
|
|
176
|
+
}
|
|
139
177
|
}
|
|
140
178
|
}
|
|
179
|
+
for (const [key, before] of firstBefore) {
|
|
180
|
+
result.changes.push({ key, before, after: raw.metrics[key].value });
|
|
181
|
+
}
|
|
141
182
|
if (!options.dryRun && (result.changes.length > 0 || result.errors.length === 0)) {
|
|
142
183
|
await writeFile(irPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
143
184
|
result.wrote = true;
|
package/dist/render.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code shows syntax, it does not state facts — a fenced example of {{m:…}} must render
|
|
3
|
+
* verbatim, not substitute. Stash code regions before substitution, restore after.
|
|
4
|
+
*/
|
|
5
|
+
export function protectCode(text) {
|
|
6
|
+
const stash = [];
|
|
7
|
+
const protectedText = text.replace(/```[\s\S]*?```|`[^`\n]*`/g, (m) => {
|
|
8
|
+
stash.push(m);
|
|
9
|
+
return `\u0000${stash.length - 1}\u0000`;
|
|
10
|
+
});
|
|
11
|
+
return { text: protectedText, restore: (s) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]) };
|
|
12
|
+
}
|
|
1
13
|
function escapeHtml(s) {
|
|
2
14
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
3
15
|
}
|
|
4
16
|
export function formatValue(m) {
|
|
5
17
|
if (Array.isArray(m.value)) {
|
|
6
18
|
const [lo, hi] = m.value;
|
|
7
|
-
|
|
19
|
+
// a range keeps the same unit conversion as a scalar — [0.3, 0.5] ratio is 30~50%, not 0.3~0.5%
|
|
20
|
+
if (m.unit === 'ratio' || m.unit === 'ratio-point') {
|
|
21
|
+
const pct = (v) => (v * 100).toFixed(1);
|
|
22
|
+
return `${pct(lo)}~${pct(hi)}${m.unit === 'ratio' ? '%' : '%p'}`;
|
|
23
|
+
}
|
|
24
|
+
return `${lo.toLocaleString()}~${hi.toLocaleString()} ${m.unit}`.trim();
|
|
8
25
|
}
|
|
9
26
|
if (m.unit === 'ratio')
|
|
10
|
-
return `${(m.value * 100).toFixed(m.value < 0.01 ? 2 : 1)}%`;
|
|
27
|
+
return `${(m.value * 100).toFixed(Math.abs(m.value) < 0.01 ? 2 : 1)}%`;
|
|
11
28
|
if (m.unit === 'ratio-point')
|
|
12
29
|
return `${m.value >= 0 ? '+' : ''}${(m.value * 100).toFixed(1)}%p`;
|
|
13
30
|
return `${m.value.toLocaleString()} ${m.unit}`.trim();
|
|
@@ -16,7 +33,11 @@ export function receipt(m, includeDefinition = true) {
|
|
|
16
33
|
const source = m.derived
|
|
17
34
|
? m.derived.op === 'sum'
|
|
18
35
|
? `= ${m.derived.of.join(' + ')} (recomputed)`
|
|
19
|
-
:
|
|
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)`
|
|
20
41
|
: [m.source?.type, ...Object.entries(m.source ?? {}).filter(([k]) => k !== 'type').map(([, v]) => String(v))].filter(Boolean).join(' · ');
|
|
21
42
|
return [source, m.window, m.fetched_at && `fetched ${m.fetched_at}`, includeDefinition && m.definition].filter(Boolean).join(' · ');
|
|
22
43
|
}
|
|
@@ -37,7 +58,8 @@ function markdown(src) {
|
|
|
37
58
|
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
|
38
59
|
}
|
|
39
60
|
export function render(report, ir) {
|
|
40
|
-
const
|
|
61
|
+
const { text: protectedReport, restore } = protectCode(escapeHtml(report));
|
|
62
|
+
const grounded = restore(protectedReport
|
|
41
63
|
.replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
|
|
42
64
|
const m = ir.metrics[key];
|
|
43
65
|
return `<b class="w" title="${escapeHtml(receipt(m))}">${formatValue(m)}<sup>†</sup></b>`;
|
|
@@ -50,7 +72,7 @@ export function render(report, ir) {
|
|
|
50
72
|
.join(' | ');
|
|
51
73
|
return `<span class="c" title="${escapeHtml(receipts)}">${text.trim()}<sup>‡</sup></span>`;
|
|
52
74
|
})
|
|
53
|
-
.replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text));
|
|
75
|
+
.replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text)));
|
|
54
76
|
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
55
77
|
<style>
|
|
56
78
|
body{font:15px/1.75 -apple-system,system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1a1a1a}
|
package/dist/renderMd.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatValue, receipt } from './render.js';
|
|
1
|
+
import { formatValue, 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
|
/**
|
|
@@ -9,7 +9,8 @@ const sup = (n) => `⁽${String(n).split('').map((d) => SUPERSCRIPT[Number(d)]).
|
|
|
9
9
|
*/
|
|
10
10
|
export function renderMarkdown(report, ir) {
|
|
11
11
|
const used = [];
|
|
12
|
-
const
|
|
12
|
+
const { text: protectedReport, restore } = protectCode(report);
|
|
13
|
+
const body = restore(protectedReport
|
|
13
14
|
.replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
|
|
14
15
|
if (!used.includes(key))
|
|
15
16
|
used.push(key);
|
|
@@ -17,7 +18,22 @@ export function renderMarkdown(report, ir) {
|
|
|
17
18
|
})
|
|
18
19
|
.replace(/\{\{id:([\w-]+)\}\}/g, (_, key) => `\`${ir.identifiers[key]}\``)
|
|
19
20
|
.replace(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g, (_, text, evidence) => `**${text.trim()}** *(evidence: ${evidence.trim()})*`)
|
|
20
|
-
.replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => text);
|
|
21
|
+
.replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => text));
|
|
22
|
+
// evidence-only metrics get receipts too — a claim's reader must be able to check its keys
|
|
23
|
+
for (const [, , evidence] of protectedReport.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
|
|
24
|
+
for (const key of evidence.split(',').map((k) => k.trim()).filter(Boolean)) {
|
|
25
|
+
if (ir.metrics[key] && !used.includes(key))
|
|
26
|
+
used.push(key);
|
|
27
|
+
}
|
|
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')}`;
|
|
21
37
|
const appendix = used
|
|
22
38
|
.map((key, i) => {
|
|
23
39
|
const m = ir.metrics[key];
|
|
@@ -25,5 +41,5 @@ export function renderMarkdown(report, ir) {
|
|
|
25
41
|
return `${i + 1}. **${key}** = ${formatValue(m)}${definition}\n ${receipt(m, false)}`;
|
|
26
42
|
})
|
|
27
43
|
.join('\n');
|
|
28
|
-
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`;
|
|
29
45
|
}
|
package/dist/scan.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Same-length whitespace, newlines kept — stripping must not move anything */
|
|
2
2
|
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
3
3
|
const lineAt = (text, index) => text.slice(0, index).split('\n').length;
|
|
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);
|
|
4
6
|
/**
|
|
5
7
|
* Find numbers in the narrative that are not bound to the IR. These are the leaks the
|
|
6
8
|
* tool exists to catch: a typed, transcribed, or hallucinated figure reads exactly like
|
|
@@ -18,12 +20,15 @@ export function scanNakedNumbers(report) {
|
|
|
18
20
|
.replace(/\{\{raw:[^}]*\}\}/g, blank) // explicit, greppable escape hatch
|
|
19
21
|
.replace(/```[\s\S]*?```/g, blank) // fenced code
|
|
20
22
|
.replace(/`[^`\n]*`/g, blank) // inline code
|
|
23
|
+
.replace(/\]\([^)\s]*\)/g, blank) // markdown link targets — URLs locate, they do not measure
|
|
24
|
+
.replace(/https?:\/\/\S+/g, blank) // bare URLs, same reason
|
|
21
25
|
.replace(/\d{4}-\d{2}-\d{2}/g, blank) // ISO dates locate, they do not measure
|
|
22
|
-
.replace(
|
|
26
|
+
.replace(/(?<![A-Za-z0-9가-힣])[A-Za-z]+\d[\w.\-]*/g, blank) // Q3, v6.109.0, iOS15 — names, not measurements
|
|
27
|
+
.replace(/^#{1,6}(?= )/gm, blank) // heading markers only — heading TEXT is scanned, people summarise numbers there
|
|
23
28
|
.replace(/^\s*\d+\.\s/gm, blank); // ordered-list markers
|
|
24
29
|
const leaks = [];
|
|
25
30
|
for (const m of stripped.matchAll(/\d[\d,.]*\s*(%p?|[가-힣]{1,2})?/g)) {
|
|
26
|
-
const token = m[0].trim();
|
|
31
|
+
const token = m[0].trim().replace(/[.,]+$/, '');
|
|
27
32
|
if (!token)
|
|
28
33
|
continue;
|
|
29
34
|
leaks.push({
|
|
@@ -37,8 +42,9 @@ export function scanNakedNumbers(report) {
|
|
|
37
42
|
return leaks;
|
|
38
43
|
}
|
|
39
44
|
/** Every reference in the narrative must resolve; a dangling one is authoring drift. */
|
|
40
|
-
export function scanRefs(
|
|
45
|
+
export function scanRefs(rawReport, metricKeys, idKeys) {
|
|
41
46
|
const leaks = [];
|
|
47
|
+
const report = blankCode(rawReport);
|
|
42
48
|
for (const m of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
|
|
43
49
|
const [, text, evidence] = m;
|
|
44
50
|
const line = lineAt(report, m.index);
|
|
@@ -80,3 +86,25 @@ export function scanRefs(report, metricKeys, idKeys) {
|
|
|
80
86
|
}
|
|
81
87
|
return leaks;
|
|
82
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Anything still shaped like a marker after every recognised form is removed was a typo —
|
|
91
|
+
* and a typo'd marker must be a leak, or it renders verbatim and its number sails through.
|
|
92
|
+
*/
|
|
93
|
+
export function scanMarkers(rawReport) {
|
|
94
|
+
const known = blankCode(rawReport)
|
|
95
|
+
.replace(/\{\{(m|id):[\w-]+\}\}/g, blank)
|
|
96
|
+
.replace(/\{\{raw:[^}]*\}\}/g, blank)
|
|
97
|
+
.replace(/\{\{claim:[^|}]*\|\s*evidence:[^}]*\}\}/g, blank)
|
|
98
|
+
.replace(/\{\{claim:[^|}]*\}\}/g, blank); // no-pipe form is already claim-without-evidence
|
|
99
|
+
const leaks = [];
|
|
100
|
+
for (const m of known.matchAll(/\{\{[^}]*\}\}?/g)) {
|
|
101
|
+
leaks.push({
|
|
102
|
+
severity: 'error',
|
|
103
|
+
rule: 'malformed-marker',
|
|
104
|
+
line: lineAt(known, m.index),
|
|
105
|
+
message: `"${m[0]}" is not a recognised marker`,
|
|
106
|
+
detail: 'Valid forms: {{m:key}}, {{id:key}}, {{raw:…}}, {{claim: text | evidence: keys}}.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return leaks;
|
|
110
|
+
}
|