Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions tests/unit/help-documentation-sync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/**
* help-documentation-sync.test.js
*
* Ensures that help documentation in scripts/modules/ui.js (displayHelp())
* stays in sync with the actual command implementations.
*
* Sources of truth for commands:
* 1. Legacy commands: scripts/modules/commands.js — .command('name') registrations
* 2. New CLI commands: apps/cli/src/command-registry.ts — CommandRegistry entries
*
* Help documentation:
* scripts/modules/ui.js — displayHelp() commandCategories
*
* Related: https://github.com/eyaltoledano/claude-task-master/issues/1594
*/

import fs from 'fs';
import path from 'path';

// Resolve paths from the project root
const projectRoot = path.resolve(
new URL('../../', import.meta.url).pathname
);
Comment on lines +20 to +25
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

projectRoot is derived from new URL(...).pathname, which can be percent-encoded (spaces, unicode) and is a known source of Windows path issues (leading /C:/...). Other tests in this repo use fileURLToPath(import.meta.url) (or global.projectRoot from tests/setup.js) to compute paths; switching to that pattern will make this test more reliable across environments.

Suggested change
// Resolve paths from the project root
const projectRoot = path.resolve(
new URL('../../', import.meta.url).pathname
);
import { fileURLToPath } from 'url';
// Resolve paths from the project root
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '../..');

Copilot uses AI. Check for mistakes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const COMMANDS_JS_PATH = path.join(
projectRoot,
'scripts/modules/commands.js'
);
const COMMAND_REGISTRY_TS_PATH = path.join(
projectRoot,
'apps/cli/src/command-registry.ts'
);
const UI_JS_PATH = path.join(projectRoot, 'scripts/modules/ui.js');

/**
* Commands intentionally excluded from the help text.
* Each entry requires a reason so the exclusion is auditable.
*/
const INTENTIONALLY_EXCLUDED_FROM_HELP = {
// --- Meta / internal commands ---
help: 'The help command itself — it displays the help, does not need a help entry',
tui: 'Terminal UI mode — experimental/internal, not documented in help',

// --- Deprecated legacy commands replaced by `tags` subcommands ---
'add-tag': 'Deprecated — replaced by `tags add`',
'delete-tag': 'Deprecated — replaced by `tags remove`',
'use-tag': 'Deprecated — replaced by `tags use`',
'rename-tag': 'Deprecated — replaced by `tags rename`',
'copy-tag': 'Deprecated — replaced by `tags copy`',

// --- Commands that are currently undocumented in help (tracked for future addition) ---
'scope-up': 'Advanced command — not yet added to help documentation',
'scope-down': 'Advanced command — not yet added to help documentation',
lang: 'Language configuration — not yet added to help documentation',
move: 'Task move command — not yet added to help documentation',
rules: 'Rules management — not yet added to help documentation',
migrate: 'Migration command — not yet added to help documentation',

// --- New CLI-only commands not yet in legacy help ---
start: 'New CLI command — not yet added to legacy help documentation',
export: 'Hamster export command — not yet added to legacy help documentation',
'export-tag': 'Hamster tag export — not yet added to legacy help documentation',
autopilot: 'AI agent orchestration — not yet added to legacy help documentation',
loop: 'Claude Code loop mode — not yet added to legacy help documentation',
auth: 'Authentication command — not yet added to legacy help documentation',
login: 'Login alias — not yet added to legacy help documentation',
logout: 'Logout alias — not yet added to legacy help documentation',
context: 'Workspace context — not yet added to legacy help documentation',
briefs: 'Briefs management — not yet added to legacy help documentation'
};

/**
* Extract all .command('name') registrations from the legacy commands.js file.
* Ignores argument definitions like 'rules [action] [profiles...]' — only takes the base name.
*/
function extractLegacyCommands(fileContent) {
const commandRegex = /\.command\(\s*'([^']+)'\s*\)/g;
const commands = new Set();
let match;
while ((match = commandRegex.exec(fileContent)) !== null) {
// Extract the base command name (first word before any space/arguments)
const baseName = match[1].split(/\s+/)[0];
commands.add(baseName);
}
return commands;
}

