watertight 0.5.0 → 0.6.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 CHANGED
@@ -164,12 +164,13 @@ figure shows its receipt.
164
164
 
165
165
  | check | catches |
166
166
  |---|---|
167
- | `naked-number` | any digit in prose not covered by a reference (dates, headings, code spans and `{{raw:}}` are exempt) |
167
+ | `naked-number` | any digit in prose not covered by a reference — heading text included; dates, URLs, code spans and `{{raw:}}` are exempt |
168
168
  | `unknown-ref` | `{{m:...}}` / `{{id:...}}` pointing at nothing |
169
169
  | `missing-field` | a measured metric without `source`, `window`, or `fetched_at` |
170
170
  | `definition-required` | a `ratio` / `ratio-point` metric with no stated basis — this is how a fill rate of 107% stays honest |
171
171
  | `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
172
  | `claim-without-evidence` | a `{{claim:}}` with no `evidence:` keys, or keys that don't exist |
173
+ | `malformed-marker` | anything still marker-shaped after every recognised form — a typo'd `{{claim:…\|evidnce:…}}` must fail, not render verbatim |
173
174
  | `bad-derived` | derived ops referencing missing or non-numeric inputs |
174
175
  | `empty-ir` | a report "grounded" in nothing |
175
176
  | `stale-metric` | with `--max-age <days>`: a receipt whose `fetched_at` is older than the budget — numbers age |
package/dist/cli.js CHANGED
@@ -74,7 +74,12 @@ function parseArgs(argv) {
74
74
  help = true;
75
75
  else if (arg === '-v' || arg === '--version')
76
76
  version = true;
77
- else if (!arg.startsWith('-'))
77
+ else if (arg.startsWith('-')) {
78
+ // a strictness tool must not silently ignore a typo'd flag — --chekc writing a file is a betrayal
79
+ console.error(`error: unknown flag "${arg}" — see --help`);
80
+ process.exit(2);
81
+ }
82
+ else
78
83
  positional.push(arg);
79
84
  }
80
85
  const command = positional[0] === 'refresh' ? 'refresh' : positional[0] === 'init' ? 'init' : 'compile';
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';
@@ -33,6 +33,7 @@ export async function compile(reportPath, irPath, options = 'html') {
33
33
  claims: [...report.matchAll(/\{\{claim:/g)].length,
34
34
  };
35
35
  leaks.push(...scanNakedNumbers(report));
36
+ leaks.push(...scanMarkers(report));
36
37
  if (ir)
37
38
  leaks.push(...scanRefs(report, new Set(Object.keys(ir.metrics)), new Set(Object.keys(ir.identifiers))));
38
39
  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
@@ -31,8 +40,10 @@ export function parseIr(raw) {
31
40
  return { leaks };
32
41
  }
33
42
  for (const [key, m] of Object.entries(metrics)) {
34
- if (m.value === undefined) {
35
- leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no value` });
43
+ const valueOk = isFiniteNumber(m.value) ||
44
+ (Array.isArray(m.value) && m.value.length === 2 && m.value.every(isFiniteNumber));
45
+ if (!valueOk) {
46
+ leaks.push({ severity: 'error', rule: 'missing-field', message: `metric "${key}" has no usable numeric value` });
36
47
  continue;
37
48
  }
38
49
  if (typeof m.unit !== 'string' || m.unit.length === 0) {
@@ -63,7 +74,7 @@ export function parseIr(raw) {
63
74
  }
64
75
  computed += part.value;
65
76
  }
66
- if (!broken && computed !== m.value) {
77
+ if (!broken && !roundsTo(m.value, computed)) {
67
78
  leaks.push({
68
79
  severity: 'error',
69
80
  rule: 'derived-mismatch',
@@ -98,6 +109,15 @@ export function parseIr(raw) {
98
109
  });
99
110
  }
100
111
  }
112
+ else {
113
+ // an op the verifier cannot recompute must never pass as verified
114
+ leaks.push({
115
+ severity: 'error',
116
+ rule: 'bad-derived',
117
+ message: `metric "${key}" has unknown derived op "${m.derived.op}"`,
118
+ detail: 'Supported ops: sum, pct_change. A derivation the compiler cannot recompute cannot hold water.',
119
+ });
120
+ }
101
121
  }
