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 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
- `--dry-run` previews changes without writing.
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
- for (const path of [reportPath, irPath]) {
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
- const r = await refresh(irPath, { allowCommands: opts.allowCommands, dryRun: opts.dryRun });
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 built-in adapter for source type "${m.source.type}"` });
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
- // parts may have moved, so stored sums are recomputed rather than left to go stale
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?.op !== 'sum' || Array.isArray(m.value))
112
+ if (!m.derived || Array.isArray(m.value))
110
113
  continue;
111
- const computed = m.derived.of.reduce((acc, ref) => {
112
- const part = raw.metrics[ref];
113
- return acc + (part && typeof part.value === 'number' ? part.value : NaN);
114
- }, 0);
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.1.0",
4
- "description": "Reports that hold water \u2014 every number carries its receipt, and ungrounded claims fail the build.",
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": "./dist/cli.js"
7
+ "watertight": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",