feat: one-click /apply-ai-fix for AI review suggested entries (#590)

- renderComment() adds maintainer hint for fix+valid YAML
- new apply-ai-fix.yml workflow (issue_comment trigger, permission-gated)
- new scripts/apply-ai-fix.js (extract, validate, commit to PR branch)
- docs/policy.md documents command
- tests: 16 new, 46 total pass

Co-authored-by: John Smith <[email protected]>
This commit is contained in:
BillyOutlastandJohn Smith authored and GitHub committed 2026-10-19 19:41:45 -04:00
1 parent 6d3eab37e1
commit 86e93031fb
5 files changed
+537

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
# Apply an AI review `fix` verdict in one command.
#
# A maintainer comments `/apply-ai-fix` on a PR and this workflow commits the Suggested
# entry from the latest AI review comment to the PR branch (same-repo PRs only).
#
# Security:
# - The trigger comment body is untrusted and only used as a signal (an `if:` check for
# the command string); nothing from it is interpolated into shell commands.
# - The YAML applied comes only from our own validated bot AI-review comment
# (MARKER `<!-- ai-review -->`), never from the trigger comment.
# - The sender's permission is checked via the collaborators permission API (write or
# admin required) before anything is written; fork PRs are rejected with an
# explanatory comment (same-repo only for the MVP); a stale reviewed SHA is rejected.
# - Untrusted strings are escaped with safe() before reaching a comment, and git runs
# via execFile array args (no shell).
name: Apply AI fix
on:
issue_comment:
types: [created]
permissions: {}
concurrency:
group: apply-ai-fix-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
apply:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/apply-ai-fix')
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Apply AI suggested entry
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: node scripts/apply-ai-fix.js --pr "${{ github.event.issue.number }}" --comment-id "${{ github.event.comment.id }}" --sender "${{ github.event.comment.user.login }}"
+2
View File
@@ -63,6 +63,8 @@ An OpenCode V2 agent reviews the judgment calls the deterministic checks leave o
A maintainer still merges every PR, including the ones the AI opened, and the `validate` check still applies. Delete the comment or remove the label to discard a verdict; dispatching the workflow with `force` re-reviews.
Maintainers can apply a fix verdict in one click by commenting /apply-ai-fix on the PR. The bot validates the sender, checks the suggestion is fresh and schema-valid, commits it to the PR branch (same-repo PRs only), and validate re-runs.
How it is contained:
- It runs only on a schedule or manual dispatch, with base-branch code; PR code is never checked out.
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env node
/**
* Apply the AI review's suggested entry to a PR branch in one command.
*
* node scripts/apply-ai-fix.js --pr <n> --sender <login> [--comment-id <id>] [--dry-run]
*
* Flow: fetch the PR (must be open) -> check the sender has write/admin via the
* collaborators permission API -> find the latest `<!-- ai-review -->` comment ->
* require its reviewed SHA to match the PR head (fail closed on stale) -> extract the
* first ```yaml block inside its <details> -> require exactly one changed
* data/**.yaml file in the PR -> schema-validate the suggestion -> commit it to the
* PR's head branch (same-repo PRs only for the MVP) and comment the result.
*
* Security: the trigger comment body is untrusted and only used as a signal (never
* interpolated into shell). The YAML applied comes only from our own validated bot
* AI-review comment (MARKER). All untrusted strings are escaped with safe() before
* reaching a comment. Git runs via execFile with array args (no shell).
*/
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const yaml = require('js-yaml');
const { rest } = require('./utils/github');
const { REPO, comment, listComments, pullChanges } = require('./utils/repo');
const { MARKER, checkSuggestedYaml, safe } = require('./utils/ai-verdict');
const { CATEGORIES } = require('./utils/catalog');
const execFileAsync = promisify(execFile);
const ROOT = path.join(__dirname, '..');
const APPLY_COMMAND = '/apply-ai-fix';
const SHA_RE = /<!-- sha=([0-9a-f]{7,40}) -->/;
const CATEGORY_RE = /category `([A-Za-z0-9_-]+)`/;
const DATA_FILE_RE = /^data\/[^/]+\/[^/]+\.yaml$/;
const SUCCESS_MESSAGE = 'Apply AI suggested entry (/apply-ai-fix by @%s)';
/** True when a comment body is an apply trigger (signal only; body is otherwise untrusted). */
function isTriggerComment(body) {
return String(body || '').includes(APPLY_COMMAND);
}
/** Latest comment whose body starts with the bot AI-review marker, or null. */
function findAiComment(comments) {
const list = Array.isArray(comments) ? comments : [];
for (let i = list.length - 1; i >= 0; i--) {
const body = list[i] && list[i].body;
if (typeof body === 'string' && body.startsWith(MARKER)) return list[i];
}
return null;
}
/** Reviewed SHA recorded in an AI review comment body, or null. */
function extractReviewedSha(body) {
const match = SHA_RE.exec(String(body || ''));
return match ? match[1] : null;
}
/**
* First ```yaml block inside the comment's <details> element (else the first ```yaml
* block anywhere, for robustness). Returns the raw YAML text or null.
*/
function extractSuggestedYaml(body) {
const text = String(body || '');
const details = /<details>([\s\S]*?)<\/details>/i.exec(text);
const scope = details ? details[1] : text;
const match = /```yaml\s*\n([\s\S]*?)```/.exec(scope);
return match ? match[1].trim() : null;
}
/** Category hint rendered in the AI review comment (`category `X``), or null. */
function extractCategoryHint(body) {
const match = CATEGORY_RE.exec(String(body || ''));
return match && CATEGORIES.includes(match[1]) ? match[1] : null;
}
/** Category implied by a catalog path (data/<category>/...), or null. */
function categoryFromPath(filePath) {
const match = /^data\/([^/]+)\//.exec(String(filePath || ''));
return match && CATEGORIES.includes(match[1]) ? match[1] : null;
}
/**
* Pick the single catalog file the suggestion applies to.
* @param {string[]} changedPaths - changed file paths in the PR
* @returns {{file: string|null, error: string|null}}
*/
function targetFile(changedPaths) {
const files = (Array.isArray(changedPaths) ? changedPaths : [])
.filter(p => typeof p === 'string' && DATA_FILE_RE.test(p));
if (files.length === 0) return { file: null, error: 'No catalog file in this PR' };
if (files.length > 1) return { file: null, error: 'Multiple files; apply manually' };
return { file: files[0], error: null };
}
/** True when the reviewed SHA is still the PR head (prefix match, same as validateVerdict). */
function isFresh(reviewedSha, headSha) {
return Boolean(reviewedSha && headSha && String(headSha).startsWith(reviewedSha));
}
function parseArgs(argv) {
const value = (flag, fallback = null) => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : fallback);
const pr = value('--pr') ? Number(value('--pr')) : NaN;
return {
pr,
sender: value('--sender'),
commentId: value('--comment-id') ? Number(value('--comment-id')) : null,
dryRun: argv.includes('--dry-run')
};
}
/** Post a comment; never throws (fail-closed reporting must not crash on the report itself). */
async function say(number, body, deps) {
try {
await deps.postComment(number, body);
} catch (err) {
console.error(`comment failed: ${err.message}`);
}
}
async function git(args, { cwd = ROOT } = {}) {
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 });
return String(stdout || '').trim();
}
/**
* Commit the suggested entry to the PR head branch (same-repo only).
* Uses execFile array args throughout; file content never touches a shell command line.
*/
async function commitToBranch({ file, content, headRef, sender }, deps) {
const abs = path.join(ROOT, file);
const resolved = path.resolve(abs);
if (!resolved.startsWith(path.resolve(ROOT) + path.sep)) throw new Error(`refusing to write outside the repo: ${file}`);
if (!DATA_FILE_RE.test(file)) throw new Error(`refusing to write non-catalog file: ${file}`);
await deps.fetchHead(headRef);
await deps.checkoutHead(headRef);
deps.writeFile(resolved, content.endsWith('\n') ? content : `${content}\n`);
await deps.stageAndCommit(file, `Apply AI suggested entry (/apply-ai-fix by @${sender})`);
await deps.pushHead(headRef);
}
const defaultDeps = {
getPull: async number => rest(`/repos/${REPO}/pulls/${number}`),
getPermission: async sender => rest(`/repos/${REPO}/collaborators/${encodeURIComponent(sender)}/permission`),
listComments,
listPrFiles: async pull => pullChanges(pull),
postComment: async (number, body) => comment(number, body),
fetchHead: async headRef => { await git(['fetch', 'origin', headRef]); },
checkoutHead: async headRef => { await git(['checkout', headRef]); },
writeFile: (abs, content) => fs.writeFileSync(abs, content, 'utf8'),
stageAndCommit: async (file, message) => {
await git(['add', '--', file]);
await git(['-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com', 'commit', '-m', message, '--', file]);
},
pushHead: async headRef => { await git(['push', 'origin', `HEAD:${headRef}`]); },
runValidate: async () => { await execFileAsync('node', ['scripts/validate.js'], { cwd: ROOT, maxBuffer: 8 * 1024 * 1024 }); }
};
/**
* Run the apply flow. Returns an exit code (0 = handled, 1 = infrastructure error).
* User-facing rejections post an explanatory comment and return 0 so the workflow
* stays green; only unexpected failures return 1.
*/
async function applyAiFix({ pr, sender, dryRun = false }, deps = defaultDeps) {
if (!Number.isInteger(pr) || pr <= 0) throw new Error('apply-ai-fix needs --pr <n>');
if (!sender || typeof sender !== 'string') throw new Error('apply-ai-fix needs --sender <login>');
const { status: pullStatus, data: pull } = await deps.getPull(pr);
if (pullStatus !== 200 || !pull) {
console.error(`fetch PR #${pr} failed: HTTP ${pullStatus}`);
return 1;
}
if (pull.state !== 'open') {
console.log(`#${pr} is ${pull.state}; nothing to apply`);
return 0;
}
const permRes = await deps.getPermission(sender);
const permission = permRes && permRes.data && permRes.data.permission;
if (permRes.status !== 200 || (permission !== 'admin' && permission !== 'write')) {
await say(pr, `Only maintainers with write access can use \`${APPLY_COMMAND}\` (sender @${safe(sender)}).`, deps);
console.log(`sender ${sender} has permission ${permission || permRes.status}; rejected`);
return 0;
}
const comments = await deps.listComments(pr);
const aiComment = findAiComment(comments);
if (!aiComment) {
await say(pr, `No AI review comment (\`${MARKER}\`) found on this PR; nothing to apply.`, deps);
return 0;
}
const reviewed = extractReviewedSha(aiComment.body);
if (!isFresh(reviewed, pull.head.sha)) {
await say(pr, 'Suggested entry is stale (PR has new commits); waiting for fresh AI review.', deps);
return 0;
}
const suggested = extractSuggestedYaml(aiComment.body);
if (!suggested) {
await say(pr, 'No suggested entry found in the latest AI review comment; nothing to apply.', deps);
return 0;
}
const headRepo = (pull.head.repo && pull.head.repo.full_name) || REPO;
if (headRepo !== REPO) {
await say(pr, 'Fork PRs are not auto-updated; please apply manually.', deps);
return 0;
}
const files = await deps.listPrFiles(pull);
const paths = files.map(f => (typeof f === 'string' ? f : f.path));
const { file, error } = targetFile(paths);
if (!file) {
await say(pr, `${safe(error)}; please apply manually.`, deps);
return 0;
}
const category = extractCategoryHint(aiComment.body) || categoryFromPath(file);
const check = checkSuggestedYaml(suggested, category);
if (!check.ok) {
await say(pr, `Suggested entry does not validate, so it was not applied:\n\n- ${safe(check.problems.join('\n- '))}`, deps);
return 0;
}
let data;
try {
data = yaml.load(suggested);
} catch (err) {
await say(pr, `Suggested entry is not valid YAML, so it was not applied: ${safe(err.message)}`, deps);
return 0;
}
const serialized = yaml.dump(data, { lineWidth: 1000 }).trim() + '\n';
if (dryRun) {
console.log(`would apply suggestion to ${file} on ${pull.head.ref} (PR #${pr})`);
return 0;
}
try {
await commitToBranch({ file, content: serialized, headRef: pull.head.ref, sender }, deps);
} catch (err) {
console.error(`apply failed: ${err.message}`);
await say(pr, `Could not apply the suggested entry (${safe(err.message).slice(0, 200)}); please apply manually.`, deps);
return 1;
}
try {
await deps.runValidate();
} catch (err) {
console.error(`validate after apply failed: ${err.message}`);
await say(pr, `Applied the suggested entry to \`${safe(file)}\`, but \`npm run validate\` still reports problems; please review the check output.`, deps);
return 1;
}
await say(pr, `Applied the AI suggested entry to \`${safe(file)}\` (commit on \`${safe(pull.head.ref)}\`); \`validate\` re-runs on the new commit.`, deps);
console.log(`applied suggestion to ${file} on PR #${pr}`);
return 0;
}
async function main(argv = process.argv.slice(2), deps = defaultDeps) {
const opts = parseArgs(argv);
return applyAiFix(opts, deps);
}
if (require.main === module) {
main().catch(err => {
console.error(err);
process.exit(1);
}).then(code => { process.exit(code); });
}
module.exports = {
APPLY_COMMAND,
applyAiFix,
categoryFromPath,
extractCategoryHint,
extractReviewedSha,
extractSuggestedYaml,
findAiComment,
isFresh,
isTriggerComment,
parseArgs,
targetFile
};
+3
View File
@@ -203,6 +203,9 @@ function renderComment(verdict, item, { model } = {}) {
'</details>',
''
);
if (verdict.verdict === 'fix' && check.ok) {
lines.push('To apply this entry automatically, comment `/apply-ai-fix` on this PR (maintainers only).', '');
}
}
lines.push(`<sub>Confidence ${verdict.confidence}/100 · reviewed commit \`${item.head_sha.slice(0, 7)}\`${model ? ` · ${safe(model)}` : ''} · advisory only: a maintainer decides. [How this works](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md#ai-review)</sub>`);
return lines.join('\n');
+190
View File
@@ -0,0 +1,190 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { MARKER, renderComment } = require('../scripts/utils/ai-verdict');
const {
applyAiFix,
extractCategoryHint,
extractReviewedSha,
extractSuggestedYaml,
findAiComment,
isFresh,
isTriggerComment,
parseArgs,
targetFile
} = require('../scripts/apply-ai-fix');
const HEAD = 'abcdef0123456789abcdef0123456789abcdef01';
const prItem = { type: 'pr', number: 42, head_sha: HEAD };
const GOOD_YAML = 'name: Thing\nrepo: https://github.com/a/thing\ntagline: Does a thing\ndescription: Does a thing well.\nkind: tool\ntopics:\n - sessions\n';
const fixVerdict = (over = {}) => ({
type: 'pr', number: 42, head_sha: HEAD, verdict: 'fix', category: 'projects', kind: 'tool',
topics: ['sessions'], reasons: ['Standalone CLI, so it belongs in projects.'],
policy: { opencode_relevant: true, crypto: false, duplicate_of: null },
suggested_yaml: GOOD_YAML, confidence: 80, ...over
});
const aiBody = (yamlText = GOOD_YAML, sha = HEAD) =>
`${MARKER}\n<!-- sha=${sha} -->\n### AI review: fix\n\n**Suggested placement:** category \`projects\` · kind \`tool\`\n\n` +
`<details><summary>Suggested entry (passes the catalog schema)</summary>\n\n\`\`\`yaml\n${yamlText}\`\`\`\n</details>\n`;
test('renderComment fix with valid suggestion includes the apply hint', () => {
const body = renderComment(fixVerdict(), prItem);
assert.match(body, /passes the catalog schema/);
assert.match(body, /\/apply-ai-fix/);
assert.match(body, /comment `\/apply-ai-fix` on this PR \(maintainers only\)/);
});
test('renderComment omits the hint for approve and for invalid suggestions', () => {
const approve = renderComment({ ...fixVerdict(), verdict: 'approve', suggested_yaml: null }, prItem);
assert.doesNotMatch(approve, /\/apply-ai-fix/);
const bad = renderComment(fixVerdict({ suggested_yaml: 'name: [' }), prItem);
assert.match(bad, /does not validate/);
assert.doesNotMatch(bad, /\/apply-ai-fix/);
});
test('isTriggerComment only fires on the exact command string', () => {
assert.equal(isTriggerComment('/apply-ai-fix please'), true);
assert.equal(isTriggerComment('comment body without the command'), false);
assert.equal(isTriggerComment(null), false);
});
test('findAiComment picks the latest marker comment and ignores others', () => {
assert.equal(findAiComment([{ body: 'unrelated' }]), null);
assert.equal(findAiComment([]), null);
const comments = [{ body: `${MARKER}\nold` }, { body: 'unrelated' }, { body: `${MARKER}\nnew` }];
assert.equal(findAiComment(comments).body, `${MARKER}\nnew`);
});
test('extractors handle missing sha, yaml and category', () => {
assert.equal(extractReviewedSha(aiBody()), HEAD);
assert.equal(extractReviewedSha('no marker here'), null);
assert.equal(extractSuggestedYaml(aiBody()).includes('name: Thing'), true);
assert.equal(extractSuggestedYaml(`${MARKER}\nno yaml here`), null);
assert.equal(extractCategoryHint(aiBody()), 'projects');
assert.equal(extractCategoryHint('no hint'), null);
assert.equal(isFresh(HEAD, HEAD), true);
assert.equal(isFresh(HEAD.slice(0, 7), HEAD), true);
assert.equal(isFresh('1234567', HEAD), false);
assert.equal(isFresh(null, HEAD), false);
});
test('targetFile requires exactly one catalog file', () => {
assert.deepEqual(targetFile([]), { file: null, error: 'No catalog file in this PR' });
assert.deepEqual(targetFile(['README.md']), { file: null, error: 'No catalog file in this PR' });
assert.deepEqual(targetFile(['data/plugins/a.yaml']), { file: 'data/plugins/a.yaml', error: null });
assert.deepEqual(targetFile(['data/plugins/a.yaml', 'data/themes/b.yaml']), { file: null, error: 'Multiple files; apply manually' });
});
test('parseArgs reads the CLI flags', () => {
assert.deepEqual(parseArgs(['--pr', '42', '--sender', 'octo', '--comment-id', '7']), { pr: 42, sender: 'octo', commentId: 7, dryRun: false });
assert.equal(parseArgs(['--pr', '42', '--sender', 'octo', '--dry-run']).dryRun, true);
});
function stubDeps(over = {}) {
const posted = [];
const gitCalls = [];
const written = {};
return {
posted,
gitCalls,
written,
deps: {
getPull: async () => ({
status: 200,
data: { number: 42, state: 'open', head: { sha: HEAD, ref: 'add-thing', repo: { full_name: 'Heretek-AI/awesome-opencode' } }, base: { sha: 'base' } }
}),
getPermission: async () => ({ status: 200, data: { permission: 'write' } }),
listComments: async () => [{ body: aiBody() }],
listPrFiles: async () => [{ path: 'data/projects/thing.yaml', status: 'added' }],
postComment: async (number, body) => { posted.push({ number, body }); return {}; },
fetchHead: async ref => { gitCalls.push(['fetch', ref]); },
checkoutHead: async ref => { gitCalls.push(['checkout', ref]); },
writeFile: (abs, content) => { written[abs] = content; },
stageAndCommit: async (file, message) => { gitCalls.push(['commit', file, message]); },
pushHead: async ref => { gitCalls.push(['push', ref]); },
runValidate: async () => {},
...over
}
};
}
test('applyAiFix rejects senders without write access', async () => {
const { posted, gitCalls, deps } = stubDeps({ getPermission: async () => ({ status: 200, data: { permission: 'read' } }) });
assert.equal(await applyAiFix({ pr: 42, sender: 'outsider' }, deps), 0);
assert.match(posted[0].body, /Only maintainers with write access/);
assert.deepEqual(gitCalls, []);
});
test('applyAiFix rejects unknown senders (permission 404)', async () => {
const { posted, deps } = stubDeps({ getPermission: async () => ({ status: 404, data: { message: 'Not Found' } }) });
assert.equal(await applyAiFix({ pr: 42, sender: 'ghost' }, deps), 0);
assert.match(posted[0].body, /Only maintainers with write access/);
});
test('applyAiFix reports a missing AI comment', async () => {
const { posted, deps } = stubDeps({ listComments: async () => [{ body: 'hello' }] });
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0);
assert.match(posted[0].body, /No AI review comment/);
});
test('applyAiFix rejects stale suggestions', async () => {
const { posted, gitCalls, deps } = stubDeps({ listComments: async () => [{ body: aiBody(GOOD_YAML, '1234567890abcdef1234567890abcdef12345678') }] });
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0);
assert.match(posted[0].body, /stale/);
assert.deepEqual(gitCalls, []);
});
test('applyAiFix rejects fork PRs without touching git', async () => {
const { posted, gitCalls, deps } = stubDeps({
getPull: async () => ({
status: 200,
data: { number: 42, state: 'open', head: { sha: HEAD, ref: 'add-thing', repo: { full_name: 'someone/awesome-opencode' } }, base: { sha: 'base' } }
})
});
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0);
assert.match(posted[0].body, /Fork PRs are not auto-updated/);
assert.deepEqual(gitCalls, []);
});
test('applyAiFix rejects zero and multiple catalog files', async () => {
const zero = stubDeps({ listPrFiles: async () => [{ path: 'README.md', status: 'modified' }] });
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, zero.deps), 0);
assert.match(zero.posted[0].body, /No catalog file/);
const multi = stubDeps({ listPrFiles: async () => [{ path: 'data/plugins/a.yaml' }, { path: 'data/themes/b.yaml' }] });
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, multi.deps), 0);
assert.match(multi.posted[0].body, /Multiple files/);
});
test('applyAiFix rejects invalid suggestions without committing', async () => {
const { posted, gitCalls, deps } = stubDeps({ listComments: async () => [{ body: aiBody('name: [') }] });
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0);
assert.match(posted[0].body, /does not validate/);
assert.deepEqual(gitCalls, []);
});
test('applyAiFix does nothing on closed PRs', async () => {
const { posted, deps } = stubDeps({
getPull: async () => ({
status: 200,
data: { number: 42, state: 'closed', head: { sha: HEAD, ref: 'add-thing', repo: { full_name: 'Heretek-AI/awesome-opencode' } }, base: { sha: 'base' } }
})
});
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, deps), 0);
assert.deepEqual(posted, []);
});
test('applyAiFix dry-run never touches git; full run commits and comments success', async () => {
const dry = stubDeps();
assert.equal(await applyAiFix({ pr: 42, sender: 'octo', dryRun: true }, dry.deps), 0);
assert.deepEqual(dry.gitCalls, []);
assert.deepEqual(dry.posted, []);
const full = stubDeps();
assert.equal(await applyAiFix({ pr: 42, sender: 'octo' }, full.deps), 0);
assert.ok(full.gitCalls.length >= 4);
assert.match(full.posted[0].body, /Applied the AI suggested entry/);
const paths = Object.keys(full.written);
assert.equal(paths.length, 1);
assert.match(paths[0], /data.projects.thing\.yaml$/);
assert.match(full.written[paths[0]], /name: Thing/);
});