Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## 0.4.0 — 2026-09-15

### Added

- **`rainfall report --json`** — the structured report, so a build can compare
committed numbers against live ones instead of scraping formatted text.
`buildReport()` already returned the right shape internally; it was simply
never exposed.
- **The demo numbers are now a test.** `__tests__/demo-numbers.test.js` runs the
report against `examples/fintech-demo` and fails if `TOKENS.md` or the README
quote figures the tool no longer produces. Verified by breaking TOKENS.md on
purpose and watching it fail.

This closes the gap that let TOKENS.md sit at 19 files / ~4977 tokens / 6.9x
while the truth was 17 / ~3699 / 5.1x. A package whose argument is
"documentation that fails the build when it stops being true" should not need
a human to notice.

### Fixed

- **`report` mis-parsed its directory argument.** It took `args[0]` as the root,
so `report --json` resolved the root to a folder named `--json`: every mapped
file came back missing and every number came back zero. Flags are now skipped
when picking the directory.

### Note

`tokenEstimator: "bytes/4"` is stated explicitly in the JSON output. Token counts
are `bytes ÷ 4`, a prose heuristic that runs low for source code — see #52.

## 0.3.2 — 2026-09-15

Documentation only. Publishes what 0.3.1 did not.
Expand Down
112 changes: 112 additions & 0 deletions __tests__/demo-numbers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* The demo's committed numbers match what the tool actually reports.
*
* TOKENS.md calls itself "real, reproducible" and on 2026-09-15 it was neither:
* it claimed 19 files / ~4977 tokens / 6.9x when the truth was 17 / ~3699 / 5.1x.
* Nothing was wrong with the tool. A measurement was written down once, the file
* walker later stopped counting two dotfiles, and no one re-ran it — so the
* document kept asserting a number that had stopped being true.
*
* That is the exact failure this package exists to prevent, and it happened in
* this package's own demo. So it is a test now.
*
* If this fails: run `rainfall report` in examples/fintech-demo and update
* TOKENS.md (and the demo section of the root README) with the new figures.
* Do not update the expectation to match the file.
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const root = path.join(__dirname, '..');
const demo = path.join(root, 'examples', 'fintech-demo');

const live = (() => {
const r = spawnSync(
process.execPath,
[path.join(root, 'bin', 'rainfall.js'), 'report', '--json'],
{ cwd: demo, encoding: 'utf8' },
);
if (r.status !== 0) throw new Error(`rainfall report --json failed: ${r.stderr}`);
return JSON.parse(r.stdout);
})();

const tokensMd = fs.readFileSync(path.join(demo, 'TOKENS.md'), 'utf8');
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');

describe('rainfall report --json', () => {
it('returns the full structured shape', () => {
expect(live).toMatchObject({
projectName: 'fintrack',
tokenEstimator: 'bytes/4',
digestTokens: expect.any(Number),
mapped: { files: expect.any(Number), tokens: expect.any(Number), missing: [] },
project: { files: expect.any(Number), tokens: expect.any(Number) },
savings: { vsMapped: expect.any(Number), vsProject: expect.any(Number) },
});
});

it('resolves the root correctly when a flag is passed', () => {
// `report --json` once resolved the root to a directory named "--json",
// so every mapped file came back missing and every number came back 0.
expect(live.mapped.files).toBeGreaterThan(0);
expect(live.project.tokens).toBeGreaterThan(0);
});

it('agrees with the human-readable output', () => {
const r = spawnSync(
process.execPath,
[path.join(root, 'bin', 'rainfall.js'), 'report'],
{ cwd: demo, encoding: 'utf8' },
);
expect(r.stdout).toContain(String(live.project.tokens));
expect(r.stdout).toContain(String(live.digestTokens));
});
});

