Stabilize AI template source-of-truth testing and VS Code resilience

Agent-Logs-Url: https://github.com/evdboom/Bindery/sessions/87484cb7-798a-4fd0-91b5-e75ed43e4213

Co-authored-by: evdboom <18037882+evdboom@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-07 22:06:37 +00:00 committed by GitHub
parent 1e2eb941f9
commit eafdeba4da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 547 additions and 1 deletions

51
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: CI
on:
push:
branches: ['**']
pull_request:
branches: ['**']
permissions:
contents: read
jobs:
test:
name: Test & template-sync check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
# ── MCP server ────────────────────────────────────────────────────────────
- name: Install mcp-ts dependencies
run: cd mcp-ts && npm ci
- name: Compile mcp-ts
run: cd mcp-ts && npm run compile
- name: Run mcp-ts tests
run: cd mcp-ts && npm test
# ── Template sync ─────────────────────────────────────────────────────────
# ai-setup-templates.ts is .gitignored — always generated fresh here.
# The copy-parity test in mcp-ts/test/templates-parity.test.ts then confirms
# the synced file is byte-for-byte identical to the source.
- name: Sync templates to vscode-ext
run: |
cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts
echo "Template copy synced."
# ── VS Code extension ─────────────────────────────────────────────────────
- name: Install vscode-ext dependencies
run: cd vscode-ext && npm ci
- name: Compile vscode-ext
run: cd vscode-ext && npm run compile
- name: Run vscode-ext tests
run: cd vscode-ext && npm test

View file

@ -100,3 +100,47 @@ The VS Code extension works standalone — no server setup needed for typography
## License
MIT — see [LICENSE](LICENSE).
## Contributing — template source of truth
The AI instruction file templates are maintained in **one place only**:
```
mcp-ts/src/templates.ts ← SINGLE SOURCE OF TRUTH
```
`vscode-ext/src/ai-setup-templates.ts` is a generated copy and **must never be edited directly**.
CI syncs it automatically before every publish step.
### Syncing locally
After changing `mcp-ts/src/templates.ts`, copy it to the VS Code extension:
```bash
cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts
```
### Running tests
```bash
# MCP server (includes template contract tests + copy-parity check)
cd mcp-ts && npm test
# VS Code extension
cd vscode-ext && npm test
```
The copy-parity test (`mcp-ts/test/templates-parity.test.ts`) skips gracefully when
`ai-setup-templates.ts` is absent (normal in fresh checkouts) and **fails** when it exists
but differs from the source — this is what CI catches.
### What CI does
The CI workflow (`.github/workflows/ci.yml`) runs on every push and pull request:
1. Builds and tests the MCP server.
2. Copies `mcp-ts/src/templates.ts``vscode-ext/src/ai-setup-templates.ts`.
3. Verifies the copy is identical to the source (fails with a clear remediation message if not).
4. Builds and tests the VS Code extension.
If CI fails on the sync check, fix it by running the copy command above and committing the result.

View file

@ -0,0 +1,42 @@
/**
* Copy-parity test: vscode-ext/src/ai-setup-templates.ts must be an exact copy
* of mcp-ts/src/templates.ts (the single source of truth).
*
* - If the copy is absent test is skipped with an actionable message.
* - If the copy is present but differs test fails (CI will always have it synced).
*
* To sync locally:
* cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts
*/
import * as fs from 'fs';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const SOURCE_FILE = path.join(REPO_ROOT, 'mcp-ts', 'src', 'templates.ts');
const COPY_FILE = path.join(REPO_ROOT, 'vscode-ext', 'src', 'ai-setup-templates.ts');
describe('templates copy-parity', () => {
it('vscode-ext/src/ai-setup-templates.ts is an exact copy of mcp-ts/src/templates.ts', () => {
if (!fs.existsSync(COPY_FILE)) {
// Skip gracefully in local dev when the copy has not been generated yet.
// In CI the sync step runs before tests, so this branch should never be hit there.
console.warn(
'\n[SKIP] vscode-ext/src/ai-setup-templates.ts is missing.\n' +
'Run the sync step to generate it:\n' +
' cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts\n',
);
return;
}
const source = fs.readFileSync(SOURCE_FILE, 'utf-8');
const copy = fs.readFileSync(COPY_FILE, 'utf-8');
expect(copy, [
'vscode-ext/src/ai-setup-templates.ts has drifted from mcp-ts/src/templates.ts.',
'Re-sync it with:',
' cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts',
].join('\n')).toBe(source);
});
});

