mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Three gaps found while auditing the agent skill. `codeman skill install` / `uninstall` had no tests at all, including the linked-case resolution that shipped in 1.14.2 with nothing guarding it. Covered now: global target resolution, `--case` resolving through linked-cases.json, `--case` falling back to the cases dir for an unlinked name, a missing or malformed registry degrading to the fallback instead of throwing, and a nonexistent case being rejected. `resolveSkillTarget` called `process.exit(1)` for a missing case, which would have killed the test runner, so the pure resolution is split out and exported; CLI behavior is unchanged. The `POST /api/sessions` injection call site was never exercised, because the shared route mock hardcoded the gate off. The mock's gate is overridable per test now (default still off, since other tests rely on that), and there is coverage that the path injects when the setting is on, does not when it is off, and is claude-mode gated. Nothing guarded skills/codeman/reference/endpoints.md against drifting from the routes it documents, which is how it drifted in the first place. A static guard parses the endpoints out of the markdown and asserts each is really registered, tolerating the /api/v1 alias and path params. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
89 lines
3.7 KiB
TypeScript
89 lines
3.7 KiB
TypeScript
/**
|
|
* @fileoverview Static guard: every endpoint the packaged agent skill documents
|
|
* still exists in the routes it is documenting.
|
|
*
|
|
* `skills/codeman/reference/endpoints.md` is injected into cases and read by agents
|
|
* driving Codeman over HTTP. Nothing tied it to the server, so renaming or dropping a
|
|
* route left the skill confidently telling agents to call a 404. This parses the
|
|
* `METHOD /api/...` pairs out of the doc and matches them against the `app.<method>()`
|
|
* registrations in src/web/routes/*.ts.
|
|
*
|
|
* Precision over recall on purpose: only a bare uppercase verb followed by an
|
|
* `/api/...` path counts, so prose that merely mentions a path (the `.../sessions/null`
|
|
* jq-pitfall example) is ignored, and a spuriously failing guard does not get deleted
|
|
* by the next person. `/api/v1` is a URL-rewrite alias (server.ts), so the version
|
|
* segment is dropped before matching, and param NAMES are normalized away since the
|
|
* doc's `:id` need not match a route's `:sessionId`.
|
|
*
|
|
* Port: N/A (pure static analysis).
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { join } from 'node:path';
|
|
|
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
const DOC_PATH = join(HERE, '../skills/codeman/reference/endpoints.md');
|
|
const ROUTES_DIR = join(HERE, '../src/web/routes');
|
|
|
|
/** `METHOD /api/<path>`, stopping before a query string, backtick or prose. */
|
|
const DOC_ENDPOINT = /\b(GET|POST|PUT|PATCH|DELETE)\s+\/(api\/[A-Za-z0-9_:/-]+)/g;
|
|
/** `app.get('/api/…'`, where the path may sit on its own line (case-routes.ts, file-routes.ts). */
|
|
const ROUTE_REGISTRATION = /app\.(get|post|put|patch|delete)\(\s*'([^']+)'/g;
|
|
|
|
/**
|
|
* Strip the `/api/v1` alias and replace param names with a placeholder, so
|
|
* `GET /api/v1/sessions/:id` and `app.get('/api/sessions/:sessionId')` compare equal.
|
|
*/
|
|
function normalize(method: string, path: string): string {
|
|
const withoutVersion = path.replace(/^\/api\/v1\//, '/api/');
|
|
const params = withoutVersion.replace(/\/:[^/]+/g, '/:p').replace(/\/$/, '');
|
|
return `${method.toUpperCase()} ${params}`;
|
|
}
|
|
|
|
function documentedEndpoints(): string[] {
|
|
const markdown = readFileSync(DOC_PATH, 'utf-8');
|
|
const found = new Set<string>();
|
|
for (const match of markdown.matchAll(DOC_ENDPOINT)) {
|
|
found.add(normalize(match[1], `/${match[2]}`));
|
|
}
|
|
return [...found].sort();
|
|
}
|
|
|
|
function registeredRoutes(): Set<string> {
|
|
const registered = new Set<string>();
|
|
for (const file of readdirSync(ROUTES_DIR)) {
|
|
if (!file.endsWith('.ts')) continue;
|
|
const source = readFileSync(join(ROUTES_DIR, file), 'utf-8');
|
|
for (const match of source.matchAll(ROUTE_REGISTRATION)) {
|
|
if (!match[2].startsWith('/api/')) continue;
|
|
registered.add(normalize(match[1], match[2]));
|
|
}
|
|
}
|
|
return registered;
|
|
}
|
|
|
|
describe('skills/codeman/reference/endpoints.md', () => {
|
|
it('parses a plausible number of endpoints out of the doc', () => {
|
|
// A parser that silently matches nothing would make the real assertion below
|
|
// pass vacuously forever.
|
|
const documented = documentedEndpoints();
|
|
expect(documented.length).toBeGreaterThanOrEqual(10);
|
|
expect(documented).toContain('POST /api/quick-start');
|
|
expect(documented).toContain('GET /api/sessions/:p/wait');
|
|
});
|
|
|
|
it('finds the route registrations it matches against', () => {
|
|
const registered = registeredRoutes();
|
|
expect(registered.size).toBeGreaterThan(100);
|
|
expect(registered.has('GET /api/status')).toBe(true);
|
|
});
|
|
|
|
it('documents only endpoints that are actually registered', () => {
|
|
const registered = registeredRoutes();
|
|
const missing = documentedEndpoints().filter((endpoint) => !registered.has(endpoint));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|