watertight 0.1.0 → 0.2.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 +22 -1
- package/SKILL.md +3 -0
- package/dist/cli.js +25 -3
- package/dist/refresh.js +22 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -150,10 +150,31 @@ can reach, rewrites `value` and `fetched_at`, and recomputes derived values:
|
|
|
150
150
|
- `command` sources — run **only** with the explicit `--allow-commands` flag,
|
|
151
151
|
because refreshing an IR you didn't author must never execute its shell
|
|
152
152
|
commands
|
|
153
|
+
- any other source type can be covered by a **custom fetcher** — a JS module
|
|
154
|
+
you point at explicitly:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
watertight refresh . --fetchers ./my-fetchers.mjs
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
// my-fetchers.mjs — export one function per source type
|
|
162
|
+
export async function mixpanel(source) {
|
|
163
|
+
// credentials from the environment, receipt fields from the IR
|
|
164
|
+
return valueFetchedFrom(source.project, source.bookmark)
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
[`examples/fetchers/mixpanel.mjs`](./examples/fetchers/mixpanel.mjs) is a
|
|
169
|
+
working example. Loading a module runs its code, so only pass files you
|
|
170
|
+
wrote or trust — the IR itself can never name a fetcher. Built-in csv/json/
|
|
171
|
+
command adapters always win over a custom one of the same name.
|
|
153
172
|
- everything else (dashboards, vendor reports, hypotheses) is **named as
|
|
154
173
|
skipped** — never silently assumed fresh
|
|
155
174
|
|
|
156
|
-
|
|
175
|
+
Derived values follow their inputs: sums are recomputed exactly, percentage
|
|
176
|
+
changes at the precision the author stated. `--dry-run` previews changes
|
|
177
|
+
without writing.
|
|
157
178
|
|
|
158
179
|
## For AI-authored reports
|
|
159
180
|
|
package/SKILL.md
CHANGED
|
@@ -89,6 +89,9 @@ inputs: recompute at the source and correct whichever side is wrong.
|
|
|
89
89
|
sources, updates `fetched_at`, recomputes derived values, and names every
|
|
90
90
|
metric it could *not* refresh. `command` sources run only under
|
|
91
91
|
`--allow-commands`; never pass that flag on an IR you did not author.
|
|
92
|
+
Other source types (vendor APIs, analytics tools) can be refreshed through
|
|
93
|
+
`--fetchers <module.mjs>` — one exported function per source type, with
|
|
94
|
+
credentials from the environment, never from the IR.
|
|
92
95
|
|
|
93
96
|
## What stays yours
|
|
94
97
|
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { access, readFile, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { compile } from './compile.js';
|
|
5
5
|
import { refresh } from './refresh.js';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
6
7
|
const USAGE = `watertight — reports that hold water. Every number carries its receipt;
|
|
7
8
|
an ungrounded claim is a leak, and a report with leaks does not build.
|
|
8
9
|
|
|
@@ -17,6 +18,9 @@ Options
|
|
|
17
18
|
--out <file> where to write the output (default: report.html / report.grounded.md)
|
|
18
19
|
--check verify only, write nothing
|
|
19
20
|
--dry-run refresh only: show what would change, write nothing
|
|
21
|
+
--fetchers <file> refresh only: a JS module of custom source adapters, e.g.
|
|
22
|
+
export function mixpanel(source) { ... return value }
|
|
23
|
+
Loading a module runs its code — only pass files you wrote or trust.
|
|
20
24
|
--allow-commands refresh only: let "command" sources run shell (off by default —
|
|
21
25
|
an IR from someone else's repo must not execute code on your machine)
|
|
22
26
|
--json machine-readable result on stdout
|
|
@@ -33,6 +37,7 @@ function parseArgs(argv) {
|
|
|
33
37
|
let json = false;
|
|
34
38
|
let dryRun = false;
|
|
35
39
|
let allowCommands = false;
|
|
40
|
+
let fetchersPath;
|
|
36
41
|
let format = 'html';
|
|
37
42
|
let help = false;
|
|
38
43
|
let version = false;
|
|
@@ -46,6 +51,8 @@ function parseArgs(argv) {
|
|
|
46
51
|
dryRun = true;
|
|
47
52
|
else if (arg === '--allow-commands')
|
|
48
53
|
allowCommands = true;
|
|
54
|
+
else if (arg === '--fetchers')
|
|
55
|
+
fetchersPath = args[++i];
|
|
49
56
|
else if (arg === '--format')
|
|
50
57
|
format = args[++i] === 'md' ? 'md' : 'html';
|
|
51
58
|
else if (arg === '--json')
|
|
@@ -60,7 +67,7 @@ function parseArgs(argv) {
|
|
|
60
67
|
const command = positional[0] === 'refresh' ? 'refresh' : 'compile';
|
|
61
68
|
if (command === 'refresh')
|
|
62
69
|
positional.shift();
|
|
63
|
-
return { command, positional, out, check, json, help, version, dryRun, allowCommands, format };
|
|
70
|
+
return { command, positional, out, check, json, help, version, dryRun, allowCommands, format, fetchersPath };
|
|
64
71
|
}
|
|
65
72
|
async function exists(path) {
|
|
66
73
|
try {
|
|
@@ -90,7 +97,8 @@ async function main() {
|
|
|
90
97
|
reportPath = join(dir, 'report.md');
|
|
91
98
|
irPath = join(dir, 'metrics.json');
|
|
92
99
|
}
|
|
93
|
-
|
|
100
|
+
// refresh works on the IR alone — an IR-only directory is a legitimate workspace
|
|
101
|
+
for (const path of opts.command === 'refresh' ? [irPath] : [reportPath, irPath]) {
|
|
94
102
|
if (!(await exists(path))) {
|
|
95
103
|
console.error(`Not found: ${path}`);
|
|
96
104
|
console.error('Expected report.md and metrics.json — see --help.');
|
|
@@ -98,7 +106,21 @@ async function main() {
|
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
if (opts.command === 'refresh') {
|
|
101
|
-
|
|
109
|
+
let fetchers;
|
|
110
|
+
if (opts.fetchersPath) {
|
|
111
|
+
const mod = await import(pathToFileURL(resolve(opts.fetchersPath)).href);
|
|
112
|
+
const exports = (typeof mod.default === 'object' && mod.default !== null ? mod.default : mod);
|
|
113
|
+
fetchers = Object.fromEntries(Object.entries(exports).filter(([, v]) => typeof v === 'function'));
|
|
114
|
+
if (Object.keys(fetchers).length === 0) {
|
|
115
|
+
console.error(`error: ${opts.fetchersPath} exports no functions — nothing to fetch with`);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const r = await refresh(irPath, { allowCommands: opts.allowCommands, dryRun: opts.dryRun, fetchers });
|
|
120
|
+
if (opts.json) {
|
|
121
|
+
console.log(JSON.stringify(r, null, 2));
|
|
122
|
+
process.exit(r.errors.length > 0 ? 1 : 0);
|
|
123
|
+
}
|
|
102
124
|
for (const c of r.changes)
|
|
103
125
|
console.log(` ${c.key}: ${c.before.toLocaleString()} → ${c.after.toLocaleString()}`);
|
|
104
126
|
for (const s of r.skipped)
|
package/dist/refresh.js
CHANGED
|
@@ -91,8 +91,11 @@ export async function refresh(irPath, options) {
|
|
|
91
91
|
}
|
|
92
92
|
value = fetchCommand(m.source, baseDir);
|
|
93
93
|
}
|
|
94
|
+
else if (options.fetchers?.[m.source.type]) {
|
|
95
|
+
value = toNumber(await options.fetchers[m.source.type](m.source, { key, metric: m, baseDir }), `fetcher "${m.source.type}" for "${key}"`);
|
|
96
|
+
}
|
|
94
97
|
else {
|
|
95
|
-
result.skipped.push({ key, reason: `no
|
|
98
|
+
result.skipped.push({ key, reason: `no adapter for source type "${m.source.type}"` });
|
|
96
99
|
continue;
|
|
97
100
|
}
|
|
98
101
|
if (value !== m.value)
|
|
@@ -104,14 +107,26 @@ export async function refresh(irPath, options) {
|
|
|
104
107
|
result.errors.push({ key, message: err instanceof Error ? err.message : String(err) });
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
|
-
//
|
|
110
|
+
// inputs may have moved, so derived values are recomputed rather than left to go stale
|
|
108
111
|
for (const [key, m] of Object.entries(raw.metrics)) {
|
|
109
|
-
if (m.derived
|
|
112
|
+
if (!m.derived || Array.isArray(m.value))
|
|
110
113
|
continue;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 before = raw.metrics[m.derived.before]?.value;
|
|
123
|
+
const after = raw.metrics[m.derived.after]?.value;
|
|
124
|
+
if (typeof before !== 'number' || typeof after !== 'number' || before === 0)
|
|
125
|
+
continue;
|
|
126
|
+
// keep the author's stated precision — refresh must not turn 0.155 into 0.1551724
|
|
127
|
+
const decimals = (String(m.value).split('.')[1] ?? '').length;
|
|
128
|
+
computed = Number(((after - before) / before).toFixed(decimals));
|
|
129
|
+
}
|
|
115
130
|
if (Number.isFinite(computed) && computed !== m.value) {
|
|
116
131
|
result.changes.push({ key, before: m.value, after: computed });
|
|
117
132
|
m.value = computed;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watertight",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Reports that hold water
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reports that hold water — every number carries its receipt, and ungrounded claims fail the build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"watertight": "
|
|
7
|
+
"watertight": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|