Repository navigation
feat(doctor): warn on legacy frontmatter drift (wish dir-sync-frontmatter-refresh group 6) #1199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /** | ||
| * Tests for `checkLegacyAgentFrontmatter` — Group 6 of wish | ||
| * `dir-sync-frontmatter-refresh`. | ||
| * | ||
| * The check walks every agent directory under `agents/` and warns when an | ||
| * AGENTS.md starts with a `---` fence while an `agent.yaml` is also | ||
| * present — the configuration-in-wrong-file scenario that sync silently | ||
| * ignores post-migration. | ||
| */ | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; | ||
| import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { checkLegacyAgentFrontmatter } from './doctor.js'; | ||
|
|
||
| let workspaceRoot: string; | ||
|
|
||
| beforeEach(() => { | ||
| workspaceRoot = mkdtempSync(join(tmpdir(), 'doctor-legacy-fm-')); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| try { | ||
| rmSync(workspaceRoot, { recursive: true, force: true }); | ||
| } catch { | ||
| /* ignore */ | ||
| } | ||
| }); | ||
|
|
||
| function seedAgent(name: string, files: { agentYaml?: string; agentsMd?: string }): void { | ||
| const agentDir = join(workspaceRoot, 'agents', name); | ||
| mkdirSync(agentDir, { recursive: true }); | ||
| if (files.agentYaml !== undefined) writeFileSync(join(agentDir, 'agent.yaml'), files.agentYaml); | ||
| if (files.agentsMd !== undefined) writeFileSync(join(agentDir, 'AGENTS.md'), files.agentsMd); | ||
| } | ||
|
|
||
| describe('checkLegacyAgentFrontmatter', () => { | ||
| test('warns when AGENTS.md has frontmatter AND agent.yaml exists', async () => { | ||
| seedAgent('drifted', { | ||
| agentYaml: 'promptMode: append\nmodel: opus\n', | ||
| agentsMd: '---\nmodel: sonnet\n---\n\n# body', | ||
| }); | ||
|
|
||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| expect(results).toHaveLength(1); | ||
| expect(results[0].status).toBe('warn'); | ||
| expect(results[0].name).toBe('agents/drifted/AGENTS.md'); | ||
| expect(results[0].message).toMatch(/legacy frontmatter/i); | ||
| expect(results[0].suggestion).toMatch(/agent\.yaml/); | ||
| }); | ||
|
|
||
| test('silent (pass) when agent.yaml absent — pre-migration state', async () => { | ||
| seedAgent('pre-migration', { | ||
| agentsMd: '---\nmodel: sonnet\n---\n\n# body', | ||
| }); | ||
|
|
||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| expect(results).toHaveLength(1); | ||
| expect(results[0].status).toBe('pass'); | ||
| expect(results[0].name).toMatch(/No legacy frontmatter/); | ||
| }); | ||
|
|
||
| test('silent (pass) when AGENTS.md has no fence — clean post-migration', async () => { | ||
| seedAgent('clean', { | ||
| agentYaml: 'promptMode: append\n', | ||
| agentsMd: '# Agent: clean\n\nBody content.', | ||
| }); | ||
|
|
||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| expect(results).toHaveLength(1); | ||
| expect(results[0].status).toBe('pass'); | ||
| }); | ||
|
|
||
| test('silent when agents/ directory does not exist', async () => { | ||
| // No seeding — workspace has no agents/ dir. | ||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| expect(results).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('flags multiple agents independently', async () => { | ||
| seedAgent('drifted-one', { | ||
| agentYaml: 'promptMode: append\n', | ||
| agentsMd: '---\nmodel: opus\n---\n# body', | ||
| }); | ||
| seedAgent('clean', { | ||
| agentYaml: 'promptMode: append\n', | ||
| agentsMd: '# Agent: clean\n\nBody.', | ||
| }); | ||
| seedAgent('drifted-two', { | ||
| agentYaml: 'promptMode: append\n', | ||
| agentsMd: '---\ncolor: red\n---\n# body', | ||
| }); | ||
|
|
||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| // 2 warnings + 0 pass fillers (pass filler only emitted when zero warnings) | ||
| expect(results).toHaveLength(2); | ||
| const names = results.map((r) => r.name).sort(); | ||
| expect(names).toEqual(['agents/drifted-one/AGENTS.md', 'agents/drifted-two/AGENTS.md']); | ||
| }); | ||
|
|
||
| test('ignores non-directory entries in agents/', async () => { | ||
| // Seed one drifted agent so we get a warning to assert. | ||
| seedAgent('real', { | ||
| agentYaml: 'promptMode: append\n', | ||
| agentsMd: '---\nmodel: opus\n---\n', | ||
| }); | ||
| // Drop a file inside agents/ — should be skipped, not crash. | ||
| writeFileSync(join(workspaceRoot, 'agents', 'README'), 'not an agent'); | ||
|
|
||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| expect(results).toHaveLength(1); | ||
| expect(results[0].name).toBe('agents/real/AGENTS.md'); | ||
| }); | ||
|
|
||
| test('skips agent dirs that only have agent.yaml (no AGENTS.md)', async () => { | ||
| seedAgent('yaml-only', { agentYaml: 'promptMode: append\n' }); | ||
|
|
||
| const results = checkLegacyAgentFrontmatter(workspaceRoot); | ||
| expect(results).toHaveLength(1); | ||
| expect(results[0].status).toBe('pass'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |||||||||||||||||||||||||||||||||
| * Checks prerequisites, configuration, and tmux connectivity. | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import { existsSync, unlinkSync } from 'node:fs'; | ||||||||||||||||||||||||||||||||||
| import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'; | ||||||||||||||||||||||||||||||||||
| import { homedir } from 'node:os'; | ||||||||||||||||||||||||||||||||||
| import { join } from 'node:path'; | ||||||||||||||||||||||||||||||||||
| import { $ } from 'bun'; | ||||||||||||||||||||||||||||||||||
|
|
@@ -14,6 +14,7 @@ import { contractClaudePath, getClaudeSettingsPath } from '../lib/claude-setting | |||||||||||||||||||||||||||||||||
| import { tmuxBin } from '../lib/ensure-tmux.js'; | ||||||||||||||||||||||||||||||||||
| import { genieConfigExists, getGenieConfigPath, isSetupComplete, loadGenieConfig } from '../lib/genie-config.js'; | ||||||||||||||||||||||||||||||||||
| import { checkCommand } from '../lib/system-detect.js'; | ||||||||||||||||||||||||||||||||||
| import { findWorkspace } from '../lib/workspace.js'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| interface CheckResult { | ||||||||||||||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||||||||||||||
|
|
@@ -395,6 +396,73 @@ async function checkBridge(): Promise<CheckResult[]> { | |||||||||||||||||||||||||||||||||
| return results; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * Check that no agent has leftover `---` frontmatter in its `AGENTS.md` | ||||||||||||||||||||||||||||||||||
| * when `agent.yaml` is also present. Wish `dir-sync-frontmatter-refresh` | ||||||||||||||||||||||||||||||||||
| * (Group 6) eliminates frontmatter entirely post-migration — if a user | ||||||||||||||||||||||||||||||||||
| * pastes config back into AGENTS.md, sync silently ignores it, which is | ||||||||||||||||||||||||||||||||||
| * confusing. This check surfaces the drift loudly. | ||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||
| * Exported for unit tests and for callers that want to run this check | ||||||||||||||||||||||||||||||||||
| * outside `genie doctor` (e.g. a pre-sync lint). | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| export function checkLegacyAgentFrontmatter(workspaceRoot?: string): CheckResult[] { | ||||||||||||||||||||||||||||||||||
| const results: CheckResult[] = []; | ||||||||||||||||||||||||||||||||||
| const root = workspaceRoot ?? findWorkspace()?.root; | ||||||||||||||||||||||||||||||||||
| if (!root) return []; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const agentsDir = join(root, 'agents'); | ||||||||||||||||||||||||||||||||||
| if (!existsSync(agentsDir)) return []; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| let entries: string[]; | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| entries = readdirSync(agentsDir); | ||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for (const name of entries) { | ||||||||||||||||||||||||||||||||||
| const agentDir = join(agentsDir, name); | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| if (!statSync(agentDir).isDirectory()) continue; | ||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+417
to
+430
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can optimize this loop by using let entries;
try {
entries = readdirSync(agentsDir, { withFileTypes: true });
} catch {
return [];
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
const agentDir = join(agentsDir, name); |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const yamlPath = join(agentDir, 'agent.yaml'); | ||||||||||||||||||||||||||||||||||
| const agentsMdPath = join(agentDir, 'AGENTS.md'); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Only flag when both files exist AND AGENTS.md starts with a fence. | ||||||||||||||||||||||||||||||||||
| // Pre-migration state (no yaml, frontmatter present) is NOT a warning — | ||||||||||||||||||||||||||||||||||
| // sync will migrate it on the next run. | ||||||||||||||||||||||||||||||||||
| if (!existsSync(yamlPath) || !existsSync(agentsMdPath)) continue; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| let headContent: string; | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| // Read only the first handful of bytes to cheaply detect the fence. | ||||||||||||||||||||||||||||||||||
| headContent = readFileSync(agentsMdPath, 'utf-8').slice(0, 4); | ||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (!headContent.startsWith('---')) continue; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+440
to
+448
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While using a hardcoded limit (magic number) to cap I/O is acceptable in non-critical logic,
Suggested change
References
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This check flags any Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| results.push({ | ||||||||||||||||||||||||||||||||||
| name: `agents/${name}/AGENTS.md`, | ||||||||||||||||||||||||||||||||||
| status: 'warn', | ||||||||||||||||||||||||||||||||||
| message: 'legacy frontmatter detected (ignored by sync)', | ||||||||||||||||||||||||||||||||||
| suggestion: `Move config into agents/${name}/agent.yaml — AGENTS.md is prompt-only post-migration.`, | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Single positive result when nothing drifted, so the section prints a | ||||||||||||||||||||||||||||||||||
| // clean "✓" row. | ||||||||||||||||||||||||||||||||||
| if (results.length === 0) { | ||||||||||||||||||||||||||||||||||
| results.push({ name: 'No legacy frontmatter in agents/*/AGENTS.md', status: 'pass' }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| return results; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * Main doctor command | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
|
|
@@ -424,6 +492,7 @@ export async function doctorCommand(options?: { fix?: boolean }): Promise<void> | |||||||||||||||||||||||||||||||||
| runCheckSection('Tmux', await checkTmux(), counts); | ||||||||||||||||||||||||||||||||||
| runCheckSection('Worker Profiles', await checkWorkerProfiles(), counts); | ||||||||||||||||||||||||||||||||||
| runCheckSection('Omni Bridge', await checkBridge(), counts); | ||||||||||||||||||||||||||||||||||
| runCheckSection('Agent Config', checkLegacyAgentFrontmatter(), counts); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Summary | ||||||||||||||||||||||||||||||||||
| console.log(); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
statSyncis only used withincheckLegacyAgentFrontmatterto check if an entry is a directory, and we can optimize that usingreaddirSync({ withFileTypes: true }), this import can be removed.