View file

@ -0,0 +1,364 @@
import { describe, expect, it } from 'vitest';
import { renderTemplate, type TemplateContext } from '../src/templates';
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeCtx(overrides: Partial<TemplateContext> = {}): TemplateContext {
return {
title: 'Test Book',
author: 'Jane Doe',
description: 'A tale of adventure.',
genre: 'Fantasy',
audience: 'middle-grade',
storyFolder: 'Story',
notesFolder: 'Notes',
arcFolder: 'Arc',
memoriesFolder: '.bindery/memories',
languages: [{ code: 'EN', folderName: 'EN' }],
langList: 'EN (source)',
hasMultiLang: false,
...overrides,
};
}
function makeMultiLangCtx(): TemplateContext {
return makeCtx({
languages: [{ code: 'EN', folderName: 'EN' }, { code: 'NL', folderName: 'NL' }],
langList: 'EN (source), NL (translation)',
hasMultiLang: true,
});
}
function makeMinimalCtx(): TemplateContext {
return {
title: 'Untitled',
author: '',
description: '',
genre: '',
audience: '',
storyFolder: 'Story',
notesFolder: 'Notes',
arcFolder: 'Arc',
memoriesFolder: '.bindery/memories',
languages: [],
langList: 'EN (source)',
hasMultiLang: false,
};
}
// ─── Top-level targets ────────────────────────────────────────────────────────
describe('renderTemplate — claude', () => {
it('contains required sections', () => {
const result = renderTemplate('claude', makeCtx());
expect(result).toContain('# Claude — Test Book');
expect(result).toContain('## Project');
expect(result).toContain('## Start of session');
expect(result).toContain('## Memory system');
expect(result).toContain('## Repo layout');
expect(result).toContain('## Writing rules');
expect(result).toContain('## Available skills');
expect(result).toContain('## MCP server (bindery-mcp)');
});
it('includes genre, description, audience, and author', () => {
const result = renderTemplate('claude', makeCtx());
expect(result).toContain('Fantasy');
expect(result).toContain('A tale of adventure.');
expect(result).toContain('middle-grade');
expect(result).toContain('Jane Doe');
});
it('omits optional fields when empty', () => {
const result = renderTemplate('claude', makeMinimalCtx());
expect(result).not.toContain('Genre:');
expect(result).not.toContain('Author:');
expect(result).not.toContain('Target audience:');
});
it('includes language section for multi-language projects', () => {
const result = renderTemplate('claude', makeMultiLangCtx());
expect(result).toContain('Languages:');
expect(result).toContain('EN (source)');
expect(result).toContain('NL (translation)');
});
it('includes story and arc folder paths', () => {
const result = renderTemplate('claude', makeCtx());
expect(result).toContain('Arc/');
expect(result).toContain('Notes/');
expect(result).toContain('Story/');
});
it('includes audience-specific writing rule when audience is set', () => {
const result = renderTemplate('claude', makeCtx());
expect(result).toContain('middle-grade');
});
it('ends with a newline', () => {
expect(renderTemplate('claude', makeCtx())).toMatch(/\n$/);
});
});
describe('renderTemplate — copilot', () => {
it('contains required sections', () => {
const result = renderTemplate('copilot', makeCtx());
expect(result).toContain('# GitHub Copilot — Test Book');
expect(result).toContain('## Repo layout');
expect(result).toContain('## Writing guidelines');
});
it('includes genre and description when set', () => {
const result = renderTemplate('copilot', makeCtx());
expect(result).toContain('Fantasy');
expect(result).toContain('A tale of adventure.');
});
it('omits project section entirely when all optional fields are empty', () => {
const result = renderTemplate('copilot', makeMinimalCtx());
expect(result).not.toContain('## Project');
});
it('includes audience writing guideline when audience is set', () => {
const result = renderTemplate('copilot', makeCtx());
expect(result).toContain('middle-grade');
});
it('ends with a newline', () => {
expect(renderTemplate('copilot', makeCtx())).toMatch(/\n$/);
});
});
describe('renderTemplate — cursor', () => {
it('contains required sections', () => {
const result = renderTemplate('cursor', makeCtx());
expect(result).toContain('# Cursor rules — Test Book');
expect(result).toContain('## Context files to read');
expect(result).toContain('## Rules');
});
it('references memory and arc folder paths', () => {
const result = renderTemplate('cursor', makeCtx());
expect(result).toContain('.bindery/memories');
expect(result).toContain('Arc/');
});
it('includes audience rule when audience is set', () => {
const result = renderTemplate('cursor', makeCtx());
expect(result).toContain('middle-grade');
});
it('ends with a newline', () => {
expect(renderTemplate('cursor', makeCtx())).toMatch(/\n$/);
});
});
describe('renderTemplate — agents', () => {
it('contains required sections', () => {
const result = renderTemplate('agents', makeCtx());
expect(result).toContain('# Agent Instructions — Test Book');
expect(result).toContain('## Project overview');
expect(result).toContain('## Start of session');
expect(result).toContain('## Story files');
expect(result).toContain('## Writing guidelines');
expect(result).toContain('## Key reference files');
});
it('includes genre, description, audience, and author', () => {
const result = renderTemplate('agents', makeCtx());
expect(result).toContain('Fantasy');
expect(result).toContain('A tale of adventure.');
expect(result).toContain('middle-grade');
expect(result).toContain('Jane Doe');
});
it('references memories and arc folders', () => {
const result = renderTemplate('agents', makeCtx());
expect(result).toContain('.bindery/memories');
expect(result).toContain('Arc/');
});
it('ends with a newline', () => {
expect(renderTemplate('agents', makeCtx())).toMatch(/\n$/);
});
});
// ─── Skill templates ──────────────────────────────────────────────────────────
describe('renderTemplate — review skill', () => {
it('contains YAML front-matter, title, trigger, and steps', () => {
const result = renderTemplate('review', makeCtx());
expect(result).toContain('name: Review');
expect(result).toContain('# Skill: /review');
expect(result).toContain('## Trigger');
expect(result).toContain('## Steps');
expect(result).toContain('## Tools');
expect(result).toContain('## Rules');
});
it('includes the book title in description', () => {
const result = renderTemplate('review', makeCtx());
expect(result).toContain('"Test Book"');
});
it('references the arc and memories folder', () => {
const result = renderTemplate('review', makeCtx());
expect(result).toContain('.bindery/memories');
expect(result).toContain('Arc/');
});
it('includes audience when set', () => {
const result = renderTemplate('review', makeCtx());
expect(result).toContain('middle-grade');
});
it('uses fallback audience string when empty', () => {
const result = renderTemplate('review', makeMinimalCtx());
expect(result).toContain('the target audience');
});
});
describe('renderTemplate — brainstorm skill', () => {
it('contains YAML front-matter, title, trigger, and steps', () => {
const result = renderTemplate('brainstorm', makeCtx());
expect(result).toContain('name: Brainstorm');
expect(result).toContain('# Skill: /brainstorm');
expect(result).toContain('## Trigger');
expect(result).toContain('## Steps');
expect(result).toContain('## Tools');
expect(result).toContain('## Rules');
});
it('includes the book title', () => {
const result = renderTemplate('brainstorm', makeCtx());
expect(result).toContain('"Test Book"');
});
});
describe('renderTemplate — memory skill', () => {
it('contains YAML front-matter, title, trigger, tools, and steps', () => {
const result = renderTemplate('memory', makeCtx());
expect(result).toContain('name: Memory');
expect(result).toContain('# Skill: /memory');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
expect(result).toContain('## Rules');
});
it('references memory_append and memory_compact tools', () => {
const result = renderTemplate('memory', makeCtx());
expect(result).toContain('memory_append');
expect(result).toContain('memory_compact');
expect(result).toContain('memory_list');
});
});
describe('renderTemplate — translate skill', () => {
it('contains YAML front-matter, title, trigger, tools, and steps', () => {
const result = renderTemplate('translate', makeCtx());
expect(result).toContain('name: Translate');
expect(result).toContain('# Skill: /translate');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
expect(result).toContain('## Rules');
});
it('references get_translation and add_translation tools', () => {
const result = renderTemplate('translate', makeCtx());
expect(result).toContain('get_translation');
expect(result).toContain('add_translation');
});
});
describe('renderTemplate — status skill', () => {
it('contains YAML front-matter, title, trigger, tools, and steps', () => {
const result = renderTemplate('status', makeCtx());
expect(result).toContain('name: Status');
expect(result).toContain('# Skill: /status');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
});
it('references chapter_status_get and get_overview tools', () => {
const result = renderTemplate('status', makeCtx());
expect(result).toContain('chapter_status_get');
expect(result).toContain('get_overview');
});
});
describe('renderTemplate — continuity skill', () => {
it('contains YAML front-matter, title, trigger, tools, steps, and output format', () => {
const result = renderTemplate('continuity', makeCtx());
expect(result).toContain('name: Continuity');
expect(result).toContain('# Skill: /continuity');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
expect(result).toContain('## Output format');
expect(result).toContain('## Rules');
});
it('references retrieve_context and get_notes tools', () => {
const result = renderTemplate('continuity', makeCtx());
expect(result).toContain('retrieve_context');
expect(result).toContain('get_notes');
});
});
describe('renderTemplate — read_aloud skill', () => {
it('contains YAML front-matter, title, trigger, tools, and rules', () => {
const result = renderTemplate('read_aloud', makeCtx());
expect(result).toContain('name: Read Aloud');
expect(result).toContain('# Skill: /read-aloud');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Rules');
});
it('includes audience in output description', () => {
const result = renderTemplate('read_aloud', makeCtx());
expect(result).toContain('middle-grade');
});
it('uses fallback when audience is empty', () => {
const result = renderTemplate('read_aloud', makeMinimalCtx());
expect(result).toContain('the target audience');
});
});
describe('renderTemplate — read_in skill', () => {
it('contains YAML front-matter, title, trigger, tools, steps, and rules', () => {
const result = renderTemplate('read_in', makeCtx());
expect(result).toContain('name: Read-in');
expect(result).toContain('# Skill: /read-in');
expect(result).toContain('## Trigger');
expect(result).toContain('## Tools');
expect(result).toContain('## Steps');
expect(result).toContain('## Rules');
});
it('references memory_list, chapter_status_get, and get_overview tools', () => {
const result = renderTemplate('read_in', makeCtx());
expect(result).toContain('memory_list');
expect(result).toContain('chapter_status_get');
expect(result).toContain('get_overview');
});
it('references the memories folder', () => {
const result = renderTemplate('read_in', makeCtx());
expect(result).toContain('.bindery/memories');
});
});
// ─── Unknown template ─────────────────────────────────────────────────────────
describe('renderTemplate — unknown', () => {
it('returns an "Unknown template" message for unrecognised names', () => {
const result = renderTemplate('nonexistent', makeCtx());
expect(result).toContain('Unknown template: nonexistent');
});
});