102
122
  else {
103
123
  // 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
- const now = new Date().toISOString();
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,65 @@ 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
- for (const [key, m] of Object.entries(raw.metrics)) {
112
- if (!m.derived || Array.isArray(m.value))
113
- continue;
114
- let computed;
115
- if (m.derived.op === 'sum') {
116
- computed = m.derived.of.reduce((acc, ref) => {
117
- const part = raw.metrics[ref];
118
- return acc + (part && typeof part.value === 'number' ? part.value : NaN);
119
- }, 0);
120
- }
121
- else {
122
- const endpoint = (v) => {
123
- if (typeof v === 'number')
124
- return v;
125
- const ref = raw.metrics[v];
126
- return ref && typeof ref.value === 'number' ? ref.value : undefined;
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
+ }
127
126
  };
128
- const before = endpoint(m.derived.before);
129
- const after = endpoint(m.derived.after);
130
- if (before === undefined || after === undefined || before === 0)
127
+ let computed;
128
+ if (m.derived.op === 'sum') {
129
+ computed = m.derived.of.reduce((acc, ref) => {
130
+ const part = raw.metrics[ref];
131
+ return acc + (part && typeof part.value === 'number' ? part.value : NaN);
132
+ }, 0);
133
+ if (!Number.isFinite(computed)) {
134
+ fail('sum references unknown or non-scalar metrics — not recomputed');
135
+ continue;
136
+ }
137
+ }
138
+ 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
+ const before = endpoint(m.derived.before);
146
+ const after = endpoint(m.derived.after);
147
+ if (before === undefined || after === undefined || before === 0) {
148
+ fail('pct_change endpoints are unresolvable or zero — not recomputed');
149
+ continue;
150
+ }
151
+ // keep the author's stated precision — refresh must not turn 0.155 into 0.1551724
152
+ const decimals = (String(m.value).split('.')[1] ?? '').length;
153
+ computed = Number(((after - before) / before).toFixed(decimals));
154
+ }
155
+ else {
156
+ fail(`unknown derived op "${m.derived.op}" — not recomputed`);
131
157
  continue;
132
- // keep the author's stated precision — refresh must not turn 0.155 into 0.1551724
133
- const decimals = (String(m.value).split('.')[1] ?? '').length;
134
- computed = Number(((after - before) / before).toFixed(decimals));
135
- }
136
- if (Number.isFinite(computed) && computed !== m.value) {
137
- result.changes.push({ key, before: m.value, after: computed });
138
- m.value = computed;
158
+ }
159
+ if (computed !== m.value) {
160
+ if (!firstBefore.has(key))
161
+ firstBefore.set(key, m.value);
162
+ m.value = computed;
163
+ moved = true;
164
+ }
139
165
  }
140
166
  }
167
+ for (const [key, before] of firstBefore) {
168
+ result.changes.push({ key, before, after: raw.metrics[key].value });
169
+ }
141
170
  if (!options.dryRun && (result.changes.length > 0 || result.errors.length === 0)) {
142
171
  await writeFile(irPath, `${JSON.stringify(raw, null, 2)}\n`);
143
172
  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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3
15
  }
