Skip to content
Open
Changes from all commits
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
310 changes: 310 additions & 0 deletions tests/unit/help-documentation-sync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
/**
* 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';
import { fileURLToPath } from 'url';

// Resolve paths from the project root (fileURLToPath handles percent-encoding and Windows drive letters)
const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..', '..'
);
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.

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 the displayHelp function body.
// We find the opening brace and then brace-match to the closing brace
// so that later name: properties in ui.js are not accidentally included.
const helpFnStart = fileContent.indexOf('function displayHelp()');
if (helpFnStart === -1) {
throw new Error('Could not find displayHelp() in ui.js');
}

const openBrace = fileContent.indexOf('{', helpFnStart);
if (openBrace === -1) {
throw new Error('Could not find opening brace for displayHelp()');
}

// Brace-match to find the end of the function body
let depth = 0;
let closeBrace = -1;
for (let i = openBrace; i < fileContent.length; i++) {
if (fileContent[i] === '{') depth++;
else if (fileContent[i] === '}') depth--;
if (depth === 0) {
closeBrace = i;
break;
}
}

const helpContent = closeBrace !== -1
? fileContent.slice(openBrace, closeBrace + 1)
: fileContent.slice(openBrace);

// NOTE: We intentionally extract only the base command name (first word).
// Entries like 'tags add' -> 'tags' and 'models --setup' -> 'models'.
// This test validates command *presence* in help, not subcommand/flag drift.
const nameRegex = /name:\s*'([^']+)'/g;
const commands = new Set();
let match;
while ((match = nameRegex.exec(helpContent)) !== null) {
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');
// Log for CI readability before the assertion throws
console.error(message);
}

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

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([]);
});
});