diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab9af4..51d04ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/demo-numbers.test.js b/__tests__/demo-numbers.test.js new file mode 100644 index 0000000..965a031 --- /dev/null +++ b/__tests__/demo-numbers.test.js @@ -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`); + }); +}); diff --git a/bin/rainfall.js b/bin/rainfall.js index f6e04eb..7e7ea99 100755 --- a/bin/rainfall.js +++ b/bin/rainfall.js @@ -7,7 +7,8 @@ // rainfall condense [file] [--focus ] // 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 @@ -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); @@ -180,7 +202,9 @@ switch (command) { console.log(' rainfall condense [file] [--focus ]'); console.log(' Print the compact AI context digest; --focus '); 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)'); diff --git a/package.json b/package.json index 90536b8..90d4142 100644 --- a/package.json +++ b/package.json @@ -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": [