View file

@ -19,7 +19,52 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { WorkspaceSettings } from './workspace';
import type { LanguageConfig } from './merge';
import { renderTemplate } from './ai-setup-templates';
// ─── Resilient template loader ────────────────────────────────────────────────
// ai-setup-templates.ts is a copy of mcp-ts/src/templates.ts generated by CI.
// In local dev and test runs the copy may be absent. We try the copy first and
// fall back to the source file so tests and local builds don't hard-fail with an
// opaque module-not-found error.
// Forward-reference is fine in TypeScript — TemplateContext is defined below.
// eslint-disable-next-line @typescript-eslint/no-use-before-define
type RenderTemplateFn = (name: string, ctx: TemplateContext) => string;
function loadRenderTemplate(): RenderTemplateFn {
// 1. Preferred: the in-tree copy (always present after CI sync / vsce publish)
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('./ai-setup-templates') as { renderTemplate: RenderTemplateFn };
return mod.renderTemplate;
} catch {
// copy not present — fall through to fallback
}
// 2. Fallback: load from the MCP source — only useful inside the mono-repo
// (e.g. `vscode-ext npm test` when ai-setup-templates.ts hasn't been synced yet).
// __dirname is `vscode-ext/out/` in compiled output and `vscode-ext/src/` in ts-node/vitest,
// so two levels up always lands at the repo root where mcp-ts/ lives.
// This path will NOT exist inside a published .vsix (that's fine — the copy is always
// present there after the CI sync step).
const fallbackPath = path.resolve(__dirname, '..', '..', 'mcp-ts', 'src', 'templates');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require(fallbackPath) as { renderTemplate: RenderTemplateFn };
return mod.renderTemplate;
} catch {
// source also missing — give a clear, actionable error
}
throw new Error(
'Bindery: template file not found.\n' +
'Run the sync step to generate vscode-ext/src/ai-setup-templates.ts:\n' +
' cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts\n' +
'Or build the MCP server first:\n' +
' cd mcp-ts && npm run compile',
);
}
const renderTemplate = loadRenderTemplate();
// ─── Public types ─────────────────────────────────────────────────────────────