4
16
  export function formatValue(m) {
5
17
  if (Array.isArray(m.value)) {
6
18
  const [lo, hi] = m.value;
7
- return `${lo.toLocaleString()}~${hi.toLocaleString()}${m.unit === 'ratio' ? '%' : ` ${m.unit}`}`;
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();
@@ -37,7 +54,8 @@ function markdown(src) {
37
54
  .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
38
55
  }
39
56
  export function render(report, ir) {
40
- const grounded = escapeHtml(report)
57
+ const { text: protectedReport, restore } = protectCode(escapeHtml(report));
58
+ const grounded = restore(protectedReport
41
59
  .replace(/\{\{m:([\w-]+)\}\}/g, (_, key) => {
42
60
  const m = ir.metrics[key];
43
61
  return `<b class="w" title="${escapeHtml(receipt(m))}">${formatValue(m)}<sup>†</sup></b>`;
@@ -50,7 +68,7 @@ export function render(report, ir) {
50
68
  .join(' | ');
51
69
  return `<span class="c" title="${escapeHtml(receipts)}">${text.trim()}<sup>‡</sup></span>`;
52
70
  })
53
- .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text));
71
+ .replace(/\{\{raw:([^}]*)\}\}/g, (_, text) => escapeHtml(text)));
54
72
  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
55
73
  <style>
56
74
  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 body = report
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,14 @@ 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
+ }
21
29
  const appendix = used
22
30
  .map((key, i) => {
23
31
  const m = ir.metrics[key];
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,14 @@ 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(/^#+ .*$/gm, blank) // headings
26
+ .replace(/^#{1,6}(?= )/gm, blank) // heading markers only — heading TEXT is scanned, people summarise numbers there
23
27
  .replace(/^\s*\d+\.\s/gm, blank); // ordered-list markers
24
28
  const leaks = [];
25
29
  for (const m of stripped.matchAll(/\d[\d,.]*\s*(%p?|[가-힣]{1,2})?/g)) {
26
- const token = m[0].trim();
30
+ const token = m[0].trim().replace(/[.,]+$/, '');
27
31
  if (!token)
28
32
  continue;
29
33
  leaks.push({
@@ -37,8 +41,9 @@ export function scanNakedNumbers(report) {
37
41
  return leaks;
38
42
  }
39
43
  /** Every reference in the narrative must resolve; a dangling one is authoring drift. */
40
- export function scanRefs(report, metricKeys, idKeys) {
44
+ export function scanRefs(rawReport, metricKeys, idKeys) {
41
45
  const leaks = [];
46
+ const report = blankCode(rawReport);
42
47
  for (const m of report.matchAll(/\{\{claim:([^|}]*)\|\s*evidence:([^}]*)\}\}/g)) {
43
48
  const [, text, evidence] = m;
44
49
  const line = lineAt(report, m.index);
@@ -80,3 +85,25 @@ export function scanRefs(report, metricKeys, idKeys) {
80
85
  }
81
86
  return leaks;
82
87
  }
88
+ /**
89
+ * Anything still shaped like a marker after every recognised form is removed was a typo —
90
+ * and a typo'd marker must be a leak, or it renders verbatim and its number sails through.
91
+ */
92
+ export function scanMarkers(rawReport) {
93
+ const known = blankCode(rawReport)
94
+ .replace(/\{\{(m|id):[\w-]+\}\}/g, blank)
95
+ .replace(/\{\{raw:[^}]*\}\}/g, blank)
96
+ .replace(/\{\{claim:[^|}]*\|\s*evidence:[^}]*\}\}/g, blank)
97
+ .replace(/\{\{claim:[^|}]*\}\}/g, blank); // no-pipe form is already claim-without-evidence
98
+ const leaks = [];
99
+ for (const m of known.matchAll(/\{\{[^}]*\}\}?/g)) {
100
+ leaks.push({
101
+ severity: 'error',
102
+ rule: 'malformed-marker',
103
+ line: lineAt(known, m.index),
104
+ message: `"${m[0]}" is not a recognised marker`,
105
+ detail: 'Valid forms: {{m:key}}, {{id:key}}, {{raw:…}}, {{claim: text | evidence: keys}}.',
106
+ });
107
+ }
108
+ return leaks;
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watertight",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Reports that hold water — every number carries its receipt, and ungrounded claims fail the build.",
5
5
  "type": "module",
6
6
  "bin": {