/**
* Extract command names from the CommandRegistry in command-registry.ts.
* Matches entries like: name: 'list',
*/
function extractCliRegistryCommands(fileContent) {
// Match name fields inside the commands array
const nameRegex = /name:\s*'([^']+)'/g;
const commands = new Set();
let match;
while ((match = nameRegex.exec(fileContent)) !== null) {
commands.add(match[1]);
}
return commands;
}

/**
* Extract base command names from displayHelp() in ui.js.
* Handles entries like 'models --setup' by taking only the base name 'models'.
* Handles entries like 'tags add' by taking only 'tags'.
*/
function extractHelpCommands(fileContent) {
// Only look at the content within displayHelp function
const helpFnStart = fileContent.indexOf('function displayHelp()');
if (helpFnStart === -1) {
throw new Error('Could not find displayHelp() in ui.js');
}

const helpContent = fileContent.slice(helpFnStart);

const nameRegex = /name:\s*'([^']+)'/g;
const commands = new Set();
let match;
while ((match = nameRegex.exec(helpContent)) !== null) {
// Take the base command name (first word)
const baseName = match[1].split(/\s+/)[0];
commands.add(baseName);
}
return commands;
Comment on lines +78 to +150
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This only validates top-level command names, not help/option drift.

Lines 121-123 collapse entries like tags add and models --setup down to tags and models, and the assertions only compare Set<string> membership. That means subcommand/flag changes can slip through green, which misses the stated acceptance criterion that meaningful help/option changes should fail.

Please compare normalized command signatures instead of bare names, e.g. { command, subcommand, flags } from the registry/legacy definitions against the rendered help entries.

