feat(ultraplan-local): tally CLI baseline fixture for examples/02-real-cli (Spor B B2) [skip-docs]
Adds the runnable counterpart to examples/01-add-verbose-flag (which is artifacts-only). The fixture is the measurement target for Spor B's end-to-end pipeline run (B3) and Spor C's cache-prefix experiment. Baseline: - tally.mjs (80 lines, hand-rolled argv parser, zero deps) - 3 flags: --json, -i/--ignore-case, --lines + --help - Exit codes: 0 success, 1 file error, 2 invalid argv - 10 node:test cases, all green (~2.2s wall-clock) - Deterministic fixtures: sample.txt (foo×7, Foo×1, regex fo+×9) + poem.txt (--lines vs total distinction) - REGENERATED.md skeleton (B3 fills the pipeline walk-through) Brief preconditions verified: - grep -c 'foo' sample.txt = 4 (>= 1) - regex /fo+/g count = 9 (> grep count) - Brief assumptions for B3 SC #1, #3 hold This is the first runnable example in plugins/ultraplan-local/examples/. Next: B3 runs /ultraresearch-local + /ultraplan-local + /ultraexecute-local against the brief to add --regex/-r, then verifies all 10 Success Criteria.
This commit is contained in:
parent
baff890789
commit
c8146c143d
5 changed files with 272 additions and 0 deletions
85
plugins/ultraplan-local/examples/02-real-cli/REGENERATED.md
Normal file
85
plugins/ultraplan-local/examples/02-real-cli/REGENERATED.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# REGENERATED.md — examples/02-real-cli
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Calibrated against | ultraplan-local v3.4.1 |
|
||||||
|
| Last regenerated | TBD — filled in after the B3 pipeline run |
|
||||||
|
| Source brief author | Hand-authored by operator (B1 session, 2026-05-04) |
|
||||||
|
| Baseline author | B2 session, 2026-05-04 |
|
||||||
|
| Pipeline run | TBD — B3 will run `/ultraresearch-local`, `/ultraplan-local`, `/ultraexecute-local` |
|
||||||
|
|
||||||
|
## What this example demonstrates
|
||||||
|
|
||||||
|
(Placeholder — fylles ut når B3-pipeline-runet er ferdig. Skal forklare
|
||||||
|
hvorfor 02-real-cli er en *runnable* fixture, i motsetning til 01-add-verbose-flag
|
||||||
|
som kun er artifacts.)
|
||||||
|
|
||||||
|
## Baseline (delivered by B2, 2026-05-04)
|
||||||
|
|
||||||
|
`tally` — a ~80-line zero-dep Node.js CLI that counts literal-substring
|
||||||
|
occurrences of a pattern in a text file. Three flags (`--json`,
|
||||||
|
`-i`/`--ignore-case`, `--lines`), `--help`, exit codes 0/1/2.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
examples/02-real-cli/
|
||||||
|
├── tally.mjs # CLI (≈ 80 lines, hand-rolled argv parser)
|
||||||
|
├── tests/tally.test.mjs # 10 node:test cases (all pass < 3s)
|
||||||
|
├── fixtures/
|
||||||
|
│ ├── sample.txt # 9-line input with known counts (foo×7, Foo×1, fo+ regex × 9)
|
||||||
|
│ └── poem.txt # 5-line input for --lines vs total distinction
|
||||||
|
└── REGENERATED.md # this file
|
||||||
|
```
|
||||||
|
|
||||||
|
(Placeholder — utvides med en mini-walk-through av baseline når B3 har
|
||||||
|
sluttført pipeline-runet.)
|
||||||
|
|
||||||
|
## Pipeline run (delivered by B3, TBD)
|
||||||
|
|
||||||
|
### `/ultraresearch-local`
|
||||||
|
|
||||||
|
(Placeholder — research-swarmen forventes å rapportere 0 topics og produsere
|
||||||
|
en kort placeholder-brief. Fyll inn faktisk output her.)
|
||||||
|
|
||||||
|
### `/ultraplan-local`
|
||||||
|
|
||||||
|
(Placeholder — plan.md forventes med 3–5 steg som målretter `tally.mjs`
|
||||||
|
+ `tests/tally.test.mjs`. Lim inn step-overskrifter + manifest-summary.)
|
||||||
|
|
||||||
|
### `/ultraexecute-local`
|
||||||
|
|
||||||
|
(Placeholder — `progress.json` forventes med `verify_passed: true` på alle
|
||||||
|
steg. Lim inn commit-SHA-ene + steg-tellingen.)
|
||||||
|
|
||||||
|
## How to re-run this example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/ultraplan-local
|
||||||
|
# 1. Re-run the pipeline against the existing brief
|
||||||
|
/ultraresearch-local --project .claude/projects/2026-05-04-examples-02-real-cli
|
||||||
|
/ultraplan-local --project .claude/projects/2026-05-04-examples-02-real-cli
|
||||||
|
/ultraexecute-local --project .claude/projects/2026-05-04-examples-02-real-cli
|
||||||
|
|
||||||
|
# 2. Verify all 10 Success Criteria from brief.md hold:
|
||||||
|
node --test examples/02-real-cli/tests/tally.test.mjs
|
||||||
|
node examples/02-real-cli/tally.mjs --regex 'fo+' examples/02-real-cli/fixtures/sample.txt
|
||||||
|
node examples/02-real-cli/tally.mjs --json --regex 'fo+' examples/02-real-cli/fixtures/sample.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
(Placeholder — utvides med eksakte forventede verdier etter B3.)
|
||||||
|
|
||||||
|
## Regeneration triggers
|
||||||
|
|
||||||
|
When to re-run this example:
|
||||||
|
|
||||||
|
- ultraplan-local minor version bump (e.g. v3.4 → v3.5)
|
||||||
|
- `plan_version` schema bump
|
||||||
|
- Manifest YAML required-key additions
|
||||||
|
- `progress.json` schema bump
|
||||||
|
- Pipeline-output format change (brief / research / plan / progress)
|
||||||
|
|
||||||
|
When regenerating: re-run the pipeline against the existing `brief.md` and
|
||||||
|
update this file plus `examples/02-real-cli/` artifacts. The fixture itself
|
||||||
|
(`tally.mjs`, fixtures, baseline tests) stays stable across regenerations —
|
||||||
|
only the pipeline outputs change.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
foo on this line
|
||||||
|
nothing here
|
||||||
|
foo and foo here
|
||||||
|
silence
|
||||||
|
foo
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
Foo bar baz
|
||||||
|
The quick brown fox jumps over the foo
|
||||||
|
foo foo bar foo
|
||||||
|
food for thought.
|
||||||
|
fooo, fooooo, very loud
|
||||||
|
This line has no match here.
|
||||||
|
A line without the magic word
|
||||||
|
And another one without it
|
||||||
|
The end. Final period.
|
||||||
80
plugins/ultraplan-local/examples/02-real-cli/tally.mjs
Executable file
80
plugins/ultraplan-local/examples/02-real-cli/tally.mjs
Executable file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const HELP = `Usage: tally [options] <pattern> <file>
|
||||||
|
|
||||||
|
Count literal-substring occurrences of <pattern> in <file>.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-i, --ignore-case Case-insensitive matching
|
||||||
|
--lines Count lines containing pattern (not total occurrences)
|
||||||
|
--json Emit a JSON object on stdout
|
||||||
|
-h, --help Show this help and exit
|
||||||
|
|
||||||
|
Exit codes: 0=success 1=file error 2=invalid argv
|
||||||
|
`;
|
||||||
|
|
||||||
|
function fail(msg, code = 2) {
|
||||||
|
process.stderr.write(`tally: ${msg}\n`);
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const positional = [];
|
||||||
|
const flags = { json: false, ignoreCase: false, lines: false };
|
||||||
|
for (const a of argv) {
|
||||||
|
if (a === '--json') flags.json = true;
|
||||||
|
else if (a === '-i' || a === '--ignore-case') flags.ignoreCase = true;
|
||||||
|
else if (a === '--lines') flags.lines = true;
|
||||||
|
else if (a === '-h' || a === '--help') { process.stdout.write(HELP); process.exit(0); }
|
||||||
|
else if (a.startsWith('-')) fail(`unknown flag: ${a}`);
|
||||||
|
else positional.push(a);
|
||||||
|
}
|
||||||
|
if (positional.length !== 2) fail('expected <pattern> <file>');
|
||||||
|
return { pattern: positional[0], file: positional[1], flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOccurrences(text, pattern, ignoreCase) {
|
||||||
|
if (pattern.length === 0) return 0;
|
||||||
|
const haystack = ignoreCase ? text.toLowerCase() : text;
|
||||||
|
const needle = ignoreCase ? pattern.toLowerCase() : pattern;
|
||||||
|
let count = 0, idx = 0;
|
||||||
|
while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLines(text, pattern, ignoreCase) {
|
||||||
|
if (pattern.length === 0) return 0;
|
||||||
|
const needle = ignoreCase ? pattern.toLowerCase() : pattern;
|
||||||
|
let count = 0;
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const haystack = ignoreCase ? line.toLowerCase() : line;
|
||||||
|
if (haystack.includes(needle)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const { pattern, file, flags } = parseArgs(process.argv.slice(2));
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = readFileSync(file, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
const what = err.code === 'ENOENT' ? 'file not found' : 'read error';
|
||||||
|
process.stderr.write(`tally: ${what}: ${file}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const count = flags.lines
|
||||||
|
? countLines(text, pattern, flags.ignoreCase)
|
||||||
|
: countOccurrences(text, pattern, flags.ignoreCase);
|
||||||
|
if (flags.json) {
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
pattern, file, count,
|
||||||
|
flags: { json: flags.json, ignoreCase: flags.ignoreCase, lines: flags.lines },
|
||||||
|
}) + '\n');
|
||||||
|
} else {
|
||||||
|
process.stdout.write(count + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const TALLY = resolve(here, '..', 'tally.mjs');
|
||||||
|
const SAMPLE = resolve(here, '..', 'fixtures', 'sample.txt');
|
||||||
|
const POEM = resolve(here, '..', 'fixtures', 'poem.txt');
|
||||||
|
|
||||||
|
function run(...args) {
|
||||||
|
return spawnSync('node', [TALLY, ...args], { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('plain count: tally foo sample.txt prints 7', () => {
|
||||||
|
const r = run('foo', SAMPLE);
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
assert.equal(r.stdout.trim(), '7');
|
||||||
|
assert.equal(r.stderr, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON output: tally --json foo sample.txt parses with count 7', () => {
|
||||||
|
const r = run('--json', 'foo', SAMPLE);
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
const parsed = JSON.parse(r.stdout);
|
||||||
|
assert.equal(parsed.count, 7);
|
||||||
|
assert.equal(parsed.pattern, 'foo');
|
||||||
|
assert.equal(parsed.flags.json, true);
|
||||||
|
assert.equal(parsed.flags.ignoreCase, false);
|
||||||
|
assert.equal(parsed.flags.lines, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-sensitive default: tally Foo sample.txt prints 1', () => {
|
||||||
|
const r = run('Foo', SAMPLE);
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
assert.equal(r.stdout.trim(), '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive: tally -i Foo == tally -i foo (and exceeds case-sensitive)', () => {
|
||||||
|
const ri1 = run('-i', 'Foo', SAMPLE);
|
||||||
|
const ri2 = run('-i', 'foo', SAMPLE);
|
||||||
|
const rcs = run('foo', SAMPLE);
|
||||||
|
assert.equal(ri1.status, 0);
|
||||||
|
assert.equal(ri2.status, 0);
|
||||||
|
assert.equal(ri1.stdout, ri2.stdout);
|
||||||
|
assert.ok(Number(ri1.stdout) > Number(rcs.stdout));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--lines mode: tally --lines foo poem.txt prints 3 (not total occurrences 4)', () => {
|
||||||
|
const lines = run('--lines', 'foo', POEM);
|
||||||
|
const total = run('foo', POEM);
|
||||||
|
assert.equal(lines.status, 0);
|
||||||
|
assert.equal(total.status, 0);
|
||||||
|
assert.equal(lines.stdout.trim(), '3');
|
||||||
|
assert.equal(total.stdout.trim(), '4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flag in last position: tally foo sample.txt --json equals tally --json foo sample.txt', () => {
|
||||||
|
const last = run('foo', SAMPLE, '--json');
|
||||||
|
const first = run('--json', 'foo', SAMPLE);
|
||||||
|
assert.equal(last.status, 0);
|
||||||
|
assert.equal(first.status, 0);
|
||||||
|
assert.equal(last.stdout, first.stdout);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing argument: tally foo exits 2 with stderr', () => {
|
||||||
|
const r = run('foo');
|
||||||
|
assert.equal(r.status, 2);
|
||||||
|
assert.match(r.stderr, /^tally: /);
|
||||||
|
assert.equal(r.stdout, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown flag: tally --unknown foo sample.txt exits 2 with stderr', () => {
|
||||||
|
const r = run('--unknown', 'foo', SAMPLE);
|
||||||
|
assert.equal(r.status, 2);
|
||||||
|
assert.match(r.stderr, /^tally: /);
|
||||||
|
assert.equal(r.stdout, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file not found: tally foo /does/not/exist exits 1 with stderr', () => {
|
||||||
|
const r = run('foo', '/does/not/exist');
|
||||||
|
assert.equal(r.status, 1);
|
||||||
|
assert.match(r.stderr, /^tally: /);
|
||||||
|
assert.equal(r.stdout, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--help: stdout contains "Usage:", exit 0', () => {
|
||||||
|
const r = run('--help');
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
assert.match(r.stdout, /Usage:/);
|
||||||
|
assert.match(r.stdout, /--ignore-case/);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue