From 80791d03741b3955c3ddfa901f0ed2e0fc558ff9 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 2 Jun 2026 22:42:49 +0400 Subject: [PATCH 1/4] feat: add one-command trial flow Closes #470 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- docs/tutorials/getting-started.md | 23 +- src/cli/main.ts | 22 ++ src/cli/parser.ts | 40 ++++ src/infrastructure/try-command.ts | 257 ++++++++++++++++++++++ tests/unit/cli.test.ts | 41 ++++ tests/unit/getting-started-docs.test.ts | 5 + tests/unit/try-command.test.ts | 278 ++++++++++++++++++++++++ tests/unit/why-madar-doc.test.ts | 4 +- 9 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 src/infrastructure/try-command.ts create mode 100644 tests/unit/try-command.test.ts diff --git a/README.md b/README.md index c3baf90a..78a27bc3 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,13 @@ Capability/scope summary only. See the [claims-and-evidence map](https://github. ## Quickstart -Start with the generated graph. `madar generate` creates the local graph artifact; `madar summary`, `madar pack`, `madar prompt`, and `madar handoff` can use that graph without any agent install. Run `madar install` only when you want Madar wired into an agent through MCP or local instruction files. +Start with a one-command local proof. `madar try` builds or reuses `out/graph.json`, prints one human-readable explain-pack result, and recommends the next install command without requiring MCP first. When you want more control, drop down to `madar generate`, `madar summary`, `madar pack`, `madar prompt`, and `madar handoff` directly. ```bash npm install -g @lubab/madar cd your-project +madar try "how does auth work?" # one-command local proof before agent install madar generate . # builds out/graph.json, no API key, no cloud madar summary # bounded repo overview before deeper retrieval madar claude install # wires Claude Code to use Madar via MCP diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index deb4e642..36e85b61 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -10,7 +10,15 @@ npm install -g @lubab/madar If you are working from this repository instead of a published npm install, run `npm run build` from the repository root first so the local CLI is up to date. -## 2. Generate a graph for the sample workspace +## 2. Start with the one-command trial flow + +```bash +madar try "how does password reset request enqueue the reset email" examples/sample-workspace +``` + +This builds or reuses `examples/sample-workspace/out/graph.json`, prints one human-readable local explanation, and ends with the next recommended install command without requiring Claude/Cursor/Codex/Copilot setup first. + +## 3. Generate a graph for the sample workspace manually ```bash madar generate examples/sample-workspace --no-html @@ -24,7 +32,7 @@ If your real repo is framework-heavy TypeScript/JavaScript and you care about ri madar generate examples/sample-workspace --spi --no-html ``` -## 3. Install one agent profile +## 4. Install one agent profile Move into the sample workspace before installing so the generated graph, agent config, and verification commands all point at the same project: @@ -40,7 +48,7 @@ madar claude install If you want a different runtime, use the same step with `madar codex install`, `madar cursor install`, `madar copilot install`, `madar gemini install`, `madar aider install`, or `madar opencode install`. -## 4. Verify the install before asking bigger questions +## 5. Verify the install before asking bigger questions ```bash madar doctor out/graph.json @@ -49,7 +57,7 @@ madar status out/graph.json For Claude, Cursor, Gemini, and Copilot, `doctor` checks graph freshness plus the install wiring, and `status` gives you the compact readiness summary plus the next recommended commands. `doctor`/`status` also report Codex, Aider, and OpenCode when their AGENTS/hook/plugin/MCP signals are present; if any of those drift, the agent is marked `partial` with a reinstall suggestion. -## 5. Start with a bounded summary +## 6. Start with a bounded summary ```bash madar summary out/graph.json @@ -57,7 +65,7 @@ madar summary out/graph.json This prints the deterministic high-signal overview first: graph counts, source domains, top modules, frameworks, entrypoints, and runtime paths. It is the fastest way to decide whether you need a deeper `pack`, `prompt`, or MCP retrieval call. -## 6. Build a compact pack +## 7. Build a compact pack ```bash madar pack "how does password reset request enqueue the reset email" \ @@ -67,7 +75,7 @@ madar pack "how does password reset request enqueue the reset email" \ This is the fastest way to confirm the route → service → job flow is represented in the graph. On runtime-generation questions like this one, newer reports can also preserve an `execution_slice` so you can inspect ordered steps without reading the whole raw slice. Treat it as a static runtime-path hypothesis from the graph, not a live trace. The nested `phase_coverage` is also static and prompt-scoped, so broader report-generation questions may show planner/research/report-builder/scoring/renderer/persistence phases when the graph supports them. -## 7. Compile a provider-ready prompt +## 8. Compile a provider-ready prompt ```bash madar prompt "where is reset token persisted before the email job runs" \ @@ -77,7 +85,7 @@ madar prompt "where is reset token persisted before the email job runs" \ `prompt` only compiles the prompt payload. It does **not** call Claude or spend paid model tokens by itself. -## 8. Run a safe compare smoke check +## 9. Run a safe compare smoke check If you want to exercise `compare` without calling a paid model, use a local echo-style runner: @@ -93,6 +101,7 @@ This does **not** measure model quality. It is a safe local smoke check that pro ## Expected output +- `try` should print one human-readable local result plus a recommended install command - `generate` should write `examples/sample-workspace/out/graph.json` - `claude install` should register the local Madar integration for the sample workspace - `doctor` should confirm the graph path plus install wiring for Claude, Cursor, Gemini, or Copilot diff --git a/src/cli/main.ts b/src/cli/main.ts index 0589f695..d2753338 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -11,6 +11,7 @@ import { runContextPromptCommand } from '../infrastructure/context-prompt-comman import { runDoctorCommand, runStatusCommand } from '../infrastructure/doctor.js' import { runProofReportCommand, type ProofReportResult } from '../infrastructure/proof-report.js' import { runReviewCompareCommand } from '../infrastructure/review-compare.js' +import { runTryCommand } from '../infrastructure/try-command.js' import { saveQueryResult } from '../infrastructure/save-query-result.js' import { compareRefs } from '../infrastructure/time-travel.js' import { federate } from '../pipeline/federate.js' @@ -77,6 +78,8 @@ import { parseServeArgs, parseTelemetryArgs, parseTimeTravelArgs, + parseTryArgs, + type TryCliOptions, type HandoffCliOptions, type PackCliOptions, type ProofReportCliOptions, @@ -130,6 +133,11 @@ export interface ContextPackCommandContext { io: CliIO } +export interface TryCommandContext { + options: TryCliOptions + io: CliIO +} + export interface HandoffCommandContext { options: HandoffCliOptions io: CliIO @@ -155,6 +163,7 @@ export interface CliDependencies { runReviewCompare: (context: ReviewCompareCommandContext) => Promise | string | void runTimeTravel: (context: TimeTravelCommandContext) => Promise | string | void runContextPack: (context: ContextPackCommandContext) => Promise | string | void + runTry: (context: TryCommandContext) => Promise | string | void runHandoff: (context: HandoffCommandContext) => Promise | string | void runContextPrompt: (context: ContextPromptCommandContext) => Promise | string | void runProofReport: (options: ProofReportCliOptions) => ProofReportResult @@ -269,6 +278,9 @@ const DEFAULT_DEPENDENCIES: CliDependencies = { runContextPack: async ({ options }) => { return await runContextPackCommand(options) }, + runTry: async ({ options, io }) => { + return await runTryCommand(options, io) + }, runHandoff: async ({ options }) => { return await runHandoffCommand(options) }, @@ -427,6 +439,7 @@ export function formatHelp(binaryName = 'madar'): string { ' --stdio serve graph query methods over stdio (JSON lines)', ' --mcp alias for --stdio for installer/runtime parity', ' summary [graph.json] print a compact deterministic graph summary as JSON', + ' try "" [path] one-command local first proof before agent install', ' query "" traverse graph.json for a question', ' --dfs use depth-first instead of breadth-first', ' --budget N cap output at N tokens (default 2000)', @@ -790,6 +803,15 @@ export async function executeCli(argv: string[], io: CliIO = console, dependenci return 0 } + if (command === 'try') { + const options = parseTryArgs(args) + const output = await dependencies.runTry({ options, io }) + if (output !== undefined) { + io.log(output) + } + return 0 + } + if (command === 'pack') { const options = parsePackArgs(args) const output = await dependencies.runContextPack({ options, io }) diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 248a5487..8dc9b883 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -41,6 +41,11 @@ export interface PackCliOptions { retrievalStrategy?: ContextPackRetrievalStrategy } +export interface TryCliOptions { + prompt: string + path: string +} + export interface HandoffCliOptions { prompt: string budget: number @@ -618,6 +623,41 @@ export function parsePackArgs(args: string[]): PackCliOptions { } } +export function parseTryArgs(args: string[]): TryCliOptions { + const usage = 'Usage: madar try "" [path]' + const prompt = args[0]?.trim() + if (!prompt) { + throw new UsageError(usage) + } + + let path = '.' + for (let index = 1; index < args.length; index += 1) { + const argument = args[index] + if (!argument) { + continue + } + + if (argument.startsWith('--')) { + throw new UsageError(`error: unknown option for try: ${argument}`) + } + + if (path !== '.') { + throw new UsageError(usage) + } + + path = argument + } + + if (path.length > MAX_CLI_PATH_LENGTH) { + throw new UsageError(`error: path exceeds maximum length of ${MAX_CLI_PATH_LENGTH} characters`) + } + + return { + prompt: validateCliQuestionText('question', prompt), + path, + } +} + export function parseHandoffArgs(args: string[]): HandoffCliOptions { const usage = 'Usage: madar handoff "" [--budget N] [--task KIND] [--graph path] [--consumer generic|codex|cursor|copilot] [--allow-snippets] [--require-fresh-graph] [--require-fresh-context]' const prompt = args[0]?.trim() diff --git a/src/infrastructure/try-command.ts b/src/infrastructure/try-command.ts new file mode 100644 index 00000000..355c505f --- /dev/null +++ b/src/infrastructure/try-command.ts @@ -0,0 +1,257 @@ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import type { TryCliOptions, PackCliOptions } from '../cli/parser.js' +import { runContextPackCommand } from './context-pack-command.js' +import { generateGraph } from './generate.js' +import { defaultInstallPlatform, type InstallPlatform } from './install.js' +import { buildGraphSummary, type GraphSummary } from '../runtime/graph-summary.js' +import { analyzeGraphContextFreshness, graphFreshnessStatusLabel, type GraphContextFreshness } from '../runtime/freshness.js' +import { loadGraph } from '../runtime/serve.js' +import { findPackageRoot } from '../shared/package-metadata.js' + +interface TrialIo { + log(message?: string): void + error(message?: string): void +} + +interface TrialWorkspaceSuccess { + status: 'success' + workspace: string + graphPath: string + packOutput: string + notes: string[] +} + +interface TrialWorkspaceFailure { + status: 'failure' + workspace: string + reason: string + notes: string[] + fallbackEligible: boolean +} + +type TrialWorkspaceResult = TrialWorkspaceSuccess | TrialWorkspaceFailure + +export interface TryCommandDependencies { + generateGraph: typeof generateGraph + runContextPack: (context: { options: PackCliOptions; io: TrialIo }) => Promise | string | void + analyzeFreshness: (graphPath: string) => GraphContextFreshness + summarizeGraph: (graphPath: string) => GraphSummary + resolvePackageRoot: () => string + pathExists: (path: string) => boolean + readNodeMajorVersion: () => number + defaultInstallPlatform: () => InstallPlatform +} + +const DEFAULT_DEPENDENCIES: TryCommandDependencies = { + generateGraph, + runContextPack: async ({ options }) => await runContextPackCommand(options), + analyzeFreshness: (graphPath) => analyzeGraphContextFreshness(graphPath), + summarizeGraph: (graphPath) => buildGraphSummary(loadGraph(graphPath)), + resolvePackageRoot: () => findPackageRoot(), + pathExists: (path) => existsSync(path), + readNodeMajorVersion: () => Number.parseInt(process.versions.node.split('.')[0] ?? '0', 10), + defaultInstallPlatform: () => defaultInstallPlatform(), +} + +const TRY_PACK_BUDGET = 3000 +const MIN_TRIAL_NODES = 10 +const GETTING_STARTED_URL = 'https://github.com/mohanagy/madar/blob/main/docs/tutorials/getting-started.md' + +function trialGraphPath(workspace: string): string { + return join(workspace, 'out', 'graph.json') +} + +function isReusableFreshnessStatus(status: GraphContextFreshness['status']): boolean { + return status === 'fresh' +} + +function tooSmallReason(nodeCount: number): string | null { + if (nodeCount >= MIN_TRIAL_NODES) { + return null + } + + return `Current repo graph is too small for a useful first-run result (${nodeCount} nodes; need at least ${MIN_TRIAL_NODES}).` +} + +function isFallbackEligibleGenerateError(reason: string): boolean { + return reason === 'No supported files were found in the target path.' + || reason.startsWith('No graph nodes could be generated from the detected corpus.') +} + +function recommendInstallPlatform(workspace: string, dependencies: TryCommandDependencies): InstallPlatform { + const hints: Array<{ path: string; platform: InstallPlatform }> = [ + { path: join(workspace, '.cursor'), platform: 'cursor' }, + { path: join(workspace, '.copilot'), platform: 'copilot' }, + { path: join(workspace, '.gemini'), platform: 'gemini' }, + { path: join(workspace, 'GEMINI.md'), platform: 'gemini' }, + { path: join(workspace, '.claude'), platform: 'claude' }, + { path: join(workspace, 'CLAUDE.md'), platform: 'claude' }, + ] + + const hintedPlatform = hints.find((hint) => dependencies.pathExists(hint.path)) + return hintedPlatform?.platform ?? dependencies.defaultInstallPlatform() +} + +function buildPackOptions(prompt: string, graphPath: string): PackCliOptions { + return { + prompt, + budget: TRY_PACK_BUDGET, + task: 'explain', + graphPath, + format: 'text', + } +} + +async function runPackForWorkspace( + workspace: string, + prompt: string, + graphPath: string, + notes: string[], + io: TrialIo, + dependencies: TryCommandDependencies, +): Promise { + const packOutput = await dependencies.runContextPack({ + options: buildPackOptions(prompt, graphPath), + io, + }) + + return { + status: 'success', + workspace, + graphPath, + packOutput: String(packOutput ?? ''), + notes, + } +} + +function formatTrialOutput( + result: TrialWorkspaceSuccess, + installPlatform: InstallPlatform, + primaryWorkspace: string, + fallbackReason?: string, +): string { + const lines = [`[madar try] ${result.workspace === primaryWorkspace ? 'Local proof ready.' : 'Local proof ready from the bundled sample workspace.'}`] + + if (fallbackReason) { + lines.push(`[madar try] ${fallbackReason}`) + lines.push(`[madar try] Falling back to ${result.workspace}.`) + } + + lines.push(...result.notes) + lines.push('') + if (result.packOutput.trim().length > 0) { + lines.push(result.packOutput.trim()) + lines.push('') + } + lines.push('[madar try] Next recommended install:') + lines.push(` madar ${installPlatform} install`) + return lines.join('\n') +} + +async function prepareWorkspace( + workspace: string, + prompt: string, + io: TrialIo, + dependencies: TryCommandDependencies, +): Promise { + const graphPath = trialGraphPath(workspace) + const notes: string[] = [] + + if (dependencies.pathExists(graphPath)) { + try { + const freshness = dependencies.analyzeFreshness(graphPath) + if (isReusableFreshnessStatus(freshness.status)) { + const summary = dependencies.summarizeGraph(graphPath) + const graphTooSmall = tooSmallReason(summary.node_count) + if (graphTooSmall) { + return { + status: 'failure', + workspace, + reason: graphTooSmall, + notes, + fallbackEligible: true, + } + } + + notes.push(`[madar try] Reusing ${graphPath} (${graphFreshnessStatusLabel(freshness.status)}).`) + return await runPackForWorkspace(workspace, prompt, graphPath, notes, io, dependencies) + } + + notes.push(`[madar try] Existing graph is ${graphFreshnessStatusLabel(freshness.status)}; rebuilding ${workspace}.`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + notes.push(`[madar try] Existing graph could not be read (${message}); rebuilding ${workspace}.`) + } + } else { + notes.push(`[madar try] No graph found at ${graphPath}; building one now.`) + } + + try { + const result = dependencies.generateGraph(workspace, { noHtml: true }) + const graphTooSmall = tooSmallReason(result.nodeCount) + if (graphTooSmall) { + return { + status: 'failure', + workspace, + reason: graphTooSmall, + notes, + fallbackEligible: true, + } + } + + notes.push(`[madar try] Built ${result.graphPath} with ${result.nodeCount} nodes for a local first proof.`) + return await runPackForWorkspace(workspace, prompt, result.graphPath, notes, io, dependencies) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + return { + status: 'failure', + workspace, + reason, + notes, + fallbackEligible: isFallbackEligibleGenerateError(reason), + } + } +} + +function sampleWorkspacePath(dependencies: TryCommandDependencies): string { + return resolve(dependencies.resolvePackageRoot(), 'examples', 'sample-workspace') +} + +function fallbackUnavailableMessage(reason: string): string { + return `${reason}\nPackaged sample workspace is not available in this install. Follow the first-run tutorial: ${GETTING_STARTED_URL}` +} + +export async function runTryCommand( + options: TryCliOptions, + io: TrialIo, + dependencies: TryCommandDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const nodeMajorVersion = dependencies.readNodeMajorVersion() + if (!Number.isFinite(nodeMajorVersion) || nodeMajorVersion < 20) { + throw new Error(`madar try requires Node.js 20+; detected Node.js ${nodeMajorVersion}.`) + } + + const primaryWorkspace = resolve(options.path) + const primaryResult = await prepareWorkspace(primaryWorkspace, options.prompt, io, dependencies) + if (primaryResult.status === 'success') { + return formatTrialOutput(primaryResult, recommendInstallPlatform(primaryWorkspace, dependencies), primaryWorkspace) + } + + if (!primaryResult.fallbackEligible) { + throw new Error(primaryResult.reason) + } + + const sampleWorkspace = sampleWorkspacePath(dependencies) + if (sampleWorkspace === primaryWorkspace || !dependencies.pathExists(sampleWorkspace)) { + throw new Error(fallbackUnavailableMessage(primaryResult.reason)) + } + + const sampleResult = await prepareWorkspace(sampleWorkspace, options.prompt, io, dependencies) + if (sampleResult.status === 'failure') { + throw new Error(`${primaryResult.reason}\n${sampleResult.reason}`) + } + + return formatTrialOutput(sampleResult, recommendInstallPlatform(primaryWorkspace, dependencies), primaryWorkspace, primaryResult.reason) +} diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index ceb2a046..cafb989f 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -25,6 +25,7 @@ import { parseServeArgs, parseTelemetryArgs, parseTimeTravelArgs, + parseTryArgs, parseWatchArgs, } from '../../src/cli/parser.js' import { KnowledgeGraph } from '../../src/contracts/graph.js' @@ -137,6 +138,7 @@ function createDependencies(): CliTestDependencies { runReviewCompare: async () => 'review compare command is not implemented yet', runTimeTravel: async () => 'time-travel command is not implemented yet', runContextPack: async () => 'context pack command is not implemented yet', + runTry: async () => 'try command is not implemented yet', runHandoff: async () => 'handoff command is not implemented yet', runContextPrompt: async () => 'context prompt command is not implemented yet', runProofReport: (options) => ({ @@ -1028,6 +1030,22 @@ describe('cli parser', () => { expect(() => parseDoctorArgs(['--wat'], 'status')).toThrow('error: unknown option for status: --wat') }) + it('parses try args with an optional target path', () => { + expect(parseTryArgs(['how does auth work?'])).toEqual({ + prompt: 'how does auth work?', + path: '.', + }) + + expect(parseTryArgs(['how does auth work?', 'examples/sample-workspace'])).toEqual({ + prompt: 'how does auth work?', + path: 'examples/sample-workspace', + }) + + expect(() => parseTryArgs([])).toThrow('Usage: madar try "" [path]') + expect(() => parseTryArgs(['how does auth work?', 'repo', '--wat'])).toThrow('error: unknown option for try: --wat') + expect(() => parseTryArgs(['how does auth work?', 'repo', 'extra'])).toThrow('Usage: madar try "" [path]') + }) + it('parses proof-report args with defaults and overrides', () => { expect(parseProofReportArgs([])).toEqual({ graphPath: 'out/graph.json', @@ -1255,6 +1273,8 @@ describe('cli main', () => { expect(help).toContain('codex ') expect(help).toContain('opencode ') expect(help).toContain('summary [graph.json]') + expect(help).toContain('try "" [path]') + expect(help).toContain('one-command local first proof before agent install') }) it('routes compare through the injected dependency after parsing args', async () => { @@ -2009,6 +2029,27 @@ describe('cli main', () => { expect(errors).toEqual([]) }) + it('routes try through the injected dependency after parsing args', async () => { + const { io, logs, errors } = createIo() + const runTry = vi.fn>().mockResolvedValue('trial result') + const dependencies: CliDependencies = { + ...createDependencies(), + runTry, + } + + await expect(executeCli(['try', 'how does auth work?', 'examples/sample-workspace'], io, dependencies)).resolves.toBe(0) + + expect(runTry).toHaveBeenCalledWith({ + options: { + prompt: 'how does auth work?', + path: 'examples/sample-workspace', + }, + io, + }) + expect(logs).toEqual(['trial result']) + expect(errors).toEqual([]) + }) + it('routes handoff through the injected dependency after parsing args', async () => { const { io, logs, errors } = createIo() const runHandoff = vi.fn>().mockResolvedValue('{"share_safe":true}') diff --git a/tests/unit/getting-started-docs.test.ts b/tests/unit/getting-started-docs.test.ts index b23574f6..4326815e 100644 --- a/tests/unit/getting-started-docs.test.ts +++ b/tests/unit/getting-started-docs.test.ts @@ -8,6 +8,7 @@ describe('getting started tutorial', () => { const tutorial = readFileSync(resolve('docs/tutorials/getting-started.md'), 'utf8') const readme = readFileSync(resolve('README.md'), 'utf8') + expect(tutorial).toContain('madar try') expect(tutorial).toContain('npm install -g @lubab/madar') expect(tutorial).not.toContain('migration') expect(tutorial).toContain('madar generate examples/sample-workspace --no-html') @@ -27,6 +28,8 @@ describe('getting started tutorial', () => { const tutorial = readFileSync(resolve('docs/tutorials/getting-started.md'), 'utf8') const readme = readFileSync(resolve('README.md'), 'utf8') + expect(tutorial.indexOf('madar try')).toBeGreaterThanOrEqual(0) + expect(tutorial.indexOf('madar try')).toBeLessThan(tutorial.indexOf('madar generate examples/sample-workspace --no-html')) expect(tutorial).toContain('cd examples/sample-workspace') expect(tutorial).toContain('madar claude install') expect(tutorial).toContain('madar doctor out/graph.json') @@ -38,6 +41,8 @@ describe('getting started tutorial', () => { expect(tutorial.toLowerCase()).toContain('paid model') expect(tutorial.toLowerCase()).toContain('10-minute') expect(readme).toContain('## Choose your agent') + expect(readme).toContain('madar try') + expect(readme.indexOf('madar try')).toBeLessThan(readme.indexOf('madar generate .')) expect(readme).toContain('Claude Code') expect(readme).toContain('Codex CLI') expect(readme).toContain('Cursor') diff --git a/tests/unit/try-command.test.ts b/tests/unit/try-command.test.ts new file mode 100644 index 00000000..0e43b21f --- /dev/null +++ b/tests/unit/try-command.test.ts @@ -0,0 +1,278 @@ +import { resolve } from 'node:path' + +import { describe, expect, it, vi } from 'vitest' + +import type { GraphContextFreshness, GraphContextFreshnessStatus } from '../../src/runtime/freshness.js' +import type { GraphSummary } from '../../src/runtime/graph-summary.js' +import { runTryCommand, type TryCommandDependencies } from '../../src/infrastructure/try-command.js' + +function createIo() { + const logs: string[] = [] + const errors: string[] = [] + return { + logs, + errors, + io: { + log(message?: string) { + logs.push(String(message ?? '')) + }, + error(message?: string) { + errors.push(String(message ?? '')) + }, + }, + } +} + +function createGraphSummary(nodeCount: number): GraphSummary { + return { + node_count: nodeCount, + edge_count: Math.max(nodeCount - 1, 0), + file_count: Math.max(nodeCount, 1), + community_count: Math.max(Math.min(nodeCount, 3), 1), + source_domains: { production: Math.max(nodeCount, 1) }, + frameworks: [], + top_modules: [], + entrypoints: [], + runtime_paths: [], + } +} + +function createFreshness(status: GraphContextFreshnessStatus, graphPath: string): GraphContextFreshness { + return { + status, + graph_path: graphPath, + graph_version: 'graph-version', + graph_modified_ms: 1, + graph_modified_at: '2026-06-01T00:00:00.000Z', + generated_ms: 1, + generated_at: '2026-06-01T00:00:00.000Z', + madar_version: '0.27.8', + indexed_file_count: 12, + changed_source_count: status === 'fresh' ? 0 : 2, + missing_source_count: 0, + selected_context_status: status === 'fresh' || status === 'partially_stale' ? 'fresh' : 'possibly_stale', + selected_context_file_count: 3, + changed_selected_context_count: status === 'fresh' ? 0 : 1, + missing_selected_context_count: 0, + changed_outside_selected_context_count: status === 'fresh' ? 0 : 1, + recommendation: 'Run `madar generate .`.', + } +} + +function createDependencies(overrides: Partial = {}): TryCommandDependencies { + return { + generateGraph: vi.fn().mockImplementation((rootPath = '.') => ({ + mode: 'generate', + rootPath: resolve(rootPath), + outputDir: resolve(rootPath, 'out'), + graphPath: resolve(rootPath, 'out', 'graph.json'), + reportPath: resolve(rootPath, 'out', 'GRAPH_REPORT.md'), + htmlPath: null, + wikiPath: null, + obsidianPath: null, + svgPath: null, + graphmlPath: null, + cypherPath: null, + docsPath: null, + totalFiles: 12, + codeFiles: 12, + nonCodeFiles: 0, + extractableFiles: 12, + extractedFiles: 12, + totalWords: 1000, + nodeCount: 12, + edgeCount: 11, + communityCount: 3, + changedFiles: 0, + deletedFiles: 0, + cache: null, + warning: null, + notes: [], + })), + runContextPack: vi.fn().mockResolvedValue('text pack'), + analyzeFreshness: vi.fn().mockImplementation((graphPath: string) => createFreshness('fresh', graphPath)), + summarizeGraph: vi.fn().mockImplementation(() => createGraphSummary(12)), + resolvePackageRoot: vi.fn().mockReturnValue('/pkg'), + pathExists: vi.fn().mockReturnValue(false), + readNodeMajorVersion: vi.fn().mockReturnValue(20), + defaultInstallPlatform: vi.fn().mockReturnValue('claude'), + ...overrides, + } +} + +describe('runTryCommand', () => { + it('reuses a fresh graph and forces a text explain pack', async () => { + const workspace = resolve('/tmp/workspace') + const graphPath = resolve(workspace, 'out', 'graph.json') + const { io } = createIo() + const dependencies = createDependencies({ + pathExists: vi.fn().mockImplementation((path: string) => path === graphPath), + analyzeFreshness: vi.fn().mockImplementation((path: string) => createFreshness('fresh', path)), + }) + + const output = await runTryCommand({ prompt: 'how does auth work?', path: workspace }, io, dependencies) + + expect(dependencies.generateGraph).not.toHaveBeenCalled() + expect(dependencies.runContextPack).toHaveBeenCalledWith({ + options: { + prompt: 'how does auth work?', + budget: 3000, + task: 'explain', + graphPath, + format: 'text', + }, + io, + }) + expect(output).toContain('text pack') + expect(output).toContain('madar claude install') + }) + + it.each(['partially_stale', 'possibly_stale', 'stale'] as const)( + 'rebuilds a %s graph before running the pack', + async (freshnessStatus) => { + const workspace = resolve('/tmp/stale-workspace') + const graphPath = resolve(workspace, 'out', 'graph.json') + const { io } = createIo() + const dependencies = createDependencies({ + pathExists: vi.fn().mockImplementation((path: string) => path === graphPath), + analyzeFreshness: vi.fn().mockImplementation((path: string) => createFreshness(freshnessStatus, path)), + }) + + await runTryCommand({ prompt: 'how does auth work?', path: workspace }, io, dependencies) + + expect(dependencies.generateGraph).toHaveBeenCalledWith(workspace, { noHtml: true }) + expect(dependencies.runContextPack).toHaveBeenCalledWith({ + options: { + prompt: 'how does auth work?', + budget: 3000, + task: 'explain', + graphPath, + format: 'text', + }, + io, + }) + }, + ) + + it('falls back to the packaged sample workspace when the current repo has no supported files', async () => { + const workspace = resolve('/tmp/empty-workspace') + const sampleWorkspace = resolve('/pkg/examples/sample-workspace') + const sampleGraphPath = resolve(sampleWorkspace, 'out', 'graph.json') + const { io } = createIo() + const dependencies = createDependencies({ + pathExists: vi.fn().mockImplementation((path: string) => path === sampleWorkspace), + generateGraph: vi.fn().mockImplementation((rootPath = '.') => { + const resolvedRoot = resolve(rootPath) + if (resolvedRoot === workspace) { + throw new Error('No supported files were found in the target path.') + } + return { + mode: 'generate', + rootPath: resolvedRoot, + outputDir: resolve(resolvedRoot, 'out'), + graphPath: resolve(resolvedRoot, 'out', 'graph.json'), + reportPath: resolve(resolvedRoot, 'out', 'GRAPH_REPORT.md'), + htmlPath: null, + wikiPath: null, + obsidianPath: null, + svgPath: null, + graphmlPath: null, + cypherPath: null, + docsPath: null, + totalFiles: 12, + codeFiles: 12, + nonCodeFiles: 0, + extractableFiles: 12, + extractedFiles: 12, + totalWords: 1000, + nodeCount: 12, + edgeCount: 11, + communityCount: 3, + changedFiles: 0, + deletedFiles: 0, + cache: null, + warning: null, + notes: [], + } + }), + }) + + const output = await runTryCommand({ prompt: 'how does auth work?', path: workspace }, io, dependencies) + + expect(dependencies.generateGraph).toHaveBeenNthCalledWith(1, workspace, { noHtml: true }) + expect(dependencies.generateGraph).toHaveBeenNthCalledWith(2, sampleWorkspace, { noHtml: true }) + expect(dependencies.runContextPack).toHaveBeenCalledWith({ + options: { + prompt: 'how does auth work?', + budget: 3000, + task: 'explain', + graphPath: sampleGraphPath, + format: 'text', + }, + io, + }) + expect(output).toContain('No supported files were found in the target path.') + expect(output).toContain('examples/sample-workspace') + }) + + it('falls back when the current repo graph is too small for a useful first proof', async () => { + const workspace = resolve('/tmp/tiny-workspace') + const graphPath = resolve(workspace, 'out', 'graph.json') + const sampleWorkspace = resolve('/pkg/examples/sample-workspace') + const sampleGraphPath = resolve(sampleWorkspace, 'out', 'graph.json') + const { io } = createIo() + const dependencies = createDependencies({ + pathExists: vi.fn().mockImplementation((path: string) => path === graphPath || path === sampleWorkspace), + summarizeGraph: vi + .fn() + .mockImplementation((path: string) => (path === graphPath ? createGraphSummary(4) : createGraphSummary(12))), + }) + + const output = await runTryCommand({ prompt: 'how does auth work?', path: workspace }, io, dependencies) + + expect(dependencies.generateGraph).toHaveBeenCalledWith(sampleWorkspace, { noHtml: true }) + expect(dependencies.runContextPack).toHaveBeenCalledWith({ + options: { + prompt: 'how does auth work?', + budget: 3000, + task: 'explain', + graphPath: sampleGraphPath, + format: 'text', + }, + io, + }) + expect(output).toContain('too small') + expect(output).toContain('examples/sample-workspace') + }) + + it('does not hide generator failures behind the sample fallback', async () => { + const workspace = resolve('/tmp/broken-workspace') + const sampleWorkspace = resolve('/pkg/examples/sample-workspace') + const { io } = createIo() + const dependencies = createDependencies({ + pathExists: vi.fn().mockImplementation((path: string) => path === sampleWorkspace), + generateGraph: vi.fn().mockImplementation(() => { + throw new Error('EACCES: permission denied, scandir /tmp/broken-workspace') + }), + }) + + await expect(runTryCommand({ prompt: 'how does auth work?', path: workspace }, io, dependencies)).rejects.toThrow( + 'EACCES: permission denied', + ) + expect(dependencies.generateGraph).toHaveBeenCalledTimes(1) + expect(dependencies.runContextPack).not.toHaveBeenCalled() + }) + + it('fails early with an explicit Node.js 20+ diagnostic', async () => { + const { io } = createIo() + const dependencies = createDependencies({ + readNodeMajorVersion: vi.fn().mockReturnValue(18), + }) + + await expect(runTryCommand({ prompt: 'how does auth work?', path: '.' }, io, dependencies)).rejects.toThrow( + 'madar try requires Node.js 20+', + ) + expect(dependencies.generateGraph).not.toHaveBeenCalled() + expect(dependencies.runContextPack).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/why-madar-doc.test.ts b/tests/unit/why-madar-doc.test.ts index d47847ff..ea0bc93a 100644 --- a/tests/unit/why-madar-doc.test.ts +++ b/tests/unit/why-madar-doc.test.ts @@ -225,8 +225,10 @@ describe('public marketing copy honesty', () => { const content = readDoc('docs/tutorials/getting-started.md') const lower = content.toLowerCase() - it('starts the walkthrough with generate, summary, and compact retrieval surfaces', () => { + it('starts the walkthrough with the one-command trial flow before the manual generate path', () => { + expect(content).toContain('madar try "how does password reset request enqueue the reset email" examples/sample-workspace') expect(content).toContain('madar generate examples/sample-workspace --no-html') + expect(content.indexOf('madar try')).toBeLessThan(content.indexOf('madar generate examples/sample-workspace --no-html')) expect(content).toContain('cd examples/sample-workspace') expect(content).toContain('madar summary out/graph.json') expect(content).toContain('madar pack') From a47094778fe15ada912958282949f58511136898 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 2 Jun 2026 22:50:27 +0400 Subject: [PATCH 2/4] test: normalize try fallback path assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/unit/try-command.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/try-command.test.ts b/tests/unit/try-command.test.ts index 0e43b21f..06989aa7 100644 --- a/tests/unit/try-command.test.ts +++ b/tests/unit/try-command.test.ts @@ -212,7 +212,7 @@ describe('runTryCommand', () => { io, }) expect(output).toContain('No supported files were found in the target path.') - expect(output).toContain('examples/sample-workspace') + expect(output).toContain('sample-workspace') }) it('falls back when the current repo graph is too small for a useful first proof', async () => { @@ -242,7 +242,7 @@ describe('runTryCommand', () => { io, }) expect(output).toContain('too small') - expect(output).toContain('examples/sample-workspace') + expect(output).toContain('sample-workspace') }) it('does not hide generator failures behind the sample fallback', async () => { From a11c98f806cd5786a0a478453c6874332b5a32bb Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 2 Jun 2026 22:55:04 +0400 Subject: [PATCH 3/4] fix: stabilize try fallback contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/infrastructure/generate.ts | 23 ++++++++++++++++++++-- src/infrastructure/try-command.ts | 9 ++++----- tests/unit/generate.test.ts | 32 ++++++++++++++++++++++++++++++- tests/unit/try-command.test.ts | 3 ++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/infrastructure/generate.ts b/src/infrastructure/generate.ts index 78104f89..b2fa5643 100644 --- a/src/infrastructure/generate.ts +++ b/src/infrastructure/generate.ts @@ -88,6 +88,18 @@ export interface GenerateGraphCacheSummary { fileCount: number } +export type GenerateUnsupportedCorpusCode = 'NO_SUPPORTED_FILES' | 'NO_GRAPH_NODES' + +export class GenerateUnsupportedCorpusError extends Error { + readonly code: GenerateUnsupportedCorpusCode + + constructor(code: GenerateUnsupportedCorpusCode, message: string) { + super(message) + this.name = 'GenerateUnsupportedCorpusError' + this.code = code + } +} + type IncrementalDetectResult = ReturnType function detectOptions(options: GenerateGraphOptions): { followSymlinks?: boolean } { @@ -218,6 +230,13 @@ function missingCodeExtractionMessage(totalFiles: number): string { return 'No graph nodes could be generated from the detected corpus. The current TypeScript extractor supports Python, JavaScript/TypeScript, documents, text-like papers, and image assets, but some detected formats still have shallow coverage.' } +function missingCodeExtractionError(totalFiles: number): GenerateUnsupportedCorpusError { + return new GenerateUnsupportedCorpusError( + totalFiles === 0 ? 'NO_SUPPORTED_FILES' : 'NO_GRAPH_NODES', + missingCodeExtractionMessage(totalFiles), + ) +} + export function loadGraphExtractorVersion(graphPath: string): number | null { try { const parsed = JSON.parse(readFileSync(graphPath, 'utf8')) as unknown @@ -404,11 +423,11 @@ export function generateGraph(rootPath = '.', options: GenerateGraphOptions = {} : null if (!graph) { - throw new Error(missingCodeExtractionMessage(detected.total_files)) + throw missingCodeExtractionError(detected.total_files) } if (!options.clusterOnly && graph.numberOfNodes() === 0) { - throw new Error(missingCodeExtractionMessage(detected.total_files)) + throw missingCodeExtractionError(detected.total_files) } progress?.({ step: 'build', message: `Built graph: ${graph.numberOfNodes()} nodes, ${graph.numberOfEdges()} edges` }) diff --git a/src/infrastructure/try-command.ts b/src/infrastructure/try-command.ts index 355c505f..7bdd8d30 100644 --- a/src/infrastructure/try-command.ts +++ b/src/infrastructure/try-command.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path' import type { TryCliOptions, PackCliOptions } from '../cli/parser.js' import { runContextPackCommand } from './context-pack-command.js' -import { generateGraph } from './generate.js' +import { generateGraph, GenerateUnsupportedCorpusError } from './generate.js' import { defaultInstallPlatform, type InstallPlatform } from './install.js' import { buildGraphSummary, type GraphSummary } from '../runtime/graph-summary.js' import { analyzeGraphContextFreshness, graphFreshnessStatusLabel, type GraphContextFreshness } from '../runtime/freshness.js' @@ -75,9 +75,8 @@ function tooSmallReason(nodeCount: number): string | null { return `Current repo graph is too small for a useful first-run result (${nodeCount} nodes; need at least ${MIN_TRIAL_NODES}).` } -function isFallbackEligibleGenerateError(reason: string): boolean { - return reason === 'No supported files were found in the target path.' - || reason.startsWith('No graph nodes could be generated from the detected corpus.') +function isFallbackEligibleGenerateError(error: unknown): boolean { + return error instanceof GenerateUnsupportedCorpusError } function recommendInstallPlatform(workspace: string, dependencies: TryCommandDependencies): InstallPlatform { @@ -210,7 +209,7 @@ async function prepareWorkspace( workspace, reason, notes, - fallbackEligible: isFallbackEligibleGenerateError(reason), + fallbackEligible: isFallbackEligibleGenerateError(error), } } } diff --git a/tests/unit/generate.test.ts b/tests/unit/generate.test.ts index bcf9b294..05313b30 100644 --- a/tests/unit/generate.test.ts +++ b/tests/unit/generate.test.ts @@ -5,7 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises' import { describe, expect, test, vi } from 'vitest' -import { generateGraph } from '../../src/infrastructure/generate.js' +import { generateGraph, GenerateUnsupportedCorpusError } from '../../src/infrastructure/generate.js' import { loadGraph } from '../../src/runtime/serve.js' import { binaryIngestSidecarPath } from '../../src/shared/binary-ingest-sidecar.js' import { normalizeAssertionPath, normalizeAssertionPaths } from './helpers/platform.js' @@ -959,6 +959,36 @@ function createTestOggOpusBuffer( describe('generateGraph', () => { const generateGraphIntegrationTimeoutMs = 15_000 + test('throws a stable unsupported-corpus error code when no supported files are detected', () => { + withTempDir((tempDir) => { + writeFileSync(join(tempDir, 'fixture.bin'), Buffer.from([0xde, 0xad, 0xbe, 0xef])) + + try { + generateGraph(tempDir, { noHtml: true }) + throw new Error('expected generateGraph() to throw') + } catch (error) { + expect(error).toBeInstanceOf(GenerateUnsupportedCorpusError) + expect((error as GenerateUnsupportedCorpusError).code).toBe('NO_SUPPORTED_FILES') + expect((error as Error).message).toContain('No supported files were found in the target path.') + } + }) + }) + + test('throws a stable unsupported-corpus error code when extraction yields zero graph nodes', () => { + withTempDir((tempDir) => { + writeFileSync(join(tempDir, 'README.md'), '# Notes only\\n', 'utf8') + + try { + generateGraph(tempDir, { includeDocs: false, noHtml: true }) + throw new Error('expected generateGraph() to throw') + } catch (error) { + expect(error).toBeInstanceOf(GenerateUnsupportedCorpusError) + expect((error as GenerateUnsupportedCorpusError).code).toBe('NO_GRAPH_NODES') + expect((error as Error).message).toContain('No graph nodes could be generated from the detected corpus.') + } + }) + }) + test('builds graph artifacts for a code corpus', () => { withTempDir((tempDir) => { writeFileSync(join(tempDir, 'main.py'), 'class Greeter:\n def hello(self):\n return 1\n', 'utf8') diff --git a/tests/unit/try-command.test.ts b/tests/unit/try-command.test.ts index 06989aa7..76386940 100644 --- a/tests/unit/try-command.test.ts +++ b/tests/unit/try-command.test.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { GenerateUnsupportedCorpusError } from '../../src/infrastructure/generate.js' import type { GraphContextFreshness, GraphContextFreshnessStatus } from '../../src/runtime/freshness.js' import type { GraphSummary } from '../../src/runtime/graph-summary.js' import { runTryCommand, type TryCommandDependencies } from '../../src/infrastructure/try-command.js' @@ -164,7 +165,7 @@ describe('runTryCommand', () => { generateGraph: vi.fn().mockImplementation((rootPath = '.') => { const resolvedRoot = resolve(rootPath) if (resolvedRoot === workspace) { - throw new Error('No supported files were found in the target path.') + throw new GenerateUnsupportedCorpusError('NO_SUPPORTED_FILES', 'No supported files were found in the target path.') } return { mode: 'generate', From b16daf6df41600c51b6f6441ef675c4dbdf00acf Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 2 Jun 2026 22:58:15 +0400 Subject: [PATCH 4/4] fix: preserve try pack errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/infrastructure/try-command.ts | 11 ++++++++--- tests/unit/try-command.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/try-command.ts b/src/infrastructure/try-command.ts index 7bdd8d30..101e475f 100644 --- a/src/infrastructure/try-command.ts +++ b/src/infrastructure/try-command.ts @@ -157,6 +157,7 @@ async function prepareWorkspace( ): Promise { const graphPath = trialGraphPath(workspace) const notes: string[] = [] + let reuseExistingGraph = false if (dependencies.pathExists(graphPath)) { try { @@ -175,14 +176,18 @@ async function prepareWorkspace( } notes.push(`[madar try] Reusing ${graphPath} (${graphFreshnessStatusLabel(freshness.status)}).`) - return await runPackForWorkspace(workspace, prompt, graphPath, notes, io, dependencies) + reuseExistingGraph = true + } else { + notes.push(`[madar try] Existing graph is ${graphFreshnessStatusLabel(freshness.status)}; rebuilding ${workspace}.`) } - - notes.push(`[madar try] Existing graph is ${graphFreshnessStatusLabel(freshness.status)}; rebuilding ${workspace}.`) } catch (error) { const message = error instanceof Error ? error.message : String(error) notes.push(`[madar try] Existing graph could not be read (${message}); rebuilding ${workspace}.`) } + + if (reuseExistingGraph) { + return await runPackForWorkspace(workspace, prompt, graphPath, notes, io, dependencies) + } } else { notes.push(`[madar try] No graph found at ${graphPath}; building one now.`) } diff --git a/tests/unit/try-command.test.ts b/tests/unit/try-command.test.ts index 76386940..d1b9def9 100644 --- a/tests/unit/try-command.test.ts +++ b/tests/unit/try-command.test.ts @@ -276,4 +276,19 @@ describe('runTryCommand', () => { expect(dependencies.generateGraph).not.toHaveBeenCalled() expect(dependencies.runContextPack).not.toHaveBeenCalled() }) + + it('surfaces pack failures from fresh graph reuse without rewriting them as graph read failures', async () => { + const workspace = resolve('/tmp/reuse-workspace') + const graphPath = resolve(workspace, 'out', 'graph.json') + const { io } = createIo() + const dependencies = createDependencies({ + pathExists: vi.fn().mockImplementation((path: string) => path === graphPath), + runContextPack: vi.fn().mockRejectedValue(new Error('pack exploded')), + }) + + await expect(runTryCommand({ prompt: 'how does auth work?', path: workspace }, io, dependencies)).rejects.toThrow( + 'pack exploded', + ) + expect(dependencies.generateGraph).not.toHaveBeenCalled() + }) })