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 +2 -1
- package/dist/cli.js +6 -1
- package/dist/compile.js +2 -1
- package/dist/init.js +2 -1
- package/dist/ir.js +23 -3
- package/dist/refresh.js +57 -28
- package/dist/render.js +22 -4
- package/dist/renderMd.js +11 -3
- package/dist/scan.js +30 -3
- package/package.json +1 -1
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
|
|
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 (
|
|
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
|
-
|
|
35
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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, '&').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();
|
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|