describe('TOKENS.md is current', () => {
const oneDp = (n) => n.toFixed(1);

it('quotes the live digest and file-token counts', () => {
for (const n of [live.digestTokens, live.mapped.tokens, live.project.tokens]) {
expect({ n, inTokensMd: tokensMd.includes(String(n)) }).toEqual({ n, inTokensMd: true });
}
});

it('quotes the live file counts', () => {
expect(tokensMd).toContain(`${live.mapped.files} files`);
expect(tokensMd).toContain(`${live.project.files} files`);
});

it('quotes the live savings multiples', () => {
expect(tokensMd).toContain(`${oneDp(live.savings.vsMapped)}x`);
expect(tokensMd).toContain(`${oneDp(live.savings.vsProject)}x`);
});

it('does not still carry the figures it drifted to in 2026-09', () => {
// Scoped to the reported-numbers fence only. The prose deliberately quotes
// the stale values when explaining that this file drifted once, and that
// note should not trip its own guard.
const fence = tokensMd.match(/```[\s\S]*?```/);
expect(fence).not.toBeNull();
for (const stale of ['4977', '6.9x', '19 files']) {
expect({ stale, inNumbers: fence[0].includes(stale) }).toEqual({
stale,
inNumbers: false,
});
}
});
});

describe("the README's demo section is current", () => {
it('quotes the same numbers as the tool', () => {
for (const n of [live.digestTokens, live.mapped.tokens, live.project.tokens]) {
expect({ n, inReadme: readme.includes(String(n)) }).toEqual({ n, inReadme: true });
}
});

it('quotes the same multiples', () => {
expect(readme).toContain(`${live.savings.vsMapped.toFixed(1)}x`);
expect(readme).toContain(`${live.savings.vsProject.toFixed(1)}x`);
});
});
30 changes: 27 additions & 3 deletions bin/rainfall.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
// rainfall condense [file] [--focus <ref>]
// Print the compact AI context digest + token estimate;
// --focus limits it to one item's subgraph
// rainfall report [dir] Tokens-saved report: digest vs reading the source
// rainfall report [dir] [--json]
// Tokens-saved report: digest vs reading the source
// rainfall components [--json] List UI component shapes and their data contracts
// rainfall prompt Print AI instructions for building the manifest

Expand Down Expand Up @@ -133,8 +134,29 @@ switch (command) {
case 'report': {
try {
const { manifest } = loadManifest(cwd);
const root = path.resolve(cwd, args[0] || '.');
// Ignore flags when picking the directory: `report --json` was resolving
// the root to a folder literally named "--json", so every file came back
// missing and every number came back zero.
const dirArg = args.find((a) => !a.startsWith('-'));
const root = path.resolve(cwd, dirArg || '.');
const report = buildReport(manifest, root);
if (args.includes('--json')) {
// Structured output so a CI job can compare committed numbers against
// live ones without scraping formatted text. TOKENS.md drifted once
// precisely because re-checking it by hand was nobody's job.
console.log(
JSON.stringify(
{
projectName: (manifest.project && manifest.project.name) || null,
tokenEstimator: 'bytes/4',
...report,
},
null,
2,
),
);
break;
}
console.log(formatReport(report, manifest.project && manifest.project.name));
} catch (err) {
fail(err.message);
Expand Down Expand Up @@ -180,7 +202,9 @@ switch (command) {
console.log(' rainfall condense [file] [--focus <ref>]');
console.log(' Print the compact AI context digest; --focus <id|name|uiId>');
console.log(' limits it to one item and everything it touches');
console.log(' rainfall report [dir] Tokens-saved report: digest vs reading the source');
console.log(' rainfall report [dir] [--json]');
console.log(' Tokens-saved report: digest vs reading the');
console.log(' source; --json for machine-readable output');
console.log(' rainfall components [--json]');
console.log(' List UI component shapes: what data each accepts');
console.log(' and what props it produces (language-agnostic)');
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@morf_engineering/rainfalljs",
"version": "0.3.2",
"version": "0.4.0",
"description": "Compact, validated data-layer manifests for token-efficient AI development \u2014 entities, endpoints, components and the requirements they serve, in one file your build can check.",
"main": "dist/datamap/index.js",
"files": [
Expand Down