diff --git a/acceptance/lib/runner-worker.js b/acceptance/lib/runner-worker.js new file mode 100644 index 0000000..332eaae --- /dev/null +++ b/acceptance/lib/runner-worker.js @@ -0,0 +1,68 @@ +/* + Persistent runner adapter for the APS gherkin-mutator. + + The mutator starts this process (once per worker) and sends mutation jobs + over newline-delimited JSON on stdin. Each job carries the path to a mutated + feature JSON IR; this worker evaluates it through the same acceptance runtime + and step handlers used by the normal acceptance pipeline and replies with the + runner outcome. + + Protocol (mutator-spec.md): + request: { "id", "feature_json", "generated_dir", "work_dir" } + response: { "id", "outcome", "output", "error", "duration" } + outcome: test_success | test_failure | infrastructure_error + + test_failure (acceptance failed) -> mutation killed + test_success (acceptance passed) -> mutation survived +*/ + +'use strict' + +const readline = require('node:readline') +const fs = require('node:fs') +const { runFeature } = require('./runtime') + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}) + +rl.on('line', async (line) => { + const started = Date.now() + const respond = (payload) => { + process.stdout.write(`${JSON.stringify(payload)}\n`) + } + + let job + try { + job = JSON.parse(line) + } catch (err) { + respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started }) + return + } + + try { + const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8')) + const report = await runFeature(ir) + respond({ + id: job.id, + outcome: report.failures === 0 ? 'test_success' : 'test_failure', + output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'), + error: '', + duration: Date.now() - started + }) + } catch (err) { + respond({ + id: job.id, + outcome: 'infrastructure_error', + output: '', + error: err.message, + duration: Date.now() - started + }) + } +}) + +rl.on('close', () => { + process.exit(0) +}) diff --git a/docs/reviews/post-memo-summary.md b/docs/reviews/post-memo-summary.md new file mode 100644 index 0000000..2f506fd --- /dev/null +++ b/docs/reviews/post-memo-summary.md @@ -0,0 +1,66 @@ +# Architectural Review Summary — post-memo + +## Task and commits reviewed +- Task: `post-memo` +- Reviewed the merged branch ending at `8e2693ad08` (refactorer), which carried: + - `d071b21`/`c9556be` — specifier Post-a-Memo Gherkin spec + feature backlog + - `514298b` — coder implementation with acceptance pipeline + - `64a7f78` — refactorer merge of coder work + - `8e2693a` — refactorer memo-post complexity reduction +- Merged into `swarmforge-architect` (fast-forward) and processed as a batch. + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and +local code quality. + +1. **Testable core behind small adapters (good).** `src/services/memo-post.js` is a + testable module free of UI/IO concerns. It injects `wallet` and `feed` adapters + (minimal-slp-wallet surface + feed reflection), so OP_RETURN/broadcast and UI + concerns stay outside the core. Environmentally unsuitable I/O is confined to + adapter boundaries. Dependency direction is inward. +2. **Acceptance pipeline separation (good).** `acceptance/lib/{generate,runtime,handlers}` + are the project-specific components the APS spec prescribes. The feature is parsed + by the **APS-supplied Babashka `gherkin-parser`** (procured fresh from + github.com/unclebob/Acceptance-Pipeline-Specification on first use), not + reimplemented. `handlers.js` drives real core behavior through fake wallet/feed + adapters so the run is deterministic and offline. +3. **Information hiding (good).** memo-post hides the Memo `0x6d02` prefix and + broadcast mechanics; handlers only assert observable outcomes (broadcast prefix, + feed reflection, empty/length rejection). +4. **Fix applied — runner adapter added.** Built the project-specific persistent + runner adapter required by `gherkin-mutator` at + `acceptance/lib/runner-worker.js` (newline-delimited JSON protocol; evaluates each + mutated feature IR through the same runtime/handlers and reports + `test_failure|test_success|infrastructure_error`). +5. **Manifests updated by tools (permitted).** The language mutation tool embedded + its differential footer manifest in `src/services/memo-post.js`; the APS mutator + wrote the acceptance-mutation scenario manifest into `specs/post-memo.feature`. + Both are normal tool output and were left for the tooling, not hand-edited. + +## Verification results +- **Unit (`node --test`):** 7/7 pass (`memo-post.test.js`). +- **Acceptance (normal):** all 6 scenario executions pass. +- **Mutation (`mutate4javascript` differential, `--max-workers 8`):** + `memo-post.js` — **killed 7, survived 0, uncovered 0**. Full kill. +- **DRY (`dry4javascript src`):** no duplicate candidates. +- **Gherkin acceptance mutation (soft):** 6 discovered; **1 killed**, **5 survived**, + 0 errors. + - Killed: the empty-memo boundary — dithering the `" "` example makes the + validation branch fail the scenario, confirming the empty-rejection guardrail is + connected to the example data. + - Survived (documented equivalents): message-content dithers in the valid-memo + scenario and the over-long scenarios. The tests treat the message as opaque data + and assert the *same* (mutated) text is composed and reflected, so changing one + character leaves the exercised branch identical. These are acceptable equivalent + survivors, not missing guardrails. +- **Property tests:** none present in this project. + +## Suite status +- Unit + acceptance suites pass; mutation fully kills source-level mutants for the + testable core. Gherkin acceptance mutation has 5 documented-equivalent survivors. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `post-memo`), to review the + architectural commit (runner adapter + tool manifests). + +By architect. diff --git a/specs/post-memo.feature b/specs/post-memo.feature index 9433ecf..d6743b1 100644 --- a/specs/post-memo.feature +++ b/specs/post-memo.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-08-25T23:22:59.013044939Z","feature_name":"Post a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-25T23:22:52.631790256Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3 Feature: Post a Memo diff --git a/src/services/memo-post.js b/src/services/memo-post.js index 12b1a90..65b3d1d 100644 --- a/src/services/memo-post.js +++ b/src/services/memo-post.js @@ -45,15 +45,7 @@ class MemoPost { // Resolves with the transaction id, or rejects with a typed error. async post (message) { const check = this.validate(message) - if (!check.ok) { - const err = new Error( - check.type === 'length' - ? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.` - : 'Memo must not be empty.' - ) - err.code = check.type === 'length' ? 'memo_length' : 'memo_validation' - throw err - } + this._throwIfInvalid(check) if (!this.wallet) { throw new Error('Memo post requires a wallet.') @@ -71,6 +63,26 @@ class MemoPost { ) // Reflect the new post in the feed once broadcast succeeds. + this._reflectPost(txid, message) + + return txid + } + + // Throw the appropriate typed error when a memo fails validation. + _throwIfInvalid (check) { + if (check.ok) return + + const err = new Error( + check.type === 'length' + ? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.` + : 'Memo must not be empty.' + ) + err.code = check.type === 'length' ? 'memo_length' : 'memo_validation' + throw err + } + + // Record the new post on the injected feed when one is present. + _reflectPost (txid, message) { if (this.feed && typeof this.feed.addPost === 'function') { this.feed.addPost({ txid, @@ -78,8 +90,6 @@ class MemoPost { text: message }) } - - return txid } } @@ -87,3 +97,7 @@ MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS module.exports = MemoPost + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-25T23:18:27.154Z","module_hash":"139dec671f2f59aad3c83f5c225c1f8eb1f56e4f662f2b674d2f6db06bf4de8b","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":25,"end_line":28,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":32,"end_line":42,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":46,"end_line":69,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":72,"end_line":82,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":85,"end_line":93,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]} +// mutate4javascript-manifest-end diff --git a/test/unit/memo-post.test.js b/test/unit/memo-post.test.js index 1a5fef2..314da82 100644 --- a/test/unit/memo-post.test.js +++ b/test/unit/memo-post.test.js @@ -89,26 +89,17 @@ test('posting an empty memo throws a validation error and broadcasts nothing', a assert.equal(feed.posts.length, 0) }) -test('posting a whitespace-only memo throws a validation error and broadcasts nothing', async () => { - const wallet = fakeWallet() - const memoPost = new MemoPost({ wallet }) +test('posting a whitespace-only or non-string memo throws a validation error and broadcasts nothing', async () => { + for (const invalid of [' ', 42]) { + const wallet = fakeWallet() + const memoPost = new MemoPost({ wallet }) - await assert.rejects( - memoPost.post(' '), - (err) => err.code === 'memo_validation' - ) - assert.equal(wallet.broadcasts.length, 0) -}) - -test('posting a non-string memo throws a validation error', async () => { - const wallet = fakeWallet() - const memoPost = new MemoPost({ wallet }) - - await assert.rejects( - memoPost.post(42), - (err) => err.code === 'memo_validation' - ) - assert.equal(wallet.broadcasts.length, 0) + await assert.rejects( + memoPost.post(invalid), + (err) => err.code === 'memo_validation' + ) + assert.equal(wallet.broadcasts.length, 0) + } }) test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => {