Skip to content

refactor: extract parseScheduleCreateArgs pure function (#103)#113

Open
dean0x wants to merge 6 commits intomainfrom
feat/103-extract-schedule-parser
Open

refactor: extract parseScheduleCreateArgs pure function (#103)#113
dean0x wants to merge 6 commits intomainfrom
feat/103-extract-schedule-parser

Conversation

@dean0x
Copy link
Owner

@dean0x dean0x commented Mar 22, 2026

Summary

Extract parsing and validation logic from the scheduleCreate handler into a pure function returning Result<ParsedScheduleCreateArgs, string>, following the parseLoopCreateArgs pattern.

Changes

Core refactor:

  • Extract parseScheduleCreateArgs() pure function from 188-line handler
  • Add ParsedScheduleCreateArgs interface (private)
  • Simplify scheduleCreate handler to ~45 lines (handler only orchestrates events)
  • Full error handling returns Result types (no process.exit)

Test improvements:

  • Replace hand-rolled test helpers with real parser calls
  • Add 22 parser unit tests (happy paths + error cases)
  • Fix workingDirectory test to use process.cwd() (was incorrectly bypassing validation)

Polish (self-review):

  • Fix biome formatting (extra blank line in test file)
  • Deduplicate baseOptions in tests
  • Replace nested ternary with if/else for readability
  • Remove redundant JSDoc
  • Remove 4 duplicate tests

Breaking Changes

None.

Testing

  • 22 new unit tests for parseScheduleCreateArgs
  • Existing scheduleCreate handler tests still pass
  • All 1,662 tests passing
  • Snyk clean

Dean Sharon and others added 3 commits March 22, 2026 14:18
Extract parsing and validation from the 188-line scheduleCreate handler
into a pure function returning Result<ParsedScheduleCreateArgs, string>,
matching the parseLoopCreateArgs pattern in loop.ts.

- Add ParsedScheduleCreateArgs interface (private)
- Export parseScheduleCreateArgs (no side effects, no process.exit)
- Simplify scheduleCreate handler to ~45 lines
- Replace hand-rolled test helpers with real parser calls
- Add 22 parser unit tests (happy paths + error cases)
- Fix workingDirectory test to use process.cwd() (was bypassing validation)

Closes #103
- Fix biome formatting (extra blank line in test file)
- Simplifier changes: deduplicate baseOptions, replace nested ternary
  with if/else, remove redundant JSDoc, remove 4 duplicate tests
Include .claudeignore to protect against sensitive files
and context pollution when using Claude-based tools.

Co-Authored-By: Claude <noreply@anthropic.com>
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 22, 2026

Confidence Score: 4/5

  • Safe to merge after addressing the priority enum conversion gap in test helpers; all structural changes are sound.
  • The refactor is well-executed: pure function, discriminated union, 22 new tests, and loop.ts alignment are all clean. Prior review concerns about the pipeline hint and agent parity in simulateScheduleCreate have been addressed. Remaining items are two minor UX copy issues and a test-helper inconsistency (priority passed as raw string vs. domain enum) that doesn't affect current test assertions but leaves a latent gap. None of these block the primary user path or introduce reliability/security risk.
  • tests/unit/cli.test.ts — priority conversion gap in simulateScheduleCreate / simulateScheduleCreatePipeline

Important Files Changed

Filename Overview
src/cli/commands/schedule.ts Core refactor: extracts parseScheduleCreateArgs pure function with discriminated-union return type, moving validation out of the handler. Logic and error handling are correct; two minor UX/hint issues (silent prompt discard in pipeline mode, --step error referencing only --cron).
src/cli/commands/loop.ts Aligns ParsedLoopArgs with the new schedule parser pattern (discriminated union), fixes all parseInt calls to pass radix 10, and improves --agent flag handling to detect missing values. Clean, no logic issues.
tests/unit/cli.test.ts Replaces hand-rolled validation stubs with 22 real parser unit tests and removes duplicates. The priority field in simulateScheduleCreate / simulateScheduleCreatePipeline is passed as a raw string while the production handler converts it to the domain enum — a subtle gap worth closing.
.claudeignore New .claudeignore file protecting sensitive paths and large/irrelevant files from AI context. Standard boilerplate, no issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[beat schedule create args] --> B[parseScheduleCreateArgs\npure function]
    B --> C{Result ok?}
    C -->|err| D[ui.error + process.exit 1]
    C -->|ok| E{isPipeline?}
    E -->|true| F[pipelineSteps variant]
    E -->|false| G[prompt variant]
    F --> H[service.createScheduledPipeline\nbaseOptions + steps]
    G --> I[service.createSchedule\nbaseOptions + prompt]
    H --> J[ui.info success]
    I --> J
Loading

Comments Outside Diff (1)

  1. tests/unit/cli.test.ts, line 2061-2074 (link)

    P2 simulateScheduleCreate passes raw priority string; real handler converts to domain enum

    simulateScheduleCreate forwards priority: args.priority (e.g. 'P0') straight to the mock service, while the real scheduleCreate handler does priority: args.priority ? Priority[args.priority] : undefined (converting to the Priority enum). Any test that asserts mockScheduleService.createCalls[0].priority === Priority.P0 via this helper will compare different types.

    The same gap exists in simulateScheduleCreatePipeline (priority: p.priority). Note that missedRunPolicy was correctly fixed in this PR to use toMissedRunPolicy(); applying the same treatment to priority would close the gap:

    // in both simulateScheduleCreate and simulateScheduleCreatePipeline
    priority: args.priority ? Priority[args.priority] : undefined,

Reviews (2): Last reviewed commit: "fix: address PR review feedback — JSDoc ..." | Re-trigger Greptile

process.exit(1);
return err('--missed-run-policy must be "skip", "catchup", or "fail"');
}
missedRunPolicy = next as 'skip' | 'catchup' | 'fail';
Copy link
Owner Author

Choose a reason for hiding this comment

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

Type Safety: Type Assertions

The lines using as 'skip' | 'catchup' | 'fail' and as 'P0' | 'P1' | 'P2' after runtime includes() checks are safe but lose type information. TypeScript's includes() doesn't narrow the type to the literal union, necessitating the assertion.

Suggestion: Use type guard functions to achieve proper narrowing without as casts:

function isValidPolicy(v: string): v is 'skip' | 'catchup' | 'fail' {
  return v === 'skip' || v === 'catchup' || v === 'fail';
}

function isValidPriority(v: string): v is 'P0' | 'P1' | 'P2' {
  return v === 'P0' || v === 'P1' || v === 'P2';
}

Then the checks become if (!isValidPolicy(next)) return err(...) followed by missedRunPolicy = next; — no cast needed, and full type narrowing.

Confidence: 80% | Category: Should Consider

workingDirectory = pathResult.value;
i++;
} else if (arg === '--max-runs' && next) {
maxRuns = parseInt(next);
Copy link
Owner Author

Choose a reason for hiding this comment

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

TypeScript: parseInt without explicit radix

Line uses parseInt(next) without specifying the radix parameter. While modern engines default to base-10 for decimal strings, best practice and ESLint's radix rule recommend always providing 10 explicitly to avoid ambiguity with edge cases (leading-zero strings in older contexts).

Fix: Change to parseInt(next, 10)

Confidence: 85% | Category: Best Practice

/**
* Parsed arguments from CLI schedule create command
*/
interface ParsedScheduleCreateArgs {
Copy link
Owner Author

Choose a reason for hiding this comment

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

Architecture: Discriminated Union for Pipeline/Prompt Invariant

The ParsedScheduleCreateArgs interface uses optional types (prompt?: string, pipelineSteps?: readonly string[]), but the parser guarantees: when isPipeline === true, pipelineSteps has items; when isPipeline === false, prompt is set. This forces lines 252 and 271 to use non-null assertions (args.pipelineSteps!, args.prompt!), losing the type-level proof.

Fix: Use a discriminated union to make TypeScript enforce the invariant:

type ParsedScheduleCreateArgs = {
  readonly scheduleType: 'cron' | 'one_time';
  readonly cronExpression?: string;
  readonly scheduledAt?: string;
  readonly timezone?: string;
  readonly missedRunPolicy?: 'skip' | 'catchup' | 'fail';
  readonly priority?: 'P0' | 'P1' | 'P2';
  readonly workingDirectory?: string;
  readonly maxRuns?: number;
  readonly expiresAt?: string;
  readonly afterScheduleId?: string;
  readonly agent?: AgentProvider;
} & (
  | { readonly isPipeline: true; readonly pipelineSteps: readonly string[]; readonly prompt?: string }
  | { readonly isPipeline: false; readonly prompt: string; readonly pipelineSteps?: undefined }
);

Then args.pipelineSteps and args.prompt narrow automatically after checking args.isPipeline. No ! assertions needed.

Confidence: 82% | Category: Type Safety

// Non-pipeline mode: prompt is required
const prompt = promptWords.join(' ');
if (!isPipeline && !prompt) {
return err('Usage: beat schedule create <prompt> --cron "..." | --at "..." [options]');
Copy link
Owner Author

Choose a reason for hiding this comment

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

Consistency: Missing Pipeline Usage Hint

The original scheduleCreate function displayed a two-line error when no prompt was provided in non-pipeline mode: the usage string plus a pipeline example (Pipeline: beat schedule create --pipeline --step "lint" --step "test" --cron "0 9 * * *"). This refactored version only returns the single-line usage error via parseScheduleCreateArgs, and the pipeline hint is lost. Users who forget the prompt no longer see the helpful pipeline alternative.

Fix: Include the pipeline hint in the error message returned by the parser:

if (!isPipeline && !prompt) {
  return err(
    'Usage: beat schedule create <prompt> --cron "..." | --at "..." [options]\n' +
    '  Pipeline: beat schedule create --pipeline --step "lint" --step "test" --cron "0 9 * * *"'
  );
}

Confidence: 90% | Category: UX/Regression Prevention

@dean0x
Copy link
Owner Author

dean0x commented Mar 22, 2026

Summary: Code Review Findings

This PR is a well-executed extraction that follows the established pattern from loop.ts. 3 inline comments created for high-confidence findings (≥80%). Below are lower-confidence suggestions (60-79%) for consideration.

Blocking Issues

None. All findings are improvements to type safety, consistency, or code quality.

Should-Consider Suggestions (60-79% Confidence)

1. Inconsistent Prompt Field Handling vs Loop Parser (Line 154, 82% confidence)
The loop parser uses prompt: isPipeline ? undefined : prompt which explicitly suppresses prompt in pipeline mode. This parser uses prompt: prompt || undefined, which converts empty strings in all modes. While safe at the call site, the two parsers should behave consistently.

2. Duplicated Option-Mapping Logic (tests/unit/cli.test.ts:2497-2582, 72% confidence)
The baseOptions construction in scheduleCreate is duplicated in test helpers. If the mapping changes in production, test helpers could diverge. Consider extracting a shared mapper.

3. Pure Function Location (src/cli/commands/schedule.ts:33, 65% confidence)
parseScheduleCreateArgs has zero UI dependencies. It could live in src/cli/parsers/ or src/utils/ to make its purity more discoverable and enable reuse from MCP adapter without importing CLI modules. Follows project pattern of validation.ts and format.ts.

4. Missing ARCHITECTURE JSDoc (src/cli/commands/schedule.ts:30-32, 85% confidence)
The parseLoopCreateArgs function includes JSDoc annotation: 'ARCHITECTURE: Pure function -- no side effects, returns Result for testability'. This function should include the same for consistency.

Overall Assessment

Score: 8/10 across all review dimensions. Recommendation: APPROVED. The refactoring is sound and well-tested. The 3 inline comments address type safety and UX regression. Lower-confidence suggestions are improvements to consistency and maintainability, not blockers.

Test Coverage: 233 CLI tests pass (up from ~208 on main). No regressions detected.


Review by Claude Code | Backbeat Code Review System

Dean Sharon and others added 3 commits March 22, 2026 21:26
- Suppress prompt in pipeline mode to match loop parser pattern
- Remove dead handler warning (prompt now undefined in pipeline mode)
- Add pipeline usage hint to missing-prompt error message
- Add explicit radix 10 to parseInt for --max-runs

Co-Authored-By: Claude <noreply@anthropic.com>
Replace optional prompt/pipelineSteps with discriminated union on
isPipeline, eliminating non-null assertions in handlers. Both parsers
now encode mutual exclusivity in the type system.
- Add ARCHITECTURE JSDoc to parseScheduleCreateArgs (matches loop parser)
- Add missing agent option to simulateScheduleCreate test helper
Comment on lines +142 to +151
// Pipeline mode
if (isPipeline) {
if (promptWords.length > 0) {
ui.info(`Ignoring positional prompt text in --pipeline mode: "${promptWords.join(' ')}". Use --step flags only.`);
}
if (pipelineSteps.length < 2) {
ui.error('Pipeline requires at least 2 --step flags');
process.exit(1);
return err('Pipeline requires at least 2 --step flags');
}
} else if (pipelineSteps.length > 0) {
return err(
'--step requires --pipeline. Did you mean: beat schedule create --pipeline --step "..." --step "..." --cron "..."',
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Positional words in --pipeline mode are now silently discarded

The original handler emitted a ui.info(...) diagnostic when positional prompt words were provided alongside --pipeline:

ui.info(`Ignoring positional prompt text in --pipeline mode: "${promptWords.join(' ')}". Use --step flags only.`);

This is now gone. The new pure function simply ignores promptWords in pipeline mode (they are collected but never referenced in the isPipeline branch). A user who accidentally writes beat schedule create my notes --pipeline --step lint --step test --cron "0 9 * * *" gets a successful result with the prompt text silently swallowed, rather than a hint that they're doing something unexpected.

If keeping this silent behavior is deliberate (to match the loop parser), consider at minimum surfacing a warning in the scheduleCreate handler after parsing, e.g.:

if (args.isPipeline && scheduleArgs.some(a => !a.startsWith('-') && /* not a value */ ...)) {
  ui.warn('Positional text is ignored in --pipeline mode; use --step flags only.');
}

Or alternatively, reject the input with an error (the strictest option, ensuring no ambiguity).

Comment on lines +147 to +150
} else if (pipelineSteps.length > 0) {
return err(
'--step requires --pipeline. Did you mean: beat schedule create --pipeline --step "..." --step "..." --cron "..."',
);
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 --step error hint only mentions --cron, not --at

The error message for --step without --pipeline ends with --cron "...", which inadvertently implies cron is the only valid scheduling option. A user building a one-time pipeline (--at "2026-06-01T09:00:00Z") would find the suggestion misleading.

return err(
  '--step requires --pipeline. Did you mean: beat schedule create --pipeline --step "..." --step "..." --cron "..."',
);

Consider broadening the hint to cover both scheduling forms:

Suggested change
} else if (pipelineSteps.length > 0) {
return err(
'--step requires --pipeline. Did you mean: beat schedule create --pipeline --step "..." --step "..." --cron "..."',
);
return err(
'--step requires --pipeline. Did you mean: beat schedule create --pipeline --step "..." --step "..." --cron "0 9 * * *" | --at "..."',
);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant