mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge architect post-memo runner adapter
By coder.
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
@@ -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
|
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
|
||||||
Feature: Post a Memo
|
Feature: Post a Memo
|
||||||
|
|
||||||
|
|||||||
+25
-11
@@ -45,15 +45,7 @@ class MemoPost {
|
|||||||
// Resolves with the transaction id, or rejects with a typed error.
|
// Resolves with the transaction id, or rejects with a typed error.
|
||||||
async post (message) {
|
async post (message) {
|
||||||
const check = this.validate(message)
|
const check = this.validate(message)
|
||||||
if (!check.ok) {
|
this._throwIfInvalid(check)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.wallet) {
|
if (!this.wallet) {
|
||||||
throw new Error('Memo post requires a 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.
|
// 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') {
|
if (this.feed && typeof this.feed.addPost === 'function') {
|
||||||
this.feed.addPost({
|
this.feed.addPost({
|
||||||
txid,
|
txid,
|
||||||
@@ -78,8 +90,6 @@ class MemoPost {
|
|||||||
text: message
|
text: message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return txid
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,3 +97,7 @@ MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX
|
|||||||
MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
|
MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
|
||||||
|
|
||||||
module.exports = MemoPost
|
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
|
||||||
|
|||||||
+10
-19
@@ -89,26 +89,17 @@ test('posting an empty memo throws a validation error and broadcasts nothing', a
|
|||||||
assert.equal(feed.posts.length, 0)
|
assert.equal(feed.posts.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('posting a whitespace-only memo throws a validation error and broadcasts nothing', async () => {
|
test('posting a whitespace-only or non-string memo throws a validation error and broadcasts nothing', async () => {
|
||||||
const wallet = fakeWallet()
|
for (const invalid of [' ', 42]) {
|
||||||
const memoPost = new MemoPost({ wallet })
|
const wallet = fakeWallet()
|
||||||
|
const memoPost = new MemoPost({ wallet })
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
memoPost.post(' '),
|
memoPost.post(invalid),
|
||||||
(err) => err.code === 'memo_validation'
|
(err) => err.code === 'memo_validation'
|
||||||
)
|
)
|
||||||
assert.equal(wallet.broadcasts.length, 0)
|
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => {
|
test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user