Also applies to: 165-189

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/help-documentation-sync.test.js` around lines 76 - 125, The
current helpers (extractLegacyCommands, extractCliRegistryCommands,
extractHelpCommands) only return base command names and must instead return
normalized command signatures including subcommands and flags so tests detect
help/option drift; update each function to parse the full string token (e.g.
split on whitespace, collect the first token as command, the second as optional
subcommand, and any tokens starting with '-' as flags), normalize into a
deterministic signature (for example "command|subcommand|--flag1,--flag2" or a
JSON-like string) and return a Set of those signatures; then update assertions
to compare these normalized-signature Sets rather than bare names so changes to
subcommands or flags in CommandRegistry, legacy .command(...) calls, or
displayHelp() entries will fail the test.

}

/**
* Combine legacy and CLI registry commands into a single unified set.
*/
function getAllRegisteredCommands(legacyCommands, cliRegistryCommands) {
const all = new Set([...legacyCommands, ...cliRegistryCommands]);
return all;
}

describe('Help Documentation Sync', () => {
let legacyCommands;
let cliRegistryCommands;
let helpCommands;
let allRegisteredCommands;

beforeAll(() => {
const commandsContent = fs.readFileSync(COMMANDS_JS_PATH, 'utf-8');
const registryContent = fs.readFileSync(
COMMAND_REGISTRY_TS_PATH,
'utf-8'
);
const uiContent = fs.readFileSync(UI_JS_PATH, 'utf-8');

legacyCommands = extractLegacyCommands(commandsContent);
cliRegistryCommands = extractCliRegistryCommands(registryContent);
helpCommands = extractHelpCommands(uiContent);
allRegisteredCommands = getAllRegisteredCommands(
legacyCommands,
cliRegistryCommands
);
});

it('should parse commands from all sources', () => {
expect(legacyCommands.size).toBeGreaterThan(0);
expect(cliRegistryCommands.size).toBeGreaterThan(0);
expect(helpCommands.size).toBeGreaterThan(0);
});

it('should have a help entry for every registered command (or an explicit exclusion)', () => {
const missingFromHelp = [];

for (const cmd of allRegisteredCommands) {
if (!helpCommands.has(cmd) && !INTENTIONALLY_EXCLUDED_FROM_HELP[cmd]) {
missingFromHelp.push(cmd);
}
}

if (missingFromHelp.length > 0) {
const message = [
'The following commands are registered but have NO entry in displayHelp() and',
'are NOT in the INTENTIONALLY_EXCLUDED_FROM_HELP allow-list:',
'',
...missingFromHelp.map((c) => ` - ${c}`),
'',
'To fix: either add a help entry in scripts/modules/ui.js displayHelp(),',
'or add the command to INTENTIONALLY_EXCLUDED_FROM_HELP in this test with a reason.'
].join('\n');
expect(missingFromHelp).toEqual([]); // will fail with a nice diff
// Also log for CI readability
console.error(message);
}

expect(missingFromHelp).toEqual([]);
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.error(message) comes after expect(missingFromHelp).toEqual([]), so the log will never print because the assertion throws first. This block also asserts the same expectation twice (line 184 and 189). Log first and fail once (or throw with message) to keep CI output readable.

Copilot uses AI. Check for mistakes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('should not have help entries for commands that do not exist', () => {
const extraInHelp = [];

for (const cmd of helpCommands) {
if (!allRegisteredCommands.has(cmd)) {
extraInHelp.push(cmd);
}
}

if (extraInHelp.length > 0) {
const message = [
'The following commands appear in displayHelp() but are NOT registered',
'in either commands.js or command-registry.ts:',
'',
...extraInHelp.map((c) => ` - ${c}`),
'',
'To fix: either register the command, or remove the stale help entry.'
].join('\n');
// Also log for CI readability
console.error(message);
}

expect(extraInHelp).toEqual([]);
});

it('should have a documented reason for every intentional exclusion', () => {
for (const [cmd, reason] of Object.entries(
INTENTIONALLY_EXCLUDED_FROM_HELP
)) {
expect(reason).toBeTruthy();
expect(typeof reason).toBe('string');
expect(reason.length).toBeGreaterThan(5);
}
});

it('should not have stale entries in the exclusion list', () => {
const staleExclusions = [];

for (const cmd of Object.keys(INTENTIONALLY_EXCLUDED_FROM_HELP)) {
// If the command is now in help AND still in the exclusion list, that's stale
if (helpCommands.has(cmd) && allRegisteredCommands.has(cmd)) {
staleExclusions.push(cmd);
}
// If the command doesn't exist anywhere anymore, the exclusion is orphaned
if (!allRegisteredCommands.has(cmd) && !helpCommands.has(cmd)) {
staleExclusions.push(`${cmd} (orphaned — command no longer exists)`);
}
}

if (staleExclusions.length > 0) {
console.error(
'Stale entries in INTENTIONALLY_EXCLUDED_FROM_HELP:\n' +
staleExclusions.map((c) => ` - ${c}`).join('\n') +
'\nRemove these entries since they are no longer needed.'
);
}

expect(staleExclusions).toEqual([]);
});

it('should detect when a new command file is added to apps/cli/src/commands/ without registry entry', () => {
// Get all .command.ts files in the commands directory
const commandsDir = path.join(
projectRoot,
'apps/cli/src/commands'
);
const commandFiles = fs
.readdirSync(commandsDir)
.filter(
(f) =>
f.endsWith('.command.ts') &&
!f.endsWith('.spec.ts') &&
!f.endsWith('.test.ts')
)
.map((f) => f.replace('.command.ts', ''));

const registryNames = [...cliRegistryCommands];

const unregisteredFiles = commandFiles.filter(
(name) => !registryNames.includes(name)
);

if (unregisteredFiles.length > 0) {
console.error(
'Command files exist without registry entries:\n' +
unregisteredFiles
.map((c) => ` - apps/cli/src/commands/${c}.command.ts`)
.join('\n') +
'\nAdd these to apps/cli/src/command-registry.ts'
);
}

expect(unregisteredFiles).toEqual([]);
});
});
Loading