Skip to content

feat: Complete CLI redesign with comprehensive testing#24

Merged
acossta merged 4 commits intomainfrom
feature/cli-redesign-v4
Aug 22, 2025
Merged

feat: Complete CLI redesign with comprehensive testing#24
acossta merged 4 commits intomainfrom
feature/cli-redesign-v4

Conversation

@acossta
Copy link
Copy Markdown
Owner

@acossta acossta commented Aug 22, 2025

Summary

This PR completes the CLI redesign to a resource-oriented architecture and adds comprehensive test coverage.

✨ Features

  • Redesigned CLI to use noun-verb pattern (e.g., captan stakeholder add, captan issuance list)
  • Added resource-specific handlers for better code organization
  • Implemented intelligent identifier resolution (supports both IDs and emails)
  • Improved user experience with better error messages and suggestions

🧪 Testing

  • Added comprehensive test suites for all handler modules (10 new test files)
  • Coverage improved from 39.23% to 83.93%
  • Handler coverage: 94.79% (exceeds 90% target)
  • Services coverage: 99.34%
  • All 874 tests passing

📚 Documentation

  • Updated README with new command structure
  • Added detailed examples for each resource type
  • Improved command help text

🔧 Technical Details

  • Fixed test isolation issues using factory functions
  • Corrected mock return structures
  • Replaced shared mutable test data with deep cloning

Breaking Changes

⚠️ This is a breaking change - CLI commands have been restructured:

Old: captan stakeholder --name "Alice"
New: captan stakeholder add --name "Alice"

Old: captan issue --holder alice@test.com --qty 1000
New: captan issuance add --stakeholder alice@test.com --qty 1000

Test Coverage Report

Overall: 83.93%
- Statements: 83.93%
- Branches: 89.78%
- Functions: 94.51%
- Lines: 83.93%

Full Changelog: main...feature/cli-redesign-v4

Summary by CodeRabbit

  • New Features

    • Resource-oriented CLI (stakeholder, security, issuance, grant, safe, report, export, system); SAFE convert; CSV/JSON exports (PDF stub); new reports: summary, ownership, stakeholder, security; JSON output options.
  • Documentation

    • README updated with per-resource commands, renamed flags (e.g., --vesting-months, --cliff-months) and terminology (stakeholder, PREFERRED); examples and QuickStart refreshed.
  • Tests

    • Expanded test coverage across CLI, handlers, services, identifier resolver, SAFEs, grants, exports, reporting, performance, and edge cases.
  • Refactor

    • Unified CLI naming, consistent messages, safer CSV generation, standardized handler results and centralized handlers hub.

Implements GitHub issue #23 - transforms CLI from inconsistent command
patterns to clean resource-oriented architecture following RESTful principles.

## Architecture Changes
- Resource-first commands: `captan [resource] [action]`
- Consistent CRUD verbs: add, list, show, update, delete
- Email-based stakeholder identification support
- Clean handler separation by resource type

## New Command Structure
- stakeholder: add/list/show/update/delete
- security: add/list/show/update/delete
- issuance: add/list/show/update/delete
- grant: add/list/show/update/delete
- safe: add/list/show/update/delete/convert
- report: summary/ownership/stakeholder/security
- export: csv/json/pdf
- system: init/validate/schema/log

## Implementation
- Extracted 2,593 lines of handlers into clean modular files
- Fixed property name mismatches (pps vs pricePerShare)
- Implemented missing CRUD operations (many were stubs)
- Added comprehensive identifier resolution system
- Updated all 28 integration tests to new syntax

## Documentation
- Added comprehensive Usage section with all commands
- Updated all examples to use new resource-oriented syntax
- Fixed parameter notation (<required> vs [optional])
- Removed outdated command references

## Testing
- All 521 tests passing
- Complete end-to-end verification of new commands
- Integration tests updated for new CLI structure

Breaking changes for v0.4.0 - new architecture is production ready.
- Add complete test suites for all handler modules (10 new test files)
- Add identifier-resolver and helpers test suites
- Fix test isolation issues by using factory functions and deep cloning
- Fix mock return structures to match implementation expectations
- Improve coverage from 39.23% to 83.93% overall
- Handler coverage increased to 94.79% (exceeds 90% target)
- Services coverage at 99.34%
- All 874 tests now passing

Key fixes:
- Replace shared mutable mock objects with factory functions
- Use JSON.parse(JSON.stringify()) for deep cloning test data
- Fix resolver mocks to return {success, stakeholder} structure
- Correct test expectations for available shares calculation
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Aug 22, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Refactors the CLI into resource-oriented commands with centralized handler modules (stakeholder, security, issuance, grant, safe, report, export, system); adds identifier resolution, helper services, unified HandlerResult typing, CSV/JSON export handlers, SAFE conversion, test utilities, many new unit/integration tests, README updates, and date utilities.

Changes

Cohort / File(s) Summary
Documentation
README.md
Rewrites usage to per-resource CLI (stakeholder, security, issuance, grant, safe, report, export, system), renames commands/flags, updates examples and outputs.
CLI Surface & Tests
src/cli.ts, src/cli.test.ts
Replaces legacy top-level commands with resource groups; routes to handlers via handlers/index; updates integration tests to new command names, flags, and outputs.
Handlers Entry & Types
src/handlers/index.ts, src/handlers/types.ts
Adds HandlerResult type and a single entry-point re-exporting all handlers.
System Handlers & Tests
src/handlers/system.handlers.ts, src/handlers/system.handlers.test.ts
Adds init/validate/schema/log handlers (wizard/manual init, schema generation, audit logs) and extensive tests.
Stakeholder Handlers & Tests
src/handlers/stakeholder.handlers.ts, src/handlers/stakeholder.handlers.test.ts
Adds stakeholder CRUD/list/show/update/delete, holdings reporting, identifier resolution integration, and tests.
Security Handlers & Tests
src/handlers/security.handlers.ts, src/handlers/security.handlers.test.ts
Adds security-class CRUD/list/show/update/delete, authorization/utilization checks, force-delete semantics, and tests.
Issuance Handlers & Tests
src/handlers/issuance.handlers.ts, src/handlers/issuance.handlers.test.ts
Adds issuance add/list/show/update/delete with authorization enforcement, table/JSON outputs, and tests.
Grant Handlers & Tests
src/handlers/grant.handlers.ts, src/handlers/grant.handlers.test.ts
Adds option grant lifecycle handlers with vesting-aware calculations, safety checks, logging, and tests.
SAFE Handlers & Tests
src/handlers/safe.handlers.ts, src/handlers/safe.handlers.test.ts
Adds SAFE add/list/show/update/delete and convert (dry-run + execute), conversion flows, and tests.
Report Handlers & Tests
src/handlers/report.handlers.ts, src/handlers/report.handlers.test.ts
Adds summary, ownership, stakeholder, and security reports with table/JSON outputs and tests.
Export Handlers & Tests
src/handlers/export.handlers.ts, src/handlers/export.handlers.test.ts
Adds CSV and JSON export handlers (PDF stub), CSV escaping/formatting, file output support, and edge-case tests.
Identifier Resolver & Tests
src/identifier-resolver.ts, src/identifier-resolver.test.ts
Adds email/ID validation, batch resolution, suggestions, formatting utilities, and tests.
Helper Services & Tests
src/services/helpers.ts, src/services/helpers.test.ts
Adds domain factories (stakeholder, security, issuance, grant, SAFE), vesting and SAFE conversion logic, holdings aggregation, audit logging, and tests.
Reporting CSV Safety
src/services/reporting-service.ts
Adds CSV-escaping helpers and integrates SAFE summaries; internal implementation changes only.
Performance & Edge-case Tests
src/performance.test.ts, src/services/*-service.test.ts
Expands performance tests, deterministic RNG, extended timeouts, and adds numeric/date/string edge-case tests.
Test Utilities & Setup
src/utils/test-utils.ts, vitest.setup.ts, vitest.config.ts
Adds test helpers (fake timers, cleanup, id generator), test environment setup, and vitest setupFiles config.
Date Utilities & Store Timestamp
src/utils/date-utils.ts, src/store.ts
Adds getCurrentDate/getCurrentTimestamp utilities; store now uses getCurrentTimestamp for audit timestamps.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant CLI as CLI (Commander)
  participant H as Handlers (handlers/index.ts)
  participant Store as Store (load/save)
  participant Helpers as Helpers (services/helpers)

  User->>CLI: "issuance add --stakeholder ... --security ..."
  CLI->>H: handleIssuanceAdd(opts)
  H->>Store: load('captable.json')
  alt captable exists & validation passes
    H->>Helpers: createIssuance / getIssuedShares / logAction
    H->>Store: save(updated captable)
    H-->>CLI: { success:true, message, data }
  else validation fails or missing
    H-->>CLI: { success:false, message }
  end
  CLI-->>User: prints message
  note right of H: Request → validate → mutate → log → persist
Loading
sequenceDiagram
  autonumber
  actor User
  participant CLI as CLI
  participant SafeH as Safe Handler (handleSafeConvert)
  participant Helpers as Helpers (calculateSAFEConversions, createIssuance)
  participant Store as Store

  User->>CLI: "safe convert --pre-money ... --pps ... [--dry-run]"
  CLI->>SafeH: handleSafeConvert(opts)
  SafeH->>Store: load('captable.json')
  SafeH->>Helpers: calculateSAFEConversions(captable, pps, preMoney)
  alt dry-run
    SafeH-->>CLI: { success:true, message: preview, data: conversions }
  else execute
    loop for each conversion
      SafeH->>Helpers: createIssuance(...shares..., pps, date)
    end
    SafeH->>Store: save(updated captable without converted SAFEs)
    SafeH-->>CLI: { success:true, message: converted, data }
  end
  CLI-->>User: prints message
  note right of Helpers: conversionReason = cap | discount | price
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

I twitch my whiskers at commands anew,
I bound through stakeholders, safes, and shares in view.
I nibble reports and CSV delights,
Dry-run conversions before I bite.
With tidy handlers, I hop — hippity hooray! 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e8b55cf and df826ce.

📒 Files selected for processing (6)
  • src/handlers/report.handlers.ts (1 hunks)
  • src/handlers/stakeholder.handlers.ts (1 hunks)
  • src/handlers/system.handlers.ts (1 hunks)
  • src/handlers/types.ts (1 hunks)
  • src/identifier-resolver.ts (1 hunks)
  • src/performance.test.ts (16 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/cli-redesign-v4

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 15

🧹 Nitpick comments (70)
src/performance.test.ts (3)

365-365: Reduce flakiness: relax or gate the 500ms assertion

The 500ms ceiling for sparse-data calc can be brittle across CI runners and Node/V8 versions. Consider either:

  • Bumping to 750–1000ms, or
  • Gating the perf assertion behind an env flag so correctness always runs while perf runs on demand.

Apply one of these diffs:

Option A (relax threshold):

-      expect(calcTime).toBeLessThan(500); // Should be fast despite many stakeholders
+      expect(calcTime).toBeLessThan(1000); // Keep this generous to avoid CI flakiness

Option B (gate by env flag):

-      expect(calcTime).toBeLessThan(500); // Should be fast despite many stakeholders
+      if (process.env.CAPTAN_PERF_ASSERT === '1') {
+        expect(calcTime).toBeLessThan(500);
+      }

99-101: Silence perf logs by default to keep CI output clean

Perf logs are helpful locally but add noise in CI. Wrap them behind a debug flag.

+const debugPerf = (...args: any[]) => {
+  if (process.env.DEBUG_PERF === '1') console.log(...args);
+};
@@
-      console.log(`Setup time for 10,000 transactions: ${setupTime.toFixed(2)}ms`);
+      debugPerf(`Setup time for 10,000 transactions: ${setupTime.toFixed(2)}ms`);
@@
-      console.log(`Cap table calculation time: ${calcTime.toFixed(2)}ms`);
+      debugPerf(`Cap table calculation time: ${calcTime.toFixed(2)}ms`);
@@
-      console.log(`SAFE conversion time for 1000 SAFEs: ${conversionTime.toFixed(2)}ms`);
+      debugPerf(`SAFE conversion time for 1000 SAFEs: ${conversionTime.toFixed(2)}ms`);
@@
-      console.log(`100-year vesting calculation time: ${calcTime.toFixed(2)}ms`);
+      debugPerf(`100-year vesting calculation time: ${calcTime.toFixed(2)}ms`);
@@
-        `Cross-century date calculations (${datePairs.length} pairs): ${calcTime.toFixed(2)}ms`
+        `Cross-century date calculations (${datePairs.length} pairs): ${calcTime.toFixed(2)}ms`
@@
-      console.log(`Memory used for 10,000 stakeholders: ${memoryIncrease.toFixed(2)} MB`);
+      debugPerf(`Memory used for 10,000 stakeholders: ${memoryIncrease.toFixed(2)} MB`);
@@
-      console.log(`1000 cap table calculations: ${totalTime.toFixed(2)}ms`);
-      console.log(`Average time per calculation: ${(totalTime / 1000).toFixed(2)}ms`);
+      debugPerf(`1000 cap table calculations: ${totalTime.toFixed(2)}ms`);
+      debugPerf(`Average time per calculation: ${(totalTime / 1000).toFixed(2)}ms`);
@@
-        `Extreme SAFE conversions (${results.length} calculations): ${calcTime.toFixed(2)}ms`
+        `Extreme SAFE conversions (${results.length} calculations): ${calcTime.toFixed(2)}ms`
@@
-        `Sparse data cap table (5000 stakeholders, 10 with equity): ${calcTime.toFixed(2)}ms`
+        `Sparse data cap table (5000 stakeholders, 10 with equity): ${calcTime.toFixed(2)}ms`
@@
-      console.log(`Parsing ${testDates.length} dates: ${parseTime.toFixed(2)}ms`);
-      console.log(`Formatting ${parsed.length} dates: ${formatTime.toFixed(2)}ms`);
+      debugPerf(`Parsing ${testDates.length} dates: ${parseTime.toFixed(2)}ms`);
+      debugPerf(`Formatting ${parsed.length} dates: ${formatTime.toFixed(2)}ms`);

Also applies to: 107-109, 122-124, 146-148, 175-177, 209-210, 263-265, 314-315, 361-362, 399-401


55-67: Deterministic inputs for perf/stress tests

Use a seeded PRNG for share/options/SAFE amounts to reduce variance across runs. This keeps perf results comparable while preserving distribution shape.

Example drop-in generator:

function mulberry32(seed: number) {
  return function() {
    let t = (seed += 0x6D2B79F5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Then replace Math.random() with a local rng seeded per test run (e.g., based on test title hash).

Would you like me to submit a patch wiring this in across the three loops that use Math.random?

Also applies to: 69-85, 87-97, 300-311

src/handlers/types.ts (1)

5-9: Introduce Generic HandlerResult for Stronger Payload Typing

Loose any for data weakens type-safety. Replace the current interface with a generic that defaults to unknown (so existing handlers won’t break) and add an optional machine-readable code. You can then gradually specialize each handler’s return type for full type safety.

Diff (in src/handlers/types.ts):

-export interface HandlerResult {
-  success: boolean;
-  message: string;
-  data?: any;
-}
+export interface HandlerResult<T = unknown> {
+  success: boolean;
+  message: string;
+  data?: T;
+  code?: string; // Optional machine-readable status or error code
+}

Places to consider specializing T (after merging the above change):

  • src/handlers/report.handlers.ts
  • src/handlers/system.handlers.ts
  • src/handlers/stakeholder.handlers.ts
  • src/handlers/safe.handlers.ts
  • src/handlers/issuance.handlers.ts
  • src/handlers/security.handlers.ts
  • src/handlers/export.handlers.ts
  • src/handlers/grant.handlers.ts
src/handlers/issuance.handlers.test.ts (7)

325-334: Prefer asserting structured data over parsing JSON from message

Since handlers already return data, assert on result.data for stronger contracts (and keep message for human output).

-      const result = handleIssuanceList({ format: 'json' });
+      const result = handleIssuanceList({ format: 'json' });
       expect(result.success).toBe(true);
-      const jsonData = JSON.parse(result.message);
-      expect(jsonData.length).toBeGreaterThan(0);
-      expect(jsonData.some((issuance: any) => issuance.id === 'is_founder')).toBe(true);
+      expect(Array.isArray(result.data)).toBe(true);
+      expect((result.data as any[]).length).toBeGreaterThan(0);
+      expect((result.data as any[]).some((issuance: any) => issuance.id === 'is_founder')).toBe(true);

444-455: Also assert the model mutation and returned payload on quantity update

Validate both the mutated in-memory captable and the returned data to fully exercise the handler.

-      mockLoad.mockReturnValue(createMockCaptable());
+      const captable = createMockCaptable();
+      mockLoad.mockReturnValue(captable);
@@
-      expect(result.success).toBe(true);
-      expect(result.message).toContain('✅ Updated issuance is_founder');
+      expect(result.success).toBe(true);
+      expect(result.message).toContain('✅ Updated issuance is_founder');
+      expect(captable.issuances[0].qty).toBe(7_000_000);
+      expect((result.data as Issuance).qty).toBe(7_000_000);

457-466: Likewise assert PPS update concretely

-      mockLoad.mockReturnValue(createMockCaptable());
+      const captable = createMockCaptable();
+      mockLoad.mockReturnValue(captable);
@@
-      expect(result.success).toBe(true);
-      expect(result.message).toContain('✅ Updated issuance is_founder');
+      expect(result.success).toBe(true);
+      expect(result.message).toContain('✅ Updated issuance is_founder');
+      expect(captable.issuances[0].pps).toBe(0.5);
+      expect((result.data as Issuance).pps).toBe(0.5);

468-479: Verify both fields when updating quantity and price

-      mockLoad.mockReturnValue(createMockCaptable());
+      const captable = createMockCaptable();
+      mockLoad.mockReturnValue(captable);
@@
-      expect(result.success).toBe(true);
-      expect(result.message).toContain('✅ Updated issuance is_founder');
+      expect(result.success).toBe(true);
+      expect(result.message).toContain('✅ Updated issuance is_founder');
+      expect(captable.issuances[0].qty).toBe(7_000_000);
+      expect(captable.issuances[0].pps).toBe(0.5);

535-543: Deletion test: assert the issuance is actually removed

Currently we only assert the message and side-effects. Also check the captable state after deletion.

-      mockLoad.mockReturnValue(createMockCaptable());
+      const captable = createMockCaptable();
+      mockLoad.mockReturnValue(captable);
@@
       expect(result.success).toBe(true);
       expect(result.message).toContain('✅ Deleted issuance is_founder');
       expect(mockSave).toHaveBeenCalled();
       expect(mockLogAction).toHaveBeenCalled();
+      expect(captable.issuances.find((i) => i.id === 'is_founder')).toBeUndefined();

158-182: Stabilize “current date” assertion

This assertion is good; to make it fully deterministic, freeze time with Vitest’s fake timers so test suites don’t break around midnight UTC.

Example:

vi.setSystemTime(new Date('2025-01-02T12:00:00Z'));
...
expect(mockCreateIssuance).toHaveBeenCalledWith(
  'sh_founder',
  'sc_common',
  1000000,
  undefined,
  '2025-01-02'
);
vi.useRealTimers();

220-238: Edge-case: add tests for non-numeric or negative qty/pps

Handlers parse strings via parseInt/parseFloat but don’t explicitly validate NaN/negative. Add tests to pin expected failures for:

  • qty: 'abc', '-100', '0'
  • pps: '-0.01'
  • date: badly formatted strings

I can add a test trio covering these cases and propose validation in the handler that returns a clear error message before mutating state. Want a patch?

src/handlers/index.ts (1)

11-18: Optional: prefer explicit named exports over star to avoid accidental API surfacing

If internal-only symbols exist in any handler module, star-exports can leak them. Consider explicit re-exports long-term.

No immediate change required; this is a design suggestion for API hygiene.

src/handlers/stakeholder.handlers.test.ts (4)

17-23: Remove unused mocks to reduce noise and accidental coupling.

formatTable and hasStakeholderHoldings are mocked but never exercised by the implementation under test. This adds cognitive load and can mislead future readers into thinking these helpers are part of the handlers' behavior.

Apply this diff to drop the unused mocks and related local aliases:

 vi.mock('../services/helpers.js', () => ({
   createStakeholder: vi.fn(),
   logAction: vi.fn(),
-  formatTable: vi.fn(),
   getStakeholderHoldings: vi.fn(),
-  hasStakeholderHoldings: vi.fn(),
 }))
 
 // ...
-const mockFormatTable = helpers.formatTable as Mock;
 const mockGetStakeholderHoldings = helpers.getStakeholderHoldings as Mock;
-const mockHasStakeholderHoldings = helpers.hasStakeholderHoldings as Mock;

Also applies to: 44-46


212-221: Avoid stubbing helpers not used by the handler; assert directly on output.

handleStakeholderList renders its own table and does not call helpers.formatTable. The stub here is unused and can be removed.

-  mockFormatTable.mockReturnValue('📋 Stakeholders\n\nNo stakeholders found.');

468-492: Assert forced delete also removes holdings.

When --force is used, the handler purges issuances/grants/SAFEs for the stakeholder. Strengthen the test by asserting the saved model no longer contains those records.

Add the following expectations after the current assertions:

const savedModel = (mockSave.mock.calls[0] as any[])[0];
expect(savedModel.issuances.every((i: any) => i.stakeholderId !== 'sh_existing')).toBe(true);
expect(savedModel.optionGrants.every((g: any) => g.stakeholderId !== 'sh_existing')).toBe(true);
expect(savedModel.safes.every((s: any) => s.stakeholderId !== 'sh_existing')).toBe(true);

281-288: Message assertions: prefer exact key phrases that won’t drift.

You currently assert with broad substrings like “Please provide a stakeholder”. The handlers return “❌ Please provide a stakeholder ID or email”. Matching the exact phrase reduces false positives if other messages include the same fragment.

Also applies to: 387-394, 518-525

src/handlers/export.handlers.ts (3)

84-89: Prefer unknown in catch blocks and normalize error messages.

Using any in catches weakens type safety. Use unknown and extract a string safely.

-  } catch (error: any) {
-    return {
-      success: false,
-      message: `❌ Error: ${error.message}`,
-    };
+  } catch (error: unknown) {
+    const msg = error instanceof Error ? error.message : String(error);
+    return { success: false, message: `❌ Error: ${msg}` };
   }

Apply the same change to handleExportJson.

Also applies to: 117-122


51-66: Option pool labeling may be ambiguous with multiple pools.

All option grants are reported against the first OPTION_POOL found. If multiple pools exist (common in later-stage companies), the CSV will misattribute grants. Consider persisting an optionPoolId on OptionGrant (helpers already accept it) and resolving labels by that ID during export.

Would you like a follow-up PR to thread optionPoolId through the model and handlers?


92-116: JSON export: optionally include a newline for POSIX tools.

A trailing newline improves UX when piping to CLI tools. Non-blocking.

-      fs.writeFileSync(opts.output, json);
+      fs.writeFileSync(opts.output, json + '\n');
src/identifier-resolver.test.ts (2)

127-135: Test name/comment don’t match the code path (not actually “fallback”).

'sh_alice' satisfies isPrefixedId, so the resolver’s ID branch runs, not the fallback. Rename the test to reflect the actual path to avoid confusion.

-    it('should resolve stakeholder by fallback search (ID or email)', () => {
+    it('should resolve stakeholder by prefixed ID (non-email)', () => {
-      // Test with ID that doesn't match isPrefixedId pattern
+      // Prefixed ID path (isPrefixedId === true)

280-288: Add a load invocation assertion to “suggest similar” tests.

Verifies the resolver reads the expected file in suggestion flows as well.

expect(mockLoad).toHaveBeenCalledWith('captable.json');

Also applies to: 319-325, 343-351

src/services/helpers.test.ts (1)

289-297: Tighten vested-options expectation for the 12‑month cliff case.

If the model defines a standard 1-year cliff with monthly vesting, at 2025-01-01 you can assert exactly 25% (25,000) to catch regressions. If rounding varies, assert a precise range (e.g., 24,999–25,001).

src/handlers/report.handlers.test.ts (1)

241-265: Ownership percentages rely on rounding; keep inputs stable.

You assert 99.69%/0.31%. If upstream rounding or locale formatting changes, this can become flaky. Consider asserting numeric values from the JSON mode for robustness, and only sanity-check formatting in table mode.

src/handlers/system.handlers.test.ts (3)

413-427: Type-safety nit: undefined array fields violate FileModel shape; prefer Partial typing.

You’re intentionally simulating missing arrays, but the literal object breaks the declared schema (arrays → undefined). To keep TS happy and make intent explicit, type this as Partial and cast at the return site.

-      const captableWithUndefined = {
-        ...mockCaptable,
-        issuances: undefined,
-        optionGrants: undefined,
-        safes: undefined,
-      };
+      const captableWithUndefined: Partial<FileModel> = {
+        ...mockCaptable,
+        issuances: undefined,
+        optionGrants: undefined,
+        safes: undefined,
+      };
+      // Cast at the usage site to keep call sites unchanged
-      mockLoad.mockReturnValue(captableWithUndefined);
+      mockLoad.mockReturnValue(captableWithUndefined as FileModel);

429-446: Align “file not found” mocking with store.load semantics.

store.load throws when the file is absent (it never returns null). Mocking a thrown error better reflects production behavior and prevents drift between tests and implementation.

-      mockLoad.mockReturnValue(null);
+      mockLoad.mockImplementation(() => {
+        throw new Error('File not found: captable.json');
+      });

If handlers map “file not found” exceptions to the friendly ‘❌ No captable.json found’ message, existing assertions can remain unchanged.


610-617: Same here: prefer throwing for “missing captable.”

Mirror store.load behavior by throwing instead of returning null.

-      mockLoad.mockReturnValue(null);
+      mockLoad.mockImplementation(() => {
+        throw new Error('File not found: captable.json');
+      });
README.md (3)

379-389: Remove trailing punctuation from heading (markdownlint MD026).

There’s a trailing colon in the “Simulating Different Conversion Scenarios:” heading that triggers MD026. Removing it avoids lint noise.

-#### Simulating Different Conversion Scenarios:
+#### Simulating Different Conversion Scenarios

187-202: Minor consistency nit: identifier examples.

Examples mix human-friendly aliases (sh_alice/sh_bob) and emails/IDs. Consider adding a one-liner explaining that identifiers accept ID or email everywhere (stakeholder, issuance, safe, grant) to reduce beginner confusion.

I can draft a short “Identifier resolution” blurb and insert it under Usage if you’d like.


145-165: Optional: pipe vs flag in export example.

You show “export csv > captable.csv” here while Export Commands prefer --output. Consider mirroring the flag approach for consistency, with a note that shell redirection also works.

-# 4. Export to CSV
-captan export csv > captable.csv
+# 4. Export to CSV
+captan export csv --output captable.csv
+# (Alternatively, use shell redirection: captan export csv > captable.csv)
src/handlers/grant.handlers.test.ts (2)

335-350: Assertion robustness: vested computation timestamp.

You pass a string date to calculateVestedOptions; depending on helper signature it might expect a Date. If the helper accepts Date, consider asserting with expect.anything() or passing a real Date to avoid future type drift.

-      expect(mockCalculateVestedOptions).toHaveBeenCalledWith(mockGrant, expect.any(String));
+      expect(mockCalculateVestedOptions).toHaveBeenCalledWith(
+        mockGrant,
+        expect.anything() // date or ISO string depending on helper
+      );

567-575: Negative-path completeness: add malformed updates test.

You already test “no updates provided.” Consider adding a case where exercise or vestingStart are present but malformed (e.g., NaN price or invalid date) to ensure handlers return a clear error.

I can add a test skeleton using invalid inputs and propose expected messages consistent with other handlers.

src/handlers/security.handlers.test.ts (2)

270-289: Show details: consider asserting issuances list formatting.

You already assert high-level fields. Optionally include an expectation that an issuance line includes qty, stakeholder name, and $pps when present to catch formatting regressions.


473-501: Option pool deletion side-effect is covered; add explicit check for grants cleared.

Since the handler clears grants when deleting the last pool, assert optionGrants length becomes 0 in the returned data (if exposed) or by inspecting the saved model argument in mockSave.

I can update the test to capture the model passed to save() and assert optionGrants was emptied.

src/handlers/safe.handlers.test.ts (2)

122-137: Confirm discount units passed into createSAFE.

The test asserts createSAFE receives 15 (percent), while the SAFE model stores discount as 0.15 (decimal). That’s fine if helpers.createSAFE expects a percent and converts internally. Please confirm and document the helper’s expected units to prevent future confusion.

Would you like me to add a short JSDoc comment to createSAFE in helpers.ts clarifying the expected units?


431-449: handleSafeShow “conversion scenarios” are illustrative; avoid implying precise math.

The handler’s “At cap” estimate assumes a fixed 10M share count. Consider adding a test asserting that the section is labeled as an estimate or, better, refactor the handler to reuse calculateSAFEConversions for consistency.

Happy to draft a patch that proxies show-time scenarios through calculateSAFEConversions to remove the hardcoded assumption.

src/handlers/security.handlers.ts (5)

23-30: Remove unreachable captable null-checks; rely on store.load throwing

store.load throws when captable.json is missing, so these if (!captable) branches are never reached and only add noise/inconsistency to error handling. Let the try/catch handle this uniformly.

Apply this pattern to each occurrence:

-    const captable = load('captable.json');
-    if (!captable) {
-      return {
-        success: false,
-        message: '❌ No captable.json found. Run "captan init" first.',
-      };
-    }
+    const captable = load('captable.json');

Also applies to: 70-76, 130-136, 204-210, 283-289


95-106: Locale-stable formatting for numbers in table output

toLocaleString() is environment-dependent and can produce non-deterministic thousands separators in different CI/OS locales. Consider locking to en-US (or a repo-wide constant) for deterministic output and tests.

Example:

-      const auth = sc.authorized.toLocaleString().padStart(14);
-      const iss = issued.toLocaleString().padStart(14);
+      const auth = sc.authorized.toLocaleString('en-US').padStart(14);
+      const iss = issued.toLocaleString('en-US').padStart(14);

Optionally add a helper like helpers.formatNumber(n: number) to centralize this.


160-162: Currency formatting for par value

Consider formatting currency consistently (e.g., fixed decimals).

-      output += `Par Value:  $${security.parValue}\n`;
+      output += `Par Value:  $${security.parValue.toFixed(2)}\n`;

318-324: Deleting last OPTION_POOL also purges all grants — confirm intent

Hard-deleting all grants when the last pool is removed is destructive and may surprise users. If this is intentional, it should be called out prominently in docs and require --force or an additional confirmation flag. Ideally grants would be tied to a specific pool via an optionPoolId to allow safe, scoped cleanup.

I can sketch a migration to add optionPoolId to grants and update handlers accordingly if desired.


60-65: Prefer unknown in catch and narrow to Error

Catching as any triggers lint warnings and weakens type safety.

-  } catch (error: any) {
-    return {
-      success: false,
-      message: `❌ Error: ${error.message}`,
-    };
+  } catch (error: unknown) {
+    const msg = error instanceof Error ? error.message : String(error);
+    return {
+      success: false,
+      message: `❌ Error: ${msg}`,
+    };
   }

Also applies to: 113-119, 184-189, 263-269, 340-345

src/identifier-resolver.ts (1)

42-49: Normalize and compare emails case-insensitively; trim input

Email addresses are case-insensitive; strict case comparisons can fail legitimate lookups. Also trim identifier to avoid whitespace issues. Keep ID comparisons case-sensitive.

-export function resolveStakeholder(identifier: string | undefined): ResolverResult {
+export function resolveStakeholder(identifier: string | undefined): ResolverResult {
   if (!identifier) {
     return {
       success: false,
       error: 'No identifier provided',
     };
   }
+  const input = identifier.trim();

@@
-  if (isEmail(identifier)) {
+  if (isEmail(input)) {
     // Lookup by email
-    stakeholder = captable.stakeholders.find((sh) => sh.email === identifier);
+    const email = input.toLowerCase();
+    stakeholder = captable.stakeholders.find(
+      (sh) => sh.email && sh.email.toLowerCase() === email
+    );
@@
-  } else if (isPrefixedId(identifier)) {
+  } else if (isPrefixedId(input)) {
     // Lookup by ID
-    stakeholder = captable.stakeholders.find((sh) => sh.id === identifier);
+    stakeholder = captable.stakeholders.find((sh) => sh.id === input);
@@
   } else {
     // Try both methods as fallback
     stakeholder = captable.stakeholders.find(
-      (sh) => sh.id === identifier || sh.email === identifier
+      (sh) =>
+        sh.id === input ||
+        (sh.email && sh.email.toLowerCase() === input.toLowerCase())
     );

Also applies to: 61-71, 82-86

src/handlers/issuance.handlers.ts (4)

56-62: Authorized check should run only after validated numbers; include available shares in error in a locale-stable way

Now that qty is validated, consider locking number formatting to a fixed locale for determinism.

-    if (currentIssued + qty > security.authorized) {
+    if (currentIssued + qty > security.authorized) {
       return {
         success: false,
-        message: `❌ Issuance would exceed authorized shares. Available: ${(security.authorized - currentIssued).toLocaleString()}`,
+        message: `❌ Issuance would exceed authorized shares. Available: ${(security.authorized - currentIssued).toLocaleString('en-US')}`,
       };
     }

145-153: Price formatting in list output

For readability, consider formatting pps with a fixed number of decimals and locale, e.g., toFixed(4) or Intl.NumberFormat.

-      const price = iss.pps ? `$${iss.pps}` : '-';
+      const price = iss.pps != null ? `$${Number(iss.pps).toLocaleString('en-US', { maximumFractionDigits: 6 })}` : '-';

203-206: Total value formatting: preserve cents and locale

toLocaleString on a raw product can drop cents. Prefer Intl.NumberFormat with decimals.

-      output += `Total Value:  $${(issuance.qty * issuance.pps).toLocaleString()}\n`;
+      output += `Total Value:  $${(issuance.qty * issuance.pps).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })}\n`;

88-94: Prefer unknown in catch and narrow to Error

Align with TS best practices to avoid any.

-  } catch (error: any) {
-    return {
-      success: false,
-      message: `❌ Error: ${error.message}`,
-    };
+  } catch (error: unknown) {
+    const msg = error instanceof Error ? error.message : String(error);
+    return {
+      success: false,
+      message: `❌ Error: ${msg}`,
+    };
   }

Also applies to: 161-165, 217-222, 304-309, 367-373

src/cli.test.ts (2)

47-56: Stabilize locale-dependent output in spawned CLI

Handlers use toLocaleString heavily; results can vary by OS/CI locale and make tests flaky. Set LANG/LC_ALL for the subprocess to ensure deterministic formatting.

-      const output = execSync(`node --import tsx "${cliPath}" ${args}`, {
+      const output = execSync(`node --import tsx "${cliPath}" ${args}`, {
         encoding: 'utf8',
         cwd: testDir,
         stdio: 'pipe',
+        env: {
+          ...process.env,
+          LANG: 'en_US.UTF-8',
+          LC_ALL: 'en_US.UTF-8',
+        },
       });

39-45: stripNoise may be unnecessary with direct node execution

Since tests invoke node directly (not npx), stripNoise likely never triggers. Keeping it is harmless; just noting for potential simplification.

src/handlers/report.handlers.ts (6)

18-24: Remove unreachable captable null-checks; rely on store.load throwing

Same pattern as other handlers: store.load throws on missing file, so these branches are dead. Simplify and let try/catch unify error messages.

-    const captable = load('captable.json');
-    if (!captable) {
-      return {
-        success: false,
-        message: '❌ No captable.json found. Run "captan init" first.',
-      };
-    }
+    const captable = load('captable.json');

Also applies to: 82-88, 178-184, 279-285


63-67: Locale-stable totals formatting

Lock number/currency formatting to a fixed locale to avoid CI flakiness.

-    output += `  Issued Shares: ${totalShares.toLocaleString()}\n`;
-    output += `  Granted Options: ${totalOptions.toLocaleString()}\n`;
-    output += `  SAFE Investment: $${totalSafes.toLocaleString()}\n`;
+    output += `  Issued Shares: ${totalShares.toLocaleString('en-US')}\n`;
+    output += `  Granted Options: ${totalOptions.toLocaleString('en-US')}\n`;
+    output += `  SAFE Investment: $${totalSafes.toLocaleString('en-US')}\n`;

126-147: Ownership table: consistent number formatting

Apply a fixed locale and consider fixed decimals for percentages.

-        const shares = o.shares.toLocaleString().padStart(12);
-        const options = o.vestedOptions.toLocaleString().padStart(12);
-        const outstanding = o.outstanding.toLocaleString().padStart(14);
+        const shares = o.shares.toLocaleString('en-US').padStart(12);
+        const options = o.vestedOptions.toLocaleString('en-US').padStart(12);
+        const outstanding = o.outstanding.toLocaleString('en-US').padStart(14);
@@
-    output += `${'Total'.padEnd(28)}  ${' '.repeat(12)}  ${' '.repeat(12)}  ${totalOutstanding.toLocaleString().padStart(14)}  100.00%\n`;
+    output += `${'Total'.padEnd(28)}  ${' '.repeat(12)}  ${' '.repeat(12)}  ${totalOutstanding.toLocaleString('en-US').padStart(14)}  100.00%\n`;

189-211: Stakeholder report: format currency/quantities deterministically

Minor readability nit: enforce consistent decimals and locale for amounts/shares.

-        output += `  • ${iss.date}: ${iss.qty.toLocaleString()} ${security?.label || 'shares'}`;
+        output += `  • ${iss.date}: ${iss.qty.toLocaleString('en-US')} ${security?.label || 'shares'}`;
@@
-        totalShares += iss.qty;
+        totalShares += iss.qty;
@@
-      output += `  Total: ${totalShares.toLocaleString()} shares\n\n`;
+      output += `  Total: ${totalShares.toLocaleString('en-US')} shares\n\n`;
@@
-        output += `  • ${safe.date}: $${safe.amount.toLocaleString()}`;
+        output += `  • ${safe.date}: $${safe.amount.toLocaleString('en-US')}`;

Also applies to: 236-247


295-301: Report-security: format numbers/currency with fixed locale

Ensure deterministic rendering across environments.

-    output += `Authorized: ${security.authorized.toLocaleString()}\n`;
+    output += `Authorized: ${security.authorized.toLocaleString('en-US')}\n`;
@@
-      output += `  Granted: ${totalGranted.toLocaleString()}\n`;
-      output += `  Remaining: ${remaining.toLocaleString()}\n`;
+      output += `  Granted: ${totalGranted.toLocaleString('en-US')}\n`;
+      output += `  Remaining: ${remaining.toLocaleString('en-US')}\n`;
@@
-      const totalIssued = issuances.reduce((sum, i) => sum + i.qty, 0);
+      const totalIssued = issuances.reduce((sum, i) => sum + i.qty, 0);
       const remaining = security.authorized - totalIssued;
@@
-      output += `  Issued: ${totalIssued.toLocaleString()}\n`;
-      output += `  Remaining: ${remaining.toLocaleString()}\n`;
+      output += `  Issued: ${totalIssued.toLocaleString('en-US')}\n`;
+      output += `  Remaining: ${remaining.toLocaleString('en-US')}\n`;

Also applies to: 313-337, 339-349


72-78: Prefer unknown in catch and narrow to Error

Replace any in catch for stronger typing and to satisfy static analysis.

-  } catch (error: any) {
-    return {
-      success: false,
-      message: `❌ Error: ${error.message}`,
-    };
+  } catch (error: unknown) {
+    const msg = error instanceof Error ? error.message : String(error);
+    return {
+      success: false,
+      message: `❌ Error: ${msg}`,
+    };
   }

Also applies to: 154-158, 262-267, 358-363

src/handlers/system.handlers.ts (5)

84-90: Align schema version with wizard output or centralize it

Manual init writes version: 2 but the wizard path uses version: 1 (see buildModelFromWizard). This inconsistency can trigger unexpected “outdated version” warnings or downstream logic drift.

-      version: 2,
+      version: 1,

Follow-up: Introduce a single source of truth (e.g., export const SCHEMA_VERSION = 1) and use it in both paths.


69-75: Normalize entity type more defensively

.replace('-', '_') only replaces the first hyphen. Use a global replacement and default consistently.

-    const entityType = opts.type?.toUpperCase().replace('-', '_') || 'C_CORP';
+    const entityType = (opts.type ?? 'C_CORP').toUpperCase().replace(/-/g, '_');

106-112: Option pool calculation semantics diverge from the wizard

Wizard computes pool as a percent of fully-diluted (derived from founder shares), while manual init uses a percent of authorized. This creates material discrepancies for the same inputs.

Consider moving pool creation after founders and using the fully-diluted formula. Example:

-    // Create option pool if percentage specified
-    if (poolPct > 0) {
-      const poolSize = Math.floor(authorized * (poolPct / 100));
-      const optionPool = helpers.createSecurityClass('OPTION_POOL', 'Stock Option Pool', poolSize);
-      captable.securityClasses.push(optionPool);
-    }
+    // Create option pool as a % of fully diluted (after founders), fall back to % of authorized if no founders
+    if (poolPct > 0) {
+      const totalFounderShares = captable.issuances
+        .filter((i) => i.securityClassId === commonStock.id)
+        .reduce((sum, i) => sum + i.qty, 0);
+      const poolSize =
+        totalFounderShares > 0
+          ? Math.floor((totalFounderShares * poolPct) / (100 - poolPct)) // FD pool sizing
+          : Math.floor(authorized * (poolPct / 100));
+      if (poolSize > 0) {
+        const optionPool = helpers.createSecurityClass('OPTION_POOL', 'Stock Option Pool', poolSize);
+        captable.securityClasses.push(optionPool);
+      }
+    }

Also applies to: 113-143


268-275: Make audit log action filter case-insensitive and validate limit

Currently only the filter term is uppercased; if stored actions vary in case, matches may be missed. Also, validate --limit.

-    if (opts.action) {
-      logs = logs.filter((log: any) => log.action === opts.action!.toUpperCase());
-    }
+    if (opts.action) {
+      const actionFilter = opts.action.toString().trim().toUpperCase();
+      logs = logs.filter((log: any) => String(log.action).toUpperCase() === actionFilter);
+    }
-
-    const limit = parseInt(opts.limit || '20');
+    const limit = parseInt(opts.limit ?? '20', 10);
+    if (!Number.isFinite(limit) || limit <= 0) {
+      return { success: false, message: '❌ --limit must be a positive integer' };
+    }
     logs = logs.slice(-limit);

53-58: Prefer unknown over any in catch blocks

Use unknown to avoid disabling type safety and normalize message extraction.

Example pattern (apply to all marked catch blocks):

-  } catch (error: any) {
-    return {
-      success: false,
-      message: `❌ Error: ${error.message}`,
-    };
-  }
+  } catch (error: unknown) {
+    const msg = error instanceof Error ? error.message : String(error);
+    return {
+      success: false,
+      message: `❌ Error: ${msg}`,
+    };
+  }

Also applies to: 161-166, 221-226, 247-252, 295-300

src/handlers/safe.handlers.ts (2)

221-228: Avoid misleading conversion math in SAFE “show” (assumes 10M shares)

Showing “~shares at cap” using safe.cap/10,000,000 is arbitrary and wrong without the actual round price/share or capitalization. Prefer guidance to run the convert preview.

-    if (safe.cap) {
-      const sharesAtCap = Math.floor(safe.amount / (safe.cap / 10000000)); // Assuming 10M shares
-      output += `  At cap:         ~${sharesAtCap.toLocaleString()} shares\n`;
-    }
-    if (safe.discount) {
-      output += `  With discount:  Price reduced by ${(safe.discount * 100).toFixed(0)}%\n`;
-    }
+    if (safe.cap) {
+      output += `  Valuation cap present — run "captan safe convert --pps <price> --pre-money <amount> --dry-run" for exact shares.\n`;
+    }
+    if (safe.discount) {
+      output += `  Discount:       ${(safe.discount * 100).toFixed(0)}% (applied against round price)\n`;
+    }

174-175: Type tightening: replace unused any parameters and catch any

  • Change unused _opts: any parameters to unknown or remove them.
  • Prefer unknown in catch blocks and normalize message extraction.

Examples (apply to all marked locations):

-export function handleSafeShow(id: string | undefined, _opts: any): HandlerResult {
+export function handleSafeShow(id: string | undefined, _opts?: unknown): HandlerResult {
-  } catch (error: any) {
+  } catch (error: unknown) {
     return {
       success: false,
-      message: `❌ Error: ${error.message}`,
+      message: `❌ Error: ${error instanceof Error ? error.message : String(error)}`,
     };
   }

Also applies to: 199-199, 258-258, 328-328, 394-394, 489-489

src/handlers/stakeholder.handlers.ts (3)

36-44: Make email uniqueness checks case-insensitive

Email comparisons should be case-insensitive to avoid duplicates like Alice@Example.com vs alice@example.com.

-    if (opts.email) {
-      const existing = captable.stakeholders.find((sh) => sh.email === opts.email);
+    if (opts.email) {
+      const emailNorm = opts.email.toLowerCase();
+      const existing = captable.stakeholders.find((sh) => sh.email?.toLowerCase() === emailNorm);
       if (existing) {
         return {
           success: false,
           message: `❌ Stakeholder with email ${opts.email} already exists (${existing.id})`,
         };
       }
     }

256-271: Case-insensitive email conflict check on update

Mirror the add-path logic here to prevent subtle duplicates.

-    if (opts.email !== undefined) {
+    if (opts.email !== undefined) {
       // Check for duplicate email
-      if (opts.email) {
-        const existing = captable.stakeholders.find(
-          (sh) => sh.email === opts.email && sh.id !== stakeholder.id
-        );
+      if (opts.email) {
+        const emailNorm = opts.email.toLowerCase();
+        const existing = captable.stakeholders.find(
+          (sh) => sh.email?.toLowerCase() === emailNorm && sh.id !== stakeholder.id
+        );
         if (existing) {
           return {
             success: false,
             message: `❌ Email ${opts.email} is already used by ${existing.name} (${existing.id})`,
           };
         }
       }
       stakeholder.email = opts.email;

124-125: Tighten types and catches: remove any, use unknown

  • handleStakeholderShow’s _opts is unused; type as unknown or omit.
  • Use unknown in catch blocks and extract message safely.

Examples (apply to all marked locations):

-export function handleStakeholderShow(idOrEmail: string | undefined, _opts: any): HandlerResult {
+export function handleStakeholderShow(idOrEmail: string | undefined, _opts?: unknown): HandlerResult {
-  } catch (error: any) {
+  } catch (error: unknown) {
     return {
       success: false,
-      message: `❌ Error: ${error.message}`,
+      message: `❌ Error: ${error instanceof Error ? error.message : String(error)}`,
     };
   }

Also applies to: 65-70, 116-121, 205-210, 294-299, 379-383

src/handlers/grant.handlers.ts (1)

199-201: Remove any for unused params and catches

  • Change _opts: any to unknown or omit.
  • Use unknown in catch and normalize message extraction.

Examples:

-export function handleGrantShow(id: string | undefined, _opts: any): HandlerResult {
+export function handleGrantShow(id: string | undefined, _opts?: unknown): HandlerResult {
-  } catch (error: any) {
+  } catch (error: unknown) {
     return {
       success: true,
-      message: `❌ Error: ${error.message}`,
+      message: `❌ Error: ${error instanceof Error ? error.message : String(error)}`,
     };
   }

Also applies to: 117-122, 191-196, 258-263, 329-333, 395-399

src/cli.ts (1)

172-176: Use angle brackets for required options in Commander help

For required options, prefer <arg> over [arg] so the help reflects reality.

-  .requiredOption('--stakeholder [id-or-email]', 'stakeholder ID or email')
-  .requiredOption('--security [id]', 'security class ID')
+  .requiredOption('--stakeholder <id-or-email>', 'stakeholder ID or email')
+  .requiredOption('--security <id>', 'security class ID')
-  .requiredOption('--stakeholder [id-or-email]', 'stakeholder ID or email')
+  .requiredOption('--stakeholder <id-or-email>', 'stakeholder ID or email')
-  .requiredOption('--stakeholder [id-or-email]', 'stakeholder ID or email')
+  .requiredOption('--stakeholder <id-or-email>', 'stakeholder ID or email')

Also applies to: 247-255, 326-333

src/services/helpers.ts (5)

24-35: Normalize stakeholder identifiers (trim/lowercase email) for reliable resolution

Improves matching by ensuring consistent casing/spacing and avoids persisting empty strings as emails.

 export function createStakeholder(
   name: string,
   email: string,
   entityType: 'PERSON' | 'ENTITY' = 'PERSON'
 ): Stakeholder {
-  return {
+  const normalizedName = name.trim();
+  const normalizedEmail =
+    email && email.trim() !== '' ? email.trim().toLowerCase() : undefined;
+  return {
     id: `sh_${randomUUID()}`,
-    name,
-    email: email || undefined,
+    name: normalizedName,
+    email: normalizedEmail,
     type: entityType === 'ENTITY' ? 'entity' : 'person',
   };
 }

41-55: Add basic input validation for security class (authorized/parValue)

Prevents obviously invalid state (negative or NaN authorized/parValue) early.

 export function createSecurityClass(
   kind: 'COMMON' | 'PREFERRED' | 'OPTION_POOL',
   label: string,
   authorized: number,
   parValue?: number
 ): SecurityClass {
   const mappedKind = kind === 'PREFERRED' ? 'PREF' : kind;
+  if (!Number.isFinite(authorized) || authorized < 0) {
+    throw new Error(`authorized must be a non-negative number, got ${authorized}`);
+  }
+  if (parValue != null && (!Number.isFinite(parValue) || parValue < 0)) {
+    throw new Error(`parValue must be >= 0, got ${parValue}`);
+  }
   return {
     id: `sc_${randomUUID()}`,
     kind: mappedKind as 'COMMON' | 'PREF' | 'OPTION_POOL',
     label,
     authorized,
     parValue,
   };
 }

69-86: Guard issuance inputs (qty, price) and normalize certificate

Pre-validate to avoid corrupt cap tables; keep cert empty strings out.

 export function createIssuance(
   stakeholderId: string,
   securityClassId: string,
   qty: number,
   pricePerShare?: number,
   date: string = new Date().toISOString().slice(0, 10),
   certificateNumber?: string
 ): Issuance {
+  if (!Number.isFinite(qty) || qty <= 0) {
+    throw new Error(`qty must be a positive number, got ${qty}`);
+  }
+  if (pricePerShare != null && (!Number.isFinite(pricePerShare) || pricePerShare < 0)) {
+    throw new Error(`pricePerShare must be >= 0, got ${pricePerShare}`);
+  }
+  const cert = certificateNumber?.trim() || undefined;
   return {
     id: `is_${randomUUID()}`,
     stakeholderId,
     securityClassId,
     qty,
     pps: pricePerShare,
     date,
-    cert: certificateNumber,
+    cert,
   };
 }

124-143: Clamp and normalize SAFE discount to [0, 100]%

Prevents negative/over-100 inputs from producing zero/negative conversion prices.

 export function createSAFE(
   stakeholderId: string,
   amount: number,
   valuationCap?: number,
   discountPct?: number,
   isPostMoney: boolean = false,
   date: string = new Date().toISOString().slice(0, 10),
   note?: string
 ): SAFE {
+  const normalizedDiscount =
+    typeof discountPct === 'number'
+      ? Math.max(0, Math.min(discountPct, 100)) / 100
+      : undefined;
   return {
     id: `safe_${randomUUID()}`,
     stakeholderId,
     amount,
     cap: valuationCap,
-    discount: discountPct ? discountPct / 100 : undefined, // Convert to 0-1 range
+    discount: normalizedDiscount, // 0..1 range
     type: isPostMoney ? 'post' : 'pre',
     date,
     note,
   };
 }

241-264: Allow structured audit details (not just string) for richer logs

This keeps logs future-proof and avoids stringifying JSON at call sites.

 export function logAction(
   captable: Captable,
   entry: {
     action: string;
     entity: string;
     entityId: string;
-    details: string;
+    details: string | Record<string, unknown>;
   }
 ): void {
   if (!captable.audit) {
     captable.audit = [];
   }
 
   captable.audit.push({
     ts: new Date().toISOString(),
     by: 'captan-cli',
     action: entry.action,
     data: {
       entity: entry.entity,
       entityId: entry.entityId,
       details: entry.details,
     },
   });
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 067376d and c2bdbed.

📒 Files selected for processing (26)
  • README.md (7 hunks)
  • src/cli.test.ts (13 hunks)
  • src/cli.ts (4 hunks)
  • src/handlers/export.handlers.test.ts (1 hunks)
  • src/handlers/export.handlers.ts (1 hunks)
  • src/handlers/grant.handlers.test.ts (1 hunks)
  • src/handlers/grant.handlers.ts (1 hunks)
  • src/handlers/index.ts (1 hunks)
  • src/handlers/issuance.handlers.test.ts (1 hunks)
  • src/handlers/issuance.handlers.ts (1 hunks)
  • src/handlers/report.handlers.test.ts (1 hunks)
  • src/handlers/report.handlers.ts (1 hunks)
  • src/handlers/safe.handlers.test.ts (1 hunks)
  • src/handlers/safe.handlers.ts (1 hunks)
  • src/handlers/security.handlers.test.ts (1 hunks)
  • src/handlers/security.handlers.ts (1 hunks)
  • src/handlers/stakeholder.handlers.test.ts (1 hunks)
  • src/handlers/stakeholder.handlers.ts (1 hunks)
  • src/handlers/system.handlers.test.ts (1 hunks)
  • src/handlers/system.handlers.ts (1 hunks)
  • src/handlers/types.ts (1 hunks)
  • src/identifier-resolver.test.ts (1 hunks)
  • src/identifier-resolver.ts (1 hunks)
  • src/performance.test.ts (1 hunks)
  • src/services/helpers.test.ts (1 hunks)
  • src/services/helpers.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (21)
src/handlers/grant.handlers.ts (3)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
src/handlers/safe.handlers.ts (3)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
src/handlers/issuance.handlers.ts (3)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
src/handlers/issuance.handlers.test.ts (4)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
src/model.ts (4)
  • Stakeholder (59-59)
  • SecurityClass (69-69)
  • Issuance (81-81)
  • FileModel (157-157)
src/handlers/issuance.handlers.ts (5)
  • handleIssuanceAdd (17-94)
  • handleIssuanceList (96-166)
  • handleIssuanceShow (168-223)
  • handleIssuanceUpdate (225-310)
  • handleIssuanceDelete (312-373)
src/identifier-resolver.test.ts (3)
src/store.ts (1)
  • load (5-14)
src/model.ts (2)
  • Stakeholder (59-59)
  • FileModel (157-157)
src/identifier-resolver.ts (8)
  • isEmail (24-28)
  • isPrefixedId (33-37)
  • resolveStakeholder (42-99)
  • resolveStakeholders (104-126)
  • getIdentifierDisplay (132-140)
  • validateIdentifier (145-176)
  • suggestSimilarStakeholders (182-218)
  • formatStakeholderReference (223-229)
src/handlers/security.handlers.ts (2)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/handlers/grant.handlers.test.ts (3)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
src/handlers/grant.handlers.ts (5)
  • handleGrantAdd (17-123)
  • handleGrantList (125-197)
  • handleGrantShow (199-264)
  • handleGrantUpdate (266-334)
  • handleGrantDelete (336-400)
src/handlers/system.handlers.ts (5)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (3)
  • exists (39-41)
  • save (16-19)
  • load (5-14)
src/init-wizard.ts (2)
  • runInitWizard (24-183)
  • buildModelFromWizard (231-303)
src/model.ts (2)
  • FileModel (157-157)
  • FileModelSchema (132-155)
src/schema.ts (3)
  • validateCaptable (65-85)
  • validateCaptableExtended (112-285)
  • ValidationWarning (10-14)
src/handlers/export.handlers.test.ts (3)
src/store.ts (1)
  • load (5-14)
src/model.ts (5)
  • Stakeholder (59-59)
  • SecurityClass (69-69)
  • Issuance (81-81)
  • OptionGrant (92-92)
  • FileModel (157-157)
src/handlers/export.handlers.ts (3)
  • handleExportCsv (15-90)
  • handleExportJson (92-123)
  • handleExportPdf (125-130)
src/handlers/types.ts (1)
src/handlers/index.ts (1)
  • HandlerResult (8-8)
src/handlers/export.handlers.ts (2)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (1)
  • load (5-14)
src/handlers/report.handlers.ts (3)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (1)
  • load (5-14)
src/identifier-resolver.ts (1)
  • resolveStakeholder (42-99)
src/identifier-resolver.ts (2)
src/model.ts (1)
  • Stakeholder (59-59)
src/store.ts (1)
  • load (5-14)
src/handlers/report.handlers.test.ts (4)
src/store.ts (1)
  • load (5-14)
src/identifier-resolver.ts (1)
  • resolveStakeholder (42-99)
src/model.ts (6)
  • Stakeholder (59-59)
  • SecurityClass (69-69)
  • Issuance (81-81)
  • OptionGrant (92-92)
  • SAFE (105-105)
  • FileModel (157-157)
src/handlers/report.handlers.ts (4)
  • handleReportSummary (16-78)
  • handleReportOwnership (80-159)
  • handleReportStakeholder (161-268)
  • handleReportSecurity (270-363)
src/handlers/system.handlers.test.ts (4)
src/store.ts (3)
  • load (5-14)
  • save (16-19)
  • exists (39-41)
src/init-wizard.ts (2)
  • runInitWizard (24-183)
  • buildModelFromWizard (231-303)
src/schema.ts (2)
  • validateCaptable (65-85)
  • validateCaptableExtended (112-285)
src/model.ts (4)
  • SecurityClass (69-69)
  • Stakeholder (59-59)
  • Issuance (81-81)
  • FileModel (157-157)
src/services/helpers.test.ts (2)
src/services/helpers.ts (9)
  • createStakeholder (24-35)
  • createSecurityClass (41-55)
  • getIssuedShares (57-63)
  • createIssuance (69-86)
  • createOptionGrant (92-110)
  • calculateVestedOptions (112-118)
  • createSAFE (124-143)
  • calculateSAFEConversions (152-205)
  • getStakeholderHoldings (218-235)
src/model.ts (3)
  • FileModel (157-157)
  • Vesting (50-50)
  • OptionGrant (92-92)
src/handlers/security.handlers.test.ts (3)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/model.ts (2)
  • FileModel (157-157)
  • SecurityClass (69-69)
src/handlers/security.handlers.ts (4)
  • handleSecurityList (68-119)
  • handleSecurityShow (121-190)
  • handleSecurityUpdate (192-269)
  • handleSecurityDelete (271-346)
src/handlers/stakeholder.handlers.ts (3)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (3)
  • formatStakeholderReference (223-229)
  • resolveStakeholder (42-99)
  • suggestSimilarStakeholders (182-218)
src/handlers/safe.handlers.test.ts (4)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
src/model.ts (4)
  • Stakeholder (59-59)
  • SecurityClass (69-69)
  • SAFE (105-105)
  • FileModel (157-157)
src/handlers/safe.handlers.ts (6)
  • handleSafeAdd (18-95)
  • handleSafeList (97-172)
  • handleSafeShow (174-241)
  • handleSafeUpdate (243-313)
  • handleSafeDelete (315-372)
  • handleSafeConvert (374-494)
src/handlers/stakeholder.handlers.test.ts (4)
src/store.ts (2)
  • load (5-14)
  • save (16-19)
src/identifier-resolver.ts (3)
  • resolveStakeholder (42-99)
  • formatStakeholderReference (223-229)
  • suggestSimilarStakeholders (182-218)
src/model.ts (2)
  • FileModel (157-157)
  • Stakeholder (59-59)
src/handlers/stakeholder.handlers.ts (5)
  • handleStakeholderAdd (21-71)
  • handleStakeholderList (73-122)
  • handleStakeholderShow (124-211)
  • handleStakeholderUpdate (213-300)
  • handleStakeholderDelete (302-384)
src/services/helpers.ts (1)
src/model.ts (8)
  • FileModel (157-157)
  • Stakeholder (59-59)
  • SecurityClass (69-69)
  • Issuance (81-81)
  • Vesting (50-50)
  • OptionGrant (92-92)
  • vestedQty (226-237)
  • SAFE (105-105)
🪛 GitHub Check: test (20)
src/handlers/grant.handlers.ts

[warning] 394-394:
Unexpected any. Specify a different type


[warning] 328-328:
Unexpected any. Specify a different type


[warning] 258-258:
Unexpected any. Specify a different type


[warning] 199-199:
Unexpected any. Specify a different type


[warning] 191-191:
Unexpected any. Specify a different type


[warning] 117-117:
Unexpected any. Specify a different type

src/handlers/issuance.handlers.ts

[warning] 160-160:
Unexpected any. Specify a different type


[warning] 88-88:
Unexpected any. Specify a different type

src/handlers/export.handlers.ts

[warning] 117-117:
Unexpected any. Specify a different type


[warning] 84-84:
Unexpected any. Specify a different type

🪛 GitHub Check: test (22)
src/handlers/grant.handlers.ts

[warning] 394-394:
Unexpected any. Specify a different type


[warning] 328-328:
Unexpected any. Specify a different type


[warning] 258-258:
Unexpected any. Specify a different type


[warning] 199-199:
Unexpected any. Specify a different type


[warning] 191-191:
Unexpected any. Specify a different type


[warning] 117-117:
Unexpected any. Specify a different type

src/handlers/issuance.handlers.ts

[warning] 160-160:
Unexpected any. Specify a different type


[warning] 88-88:
Unexpected any. Specify a different type

src/handlers/export.handlers.ts

[warning] 117-117:
Unexpected any. Specify a different type


[warning] 84-84:
Unexpected any. Specify a different type

🪛 LanguageTool
README.md

[grammar] ~31-~31: There might be a mistake here.
Context: ...V/JSON for investors & lawyers --- ## 📖 Usage Captan uses a resource-oriented co...

(QB_NEW_EN)


[grammar] ~31-~31: There might be a mistake here.
Context: ...or investors & lawyers --- ## 📖 Usage Captan uses a resource-oriented command s...

(QB_NEW_EN)

🪛 markdownlint-cli2 (0.17.2)
README.md

379-379: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

Comment on lines +108 to +135
describe('handleExportCsv', () => {
it('should export cap table to CSV format with shares and options', () => {
mockLoad.mockReturnValue(mockCaptable);
mockCalculateVestedOptions.mockReturnValue(25000); // 25% vested

const result = handleExportCsv({});

expect(result.success).toBe(true);
const csvLines = result.message.split('\n');

// Check header
expect(csvLines[0]).toBe(
'Name,Email,Type,Security Class,Quantity,Price Per Share,Date,Vested'
);

// Check share issuance row
expect(csvLines[1]).toBe(
'Founder,founder@test.com,Shares,Common Stock,8000000,0.001,2024-01-01,8000000'
);

// Check option grant row
expect(csvLines[2]).toBe(
'Employee,employee@test.com,Options,Employee Option Pool,100000,0.1,2024-01-01,25000'
);

expect(mockCalculateVestedOptions).toHaveBeenCalledWith(mockGrant, expect.any(String));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Add CSV escaping test (commas/quotes) to catch real-world data issues

The CSV implementation joins with commas but doesn’t quote/escape. Add a test to lock desired behavior; currently, fields with commas/quotes will generate malformed CSV.

Proposed test to add (near existing CSV tests):

it('should escape commas and quotes in CSV fields', () => {
  const captable = {
    ...mockCaptable,
    stakeholders: [
      { ...mockStakeholder, name: 'Founder, Inc.' },             // comma
      { ...mockEmployee, name: 'Emp "Quoted" Name' },            // quotes
    ],
    issuances: [{ ...mockIssuance }],
    optionGrants: [{ ...mockGrant }],
  } as FileModel;

  mockLoad.mockReturnValue(captable);
  mockCalculateVestedOptions.mockReturnValue(25000);

  const result = handleExportCsv({});
  expect(result.success).toBe(true);
  const csv = result.message.split('\n');

  // Expect quotes around fields with commas/quotes and doubled quotes for embedded quotes
  expect(csv[1]).toMatch(/^"Founder, Inc.",founder@test\.com,Shares,/);
  expect(csv[2]).toMatch(/^"Emp ""Quoted"" Name",employee@test\.com,Options,/);
});

And refactor in handleExportCsv to properly escape:

-      rows.push(
-        [
-          holder?.name || '',
-          holder?.email || '',
-          'Shares',
-          security?.label || '',
-          iss.qty.toString(),
-          iss.pps?.toString() || '',
-          iss.date,
-          iss.qty.toString(),
-        ].join(',')
-      );
+      const csv = (v: string) => (/[,\"\n]/.test(v) ? `"${v.replace(/\"/g, '""')}"` : v);
+      rows.push(
+        [
+          csv(holder?.name || ''),
+          csv(holder?.email || ''),
+          'Shares',
+          csv(security?.label || ''),
+          iss.qty.toString(),
+          (iss.pps ?? '').toString(),
+          csv(iss.date),
+          iss.qty.toString(),
+        ].join(',')
+      );
🤖 Prompt for AI Agents
In src/handlers/export.handlers.test.ts around lines 108-135, add a test that
ensures CSV fields with commas or quotes are properly escaped and adjust the
implementation accordingly: insert the proposed "should escape commas and quotes
in CSV fields" test (using the provided captable with 'Founder, Inc.' and 'Emp
\"Quoted\" Name'), assert that fields with commas are wrapped in double quotes
and embedded quotes are doubled, and update handleExportCsv to escape output
values by wrapping any field containing a comma, quote, or newline in double
quotes and doubling internal double quotes before joining with commas so the
test passes.

Comment on lines +192 to +205
// Extended business rules validation
if (opts.extended) {
const extendedResult = validateCaptableExtended(captable);
if (!extendedResult.valid) {
let message = `⚠️ Business rule violations:\n`;
message += (extendedResult.warnings || [])
.map((w: ValidationWarning) => ` • ${w.message}`)
.join('\n');
return {
success: false,
message,
};
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix extended validation flow: errors vs. warnings are conflated and warnings alone incorrectly fail validation

When opts.extended is set, the current logic only surfaces warnings and always fails when valid is false. It never prints actual errors and also cannot return “passed with warnings.” This will confuse users and hide actionable errors.

Apply this diff to report errors distinctly, include warnings when present, and allow “success with warnings”:

-    // Extended business rules validation
-    if (opts.extended) {
-      const extendedResult = validateCaptableExtended(captable);
-      if (!extendedResult.valid) {
-        let message = `⚠️ Business rule violations:\n`;
-        message += (extendedResult.warnings || [])
-          .map((w: ValidationWarning) => `  • ${w.message}`)
-          .join('\n');
-        return {
-          success: false,
-          message,
-        };
-      }
-    }
+    // Extended business rules validation
+    if (opts.extended) {
+      const extendedResult = validateCaptableExtended(captable);
+      const errors = extendedResult.errors ?? [];
+      const warnings = extendedResult.warnings ?? [];
+
+      if (errors.length > 0) {
+        let message = `❌ Business rule violations:\n`;
+        message += errors.map((e) => `  • ${e}`).join('\n');
+        if (warnings.length > 0) {
+          message += `\n\nWarnings:\n`;
+          message += warnings.map((w: ValidationWarning) => `  • ${w.message}`).join('\n');
+        }
+        return { success: false, message };
+      }
+
+      if (warnings.length > 0) {
+        return {
+          success: true,
+          message:
+            `✅ Validation passed with warnings:\n` +
+            warnings.map((w: ValidationWarning) => `  • ${w.message}`).join('\n'),
+          data: { warnings },
+        };
+      }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Extended business rules validation
if (opts.extended) {
const extendedResult = validateCaptableExtended(captable);
if (!extendedResult.valid) {
let message = `⚠️ Business rule violations:\n`;
message += (extendedResult.warnings || [])
.map((w: ValidationWarning) => ` • ${w.message}`)
.join('\n');
return {
success: false,
message,
};
}
}
// Extended business rules validation
if (opts.extended) {
const extendedResult = validateCaptableExtended(captable);
const errors = extendedResult.errors ?? [];
const warnings = extendedResult.warnings ?? [];
if (errors.length > 0) {
let message = `❌ Business rule violations:\n`;
message += errors.map((e) => ` • ${e}`).join('\n');
if (warnings.length > 0) {
message += `\n\nWarnings:\n`;
message += warnings
.map((w: ValidationWarning) => ` • ${w.message}`)
.join('\n');
}
return { success: false, message };
}
if (warnings.length > 0) {
return {
success: true,
message:
`✅ Validation passed with warnings:\n` +
warnings.map((w: ValidationWarning) => ` • ${w.message}`).join('\n'),
data: { warnings },
};
}
}
🤖 Prompt for AI Agents
In src/handlers/system.handlers.ts around lines 192 to 205, the extended
validation currently conflates errors and warnings and always fails when
extendedResult.valid is false; change the flow to (1) check
extendedResult.errors first: if any errors exist return success: false with a
message listing errors and include warnings if present, (2) if no errors but
warnings exist return success: true with a message indicating "Passed with
warnings" and list those warnings, and (3) if neither errors nor warnings exist
return success: true. Ensure messages clearly label "Errors" vs "Warnings" and
use extendedResult.errors and extendedResult.warnings arrays (typed
appropriately) when building the returned message.

Comment on lines +50 to +56
const captable = load('captable.json');
if (!captable) {
return {
success: false,
error: 'No captable.json found',
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Handle store.load errors inside resolver for consistent UX

store.load throws on missing captable.json; this bubbles out as a thrown error and yields a generic "Error:" wrapper upstream. Catch here and return a consistent "No captable.json found" message like other handlers.

-  const captable = load('captable.json');
-  if (!captable) {
-    return {
-      success: false,
-      error: 'No captable.json found',
-    };
-  }
+  let captable;
+  try {
+    captable = load('captable.json');
+  } catch {
+    return {
+      success: false,
+      error: 'No captable.json found',
+    };
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const captable = load('captable.json');
if (!captable) {
return {
success: false,
error: 'No captable.json found',
};
}
let captable;
try {
captable = load('captable.json');
} catch {
return {
success: false,
error: 'No captable.json found',
};
}
🤖 Prompt for AI Agents
In src/identifier-resolver.ts around lines 50 to 56, the current call to
load('captable.json') can throw when the file is missing which bubbles up as a
thrown error; wrap the load call in a try/catch and when load throws return the
same structured error response ({ success: false, error: 'No captable.json
found' }) instead of allowing the exception to propagate, ensuring consistent UX
with other handlers.

Comment on lines +92 to +110
export function createOptionGrant(
stakeholderId: string,
optionPoolId: string, // Not stored but used for validation
qty: number,
exercisePrice: number,
date: string = new Date().toISOString().slice(0, 10),
vesting?: Vesting
): OptionGrant {
// Note: optionPoolId is not part of the OptionGrant model
// It's only used for validation during creation
return {
id: `og_${randomUUID()}`,
stakeholderId,
qty,
exercise: exercisePrice,
grantDate: date,
vesting,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Use underscore for unused optionPoolId param and validate inputs; confirm OptionGrant schema field names

  • Rename the unused parameter to avoid TS noUnusedParameters issues.
  • Validate qty/exercisePrice.
  • Double-check OptionGrantSchema uses fields exercise and grantDate (vs. exercisePrice/date) to avoid silent mismatches.
 export function createOptionGrant(
   stakeholderId: string,
-  optionPoolId: string, // Not stored but used for validation
+  _optionPoolId: string, // Not stored but used for validation
   qty: number,
   exercisePrice: number,
   date: string = new Date().toISOString().slice(0, 10),
   vesting?: Vesting
 ): OptionGrant {
   // Note: optionPoolId is not part of the OptionGrant model
   // It's only used for validation during creation
+  if (!Number.isFinite(qty) || qty <= 0) {
+    throw new Error(`qty must be a positive number, got ${qty}`);
+  }
+  if (!Number.isFinite(exercisePrice) || exercisePrice < 0) {
+    throw new Error(`exercisePrice must be >= 0, got ${exercisePrice}`);
+  }
   return {
     id: `og_${randomUUID()}`,
     stakeholderId,
     qty,
     exercise: exercisePrice,
     grantDate: date,
     vesting,
   };
 }

Run this to verify schema field names and usages:


🏁 Script executed:

#!/bin/bash
# Expect OptionGrantSchema to define 'exercise' and 'grantDate'; flag if not found.
rg -n -C3 "OptionGrantSchema" --type=ts
rg -n -C2 "\bexercise\b|\bgrantDate\b" src/model.ts
# Also inspect all createOptionGrant call sites for assumptions about field names.
rg -n -C2 "createOptionGrant\s*\(" --type=ts

Length of output: 4360


Action Required: Align OptionGrantSchema with createOptionGrant & add input validation

The Zod schema for OptionGrant in src/model.ts currently omits the exercise and grantDate fields, so any values produced by your helper are stripped during validation. This will silently drop critical data when you call OptionGrantSchema.parse(grant) in the equity service. Please address the following:

• Rename the unused parameter to avoid TS no-unused-parameters:

-export function createOptionGrant(
-  stakeholderId: string,
-  optionPoolId: string, // Not stored but used for validation
+export function createOptionGrant(
+  stakeholderId: string,
+  _optionPoolId: string, // Not stored but used for validation
   qty: number,
   exercisePrice: number,
   date: string = new Date().toISOString().slice(0, 10),
   vesting?: Vesting
 ): OptionGrant {
   // Note: _optionPoolId is only used for validation on creation
+  if (!Number.isFinite(qty) || qty <= 0) {
+    throw new Error(`qty must be a positive number, got ${qty}`);
+  }
+  if (!Number.isFinite(exercisePrice) || exercisePrice < 0) {
+    throw new Error(`exercisePrice must be >= 0, got ${exercisePrice}`);
+  }
   return {
     id: `og_${randomUUID()}`,
     stakeholderId,
     qty,
     exercise: exercisePrice,
     grantDate: date,
     vesting,
   };
 }

• Update the Zod schema in src/model.ts to include those fields, so OptionGrantSchema.parse() preserves them:

 export const OptionGrantSchema = z.object({
   id: PrefixedIdSchema.describe('Unique option grant identifier'),
   stakeholderId: PrefixedIdSchema.describe('Reference to stakeholder'),
   qty: z.number().positive().describe('Number of options granted'),
+  exercise: z.number().positive().describe('Exercise price per option'),
+  grantDate: ISODateSchema.describe('Grant date in YYYY-MM-DD format'),
   vesting: VestingSchema.optional().describe('Vesting schedule'),
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createOptionGrant(
stakeholderId: string,
optionPoolId: string, // Not stored but used for validation
qty: number,
exercisePrice: number,
date: string = new Date().toISOString().slice(0, 10),
vesting?: Vesting
): OptionGrant {
// Note: optionPoolId is not part of the OptionGrant model
// It's only used for validation during creation
return {
id: `og_${randomUUID()}`,
stakeholderId,
qty,
exercise: exercisePrice,
grantDate: date,
vesting,
};
}
// src/services/helpers.ts
export function createOptionGrant(
stakeholderId: string,
_optionPoolId: string, // Not stored but used for validation
qty: number,
exercisePrice: number,
date: string = new Date().toISOString().slice(0, 10),
vesting?: Vesting
): OptionGrant {
// Note: _optionPoolId is only used for validation on creation
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error(`qty must be a positive number, got ${qty}`);
}
if (!Number.isFinite(exercisePrice) || exercisePrice < 0) {
throw new Error(`exercisePrice must be >= 0, got ${exercisePrice}`);
}
return {
id: `og_${randomUUID()}`,
stakeholderId,
qty,
exercise: exercisePrice,
grantDate: date,
vesting,
};
}
🤖 Prompt for AI Agents
In src/services/helpers.ts around lines 92 to 110, rename the unused parameter
optionPoolId to a prefixed name (e.g. _optionPoolId or _unusedOptionPoolId) to
avoid the TS no-unused-parameters warning, and keep the rest of the function
unchanged; then update src/model.ts OptionGrantSchema to include the missing
exercise (z.number()) and grantDate (z.string() or z.string().refine(...) if you
want date validation) fields with the correct types so that
OptionGrantSchema.parse(grant) preserves the exercise and grantDate produced by
createOptionGrant. Ensure the schema keys match the helper's output names
(exercise, grantDate) and run any existing tests or validation calls to confirm
no other fields were impacted.

Comment on lines +152 to +205
export function calculateSAFEConversions(
captable: Captable,
pricePerShare: number,
_preMoneyValuation: number
): SAFEConversion[] {
if (!captable.safes || captable.safes.length === 0) {
return [];
}

// Calculate total outstanding shares (for cap calculations)
const outstandingShares = captable.issuances
? captable.issuances.reduce((sum: number, i: Issuance) => sum + i.qty, 0)
: 0;

const conversions: SAFEConversion[] = [];

for (const safe of captable.safes) {
let conversionPrice = pricePerShare;
let conversionReason: 'cap' | 'discount' | 'price' = 'price';

// Calculate cap price if applicable
if (safe.cap && outstandingShares > 0) {
const capPrice =
safe.type === 'post'
? safe.cap / (outstandingShares + safe.amount / pricePerShare)
: safe.cap / outstandingShares;

if (capPrice < conversionPrice) {
conversionPrice = capPrice;
conversionReason = 'cap';
}
}

// Calculate discount price if applicable
if (safe.discount) {
const discountPrice = pricePerShare * (1 - safe.discount);
if (discountPrice < conversionPrice) {
conversionPrice = discountPrice;
conversionReason = 'discount';
}
}

const shares = Math.floor(safe.amount / conversionPrice);

conversions.push({
safe,
shares,
conversionPrice,
conversionReason,
});
}

return conversions;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Fix post-money cap conversion price and add guards for invalid prices

  • Post-money cap price should be (cap - amount) / outstandingShares (single-SAFE approximation). Using amount / pricePerShare in the denominator ties the cap price to the round price and is inconsistent.
  • Guard against non-positive/NaN prices; fall back to round price if a computed price is invalid.
 export function calculateSAFEConversions(
   captable: Captable,
-  pricePerShare: number,
-  _preMoneyValuation: number
+  pricePerShare: number,
+  // _preMoneyValuation: number // unused; consider removing if not needed
 ): SAFEConversion[] {
+  if (!Number.isFinite(pricePerShare) || pricePerShare <= 0) {
+    throw new Error(`pricePerShare must be a positive number, got ${pricePerShare}`);
+  }
   if (!captable.safes || captable.safes.length === 0) {
     return [];
   }
 
   // Calculate total outstanding shares (for cap calculations)
   const outstandingShares = captable.issuances
     ? captable.issuances.reduce((sum: number, i: Issuance) => sum + i.qty, 0)
     : 0;
 
   const conversions: SAFEConversion[] = [];
 
   for (const safe of captable.safes) {
     let conversionPrice = pricePerShare;
     let conversionReason: 'cap' | 'discount' | 'price' = 'price';
 
     // Calculate cap price if applicable
-    if (safe.cap && outstandingShares > 0) {
-      const capPrice =
-        safe.type === 'post'
-          ? safe.cap / (outstandingShares + safe.amount / pricePerShare)
-          : safe.cap / outstandingShares;
+    if (safe.cap != null && outstandingShares > 0) {
+      let capPrice: number | undefined;
+      if (safe.type === 'post') {
+        // Single-SAFE post-money approximation: pre-money implied by cap minus this SAFE's amount
+        const preMoneyFromCap = safe.cap - safe.amount;
+        if (preMoneyFromCap > 0) {
+          capPrice = preMoneyFromCap / outstandingShares;
+        }
+      } else {
+        capPrice = safe.cap / outstandingShares;
+      }
 
-      if (capPrice < conversionPrice) {
+      if (capPrice != null && capPrice > 0 && Number.isFinite(capPrice) && capPrice < conversionPrice) {
         conversionPrice = capPrice;
         conversionReason = 'cap';
       }
     }
 
     // Calculate discount price if applicable
     if (safe.discount) {
       const discountPrice = pricePerShare * (1 - safe.discount);
-      if (discountPrice < conversionPrice) {
+      if (discountPrice > 0 && Number.isFinite(discountPrice) && discountPrice < conversionPrice) {
         conversionPrice = discountPrice;
         conversionReason = 'discount';
       }
     }
 
+    if (!(conversionPrice > 0) || !Number.isFinite(conversionPrice)) {
+      conversionPrice = pricePerShare;
+      conversionReason = 'price';
+    }
     const shares = Math.floor(safe.amount / conversionPrice);
 
     conversions.push({
       safe,
       shares,
       conversionPrice,
       conversionReason,
     });
   }
 
   return conversions;
 }

If _preMoneyValuation is truly unused, consider removing it and updating call sites.

Run:


🏁 Script executed:

#!/bin/bash
# Identify all call sites to update if removing the third parameter.
rg -n -C2 "calculateSAFEConversions\s*\(" --type=ts

Length of output: 2340


Fix post-money SAFE conversion formula, add validation guards, and remove unused parameter

  • Use the single-SAFE post-money cap formula:
    // Before:
    capPrice = safe.cap / (outstandingShares + safe.amount / pricePerShare)
    // After:
    capPrice = (safe.cap - safe.amount) / outstandingShares
    This solves for the conversion price directly (cap − SAFE amount) over pre-SAFE shares.
  • Validate all computed prices and the round price:
    • Throw if pricePerShare is not a positive, finite number.
    • Only accept capPrice or discountPrice if they’re > 0 and finite; otherwise fall back to pricePerShare.
  • Remove the unused _preMoneyValuation parameter from the signature and update every call site:
    • Handlers: src/handlers/safe.handlers.ts
    • Tests: src/services/helpers.test.ts (lines 418, 451, 472, 481, 489)
 export function calculateSAFEConversions(
   captable: Captable,
-  pricePerShare: number,
-  _preMoneyValuation: number
+  pricePerShare: number
 ): SAFEConversion[] {
+  if (!Number.isFinite(pricePerShare) || pricePerShare <= 0) {
+    throw new Error(`pricePerShare must be a positive finite number; got ${pricePerShare}`);
+  }
   if (!captable.safes?.length) return [];

   const outstandingShares = captable.issuances?.reduce((sum, i) => sum + i.qty, 0) ?? 0;
   const conversions: SAFEConversion[] = [];

   for (const safe of captable.safes) {
     let conversionPrice = pricePerShare;
     let conversionReason: 'cap' | 'discount' | 'price' = 'price';

-    if (safe.cap && outstandingShares > 0) {
-      const capPrice =
-        safe.type === 'post'
-          ? safe.cap / (outstandingShares + safe.amount / pricePerShare)
-          : safe.cap / outstandingShares;
-      if (capPrice < conversionPrice) {
+    if (safe.cap != null && outstandingShares > 0) {
+      const capPrice =
+        safe.type === 'post'
+          ? (safe.cap - safe.amount) / outstandingShares
+          : safe.cap / outstandingShares;
+      if (Number.isFinite(capPrice) && capPrice > 0 && capPrice < conversionPrice) {
         conversionPrice = capPrice;
         conversionReason = 'cap';
       }
     }

     if (safe.discount) {
       const discountPrice = pricePerShare * (1 - safe.discount);
-      if (discountPrice < conversionPrice) {
+      if (Number.isFinite(discountPrice) && discountPrice > 0 && discountPrice < conversionPrice) {
         conversionPrice = discountPrice;
         conversionReason = 'discount';
       }
     }

+    // Fallback guard
+    if (!Number.isFinite(conversionPrice) || conversionPrice <= 0) {
+      conversionPrice = pricePerShare;
+      conversionReason = 'price';
+    }
     const shares = Math.floor(safe.amount / conversionPrice);

     conversions.push({ safe, shares, conversionPrice, conversionReason });
   }

   return conversions;
 }

Please update all call sites of calculateSAFEConversions(…) in:

  • src/handlers/safe.handlers.ts
  • src/services/helpers.test.ts (lines 418, 451, 472, 481, 489)

to remove the third argument and keep tests & handlers in sync.

🤖 Prompt for AI Agents
In src/services/helpers.ts around lines 152 to 205, the post‑money SAFE cap
formula, validations, and an unused parameter need fixing: change the post‑money
cap calculation to use capPrice = (safe.cap - safe.amount) / outstandingShares;
remove the unused _preMoneyValuation parameter from the function signature and
update all call sites (src/handlers/safe.handlers.ts and
src/services/helpers.test.ts at lines 418, 451, 472, 481, 489) to drop the third
argument; add guards that throw if pricePerShare is not a positive finite number
and only accept capPrice or discountPrice when they are > 0 and finite
(otherwise fall back to pricePerShare); ensure conversionPrice selection uses
these validated values and keep other logic (flooring shares, conversionReason)
unchanged.

- Move string handling edge cases to stakeholder-service.test.ts
- Move numeric boundary tests to security-service.test.ts
- Move pricing, quantity, and date edge cases to equity-service.test.ts
- Move large dataset performance test to performance.test.ts
- Remove standalone edge-cases.test.ts file
- Fix security class kind validation in edge case tests
- Adjust performance test timeout and stakeholder count for reliability

All edge case tests now live alongside the code they test, improving organization while maintaining comprehensive coverage.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (11)
src/handlers/export.handlers.ts (2)

22-41: Fix CSV formula-injection quoting and simplify escaping

The formula-hardening branch returns "'${...}", which is easy to misread and couples quoting with injection handling. Prefer: prepend a single quote to the raw cell value when it starts with [= + - @ or a tab], then apply the normal quoting/escaping rules. This keeps behaviors orthogonal and avoids edge cases.

Apply:

-function escapeCSVValue(value: string | number | undefined | null): string {
-  if (value === undefined || value === null) {
-    return '';
-  }
-
-  const str = String(value);
-
-  // Prevent formula injection - prepend single quote to formulas
-  if (str.length > 0 && ['=', '+', '-', '@', '\t', '\r'].includes(str[0])) {
-    return `"'${str.replace(/"/g, '""')}"`;
-  }
-
-  // Check if value needs escaping
-  if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
-    // Escape quotes by doubling them and wrap in quotes
-    return `"${str.replace(/"/g, '""')}"`;
-  }
-
-  return str;
-}
+function escapeCSVValue(value: string | number | undefined | null): string {
+  if (value == null) return '';
+  let s = String(value);
+  // Neutralize potential formulas
+  if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
+  // Quote if needed; double double-quotes per RFC 4180
+  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}

114-119: Ensure output directory exists before writing the CSV

Writing to nested paths will fail with ENOENT. Create the directory (recursively) before writeFileSync.

-import * as fs from 'fs';
+import * as fs from 'fs';
+import * as path from 'path';
@@
-    if (opts.output) {
-      fs.writeFileSync(opts.output, csv);
+    if (opts.output) {
+      fs.mkdirSync(path.dirname(opts.output), { recursive: true });
+      fs.writeFileSync(opts.output, csv);
src/handlers/issuance.handlers.ts (2)

46-49: Stricter parsing for --qty/--pps to reject “10abc” and whitespace-only inputs

parseInt/parseFloat will coerce partial strings like "10abc". Trim and validate against numeric regex before parsing so only clean inputs are accepted.

-    const qty = parseInt(opts.qty, 10);
-    const pricePerShare = opts.pps ? parseFloat(opts.pps) : undefined;
+    const qtyStr = String(opts.qty).trim();
+    if (!/^\d+$/.test(qtyStr)) {
+      return { success: false, message: '❌ Invalid quantity. Provide a positive integer.' };
+    }
+    const qty = Number.parseInt(qtyStr, 10);
+    const ppsStr = opts.pps !== undefined ? String(opts.pps).trim() : undefined;
+    const pricePerShare =
+      ppsStr !== undefined && ppsStr.length > 0 ? Number.parseFloat(ppsStr) : undefined;

Also applies to: 50-63


258-267: Same stricter parsing for updates

Mirror the add-path parsing/validation for --qty and --pps to avoid partial parses and whitespace-only inputs.

-    if (opts.qty !== undefined) {
-      const newQty = parseInt(opts.qty, 10);
+    if (opts.qty !== undefined) {
+      const qtyStr = String(opts.qty).trim();
+      if (!/^\d+$/.test(qtyStr)) {
+        return { success: false, message: '❌ Invalid quantity. Provide a positive integer.' };
+      }
+      const newQty = Number.parseInt(qtyStr, 10);
@@
-    if (opts.pps !== undefined) {
-      const newPps = parseFloat(opts.pps);
+    if (opts.pps !== undefined) {
+      const ppsStr = String(opts.pps).trim();
+      if (!/^\d+(\.\d+)?$/.test(ppsStr)) {
+        return { success: false, message: '❌ Invalid price per share. Provide a non-negative number.' };
+      }
+      const newPps = Number.parseFloat(ppsStr);

Also applies to: 287-299

src/handlers/safe.handlers.ts (2)

240-248: Conversion scenarios: clarify math or reuse the core conversion logic

The “Assuming 10M shares” heuristic is misleading; conversion depends on round PPS, cap/discount terms, and post/pre-money context. Consider either:

  • Reusing helpers.calculateSAFEConversions in show-mode for a sample PPS, or
  • Dropping the heuristic to avoid confusion.

Also, --new-money is parsed but not used in the conversion calculations; either plumb it through (if supported) or remove the flag to prevent confusion.

Also applies to: 420-471


40-46: Bug: discount is parsed as percentage but stored/used as decimal elsewhere

handleSafeAdd passes discountPct directly into createSAFE, yet the rest of the code treats safe.discount as a decimal (0–1). This yields 100x the intended value (e.g., 20 → 2000%). Normalize to decimal on add and tighten validation to (0,100).

-    const discountPct = opts.discount ? parseFloat(opts.discount) : undefined;
+    const discountPct = opts.discount ? parseFloat(opts.discount) : undefined;
@@
-    if (
-      discountPct !== undefined &&
-      (!Number.isFinite(discountPct) || discountPct < 0 || discountPct > 100)
-    ) {
+    if (
+      discountPct !== undefined &&
+      (!Number.isFinite(discountPct) || discountPct <= 0 || discountPct >= 100)
+    ) {
       return {
         success: false,
-        message: '❌ Invalid discount percentage. Please provide a number between 0 and 100.',
+        message: '❌ Invalid discount percentage. Provide a number between 0 and 100 (exclusive).',
       };
     }
@@
-    const safe = helpers.createSAFE(
+    const discount = discountPct !== undefined ? discountPct / 100 : undefined;
+    const safe = helpers.createSAFE(
       stakeholderResult.stakeholder.id,
       amount,
       valuationCap,
-      discountPct,
+      discount,
       isPostMoney,
       date,
       opts.note
     );
@@
-    if (discountPct) terms.push(`${discountPct}% discount`);
+    if (discountPct) terms.push(`${discountPct}% discount`);

Also applies to: 62-71, 88-96, 100-104

src/handlers/grant.handlers.ts (1)

121-129: Pool availability must be computed for the selected pool only

Summing all grants across all pools can incorrectly block grants. Filter by the selected pool’s ID.

-    // Check pool availability
-    // LIMITATION: Since optionPoolId is not stored in the OptionGrant model,
-    // we must count ALL grants against ALL pools. This means with multiple pools,
-    // the available count is conservative (may show less than actually available).
-    // TODO: Future enhancement - add poolId to OptionGrant model for accurate tracking
-    const poolUsed = captable.optionGrants.reduce((sum, g) => sum + g.qty, 0);
+    // Check pool availability for the selected pool
+    const poolUsed = captable.optionGrants
+      .filter((g) => g.securityClassId === pool.id)
+      .reduce((sum, g) => sum + g.qty, 0);
     const poolAvailable = pool.authorized - poolUsed;
src/handlers/security.handlers.ts (2)

33-45: Harden numeric parsing: trim, reject junk tails, and ensure integerness

parseInt will accept “100abc” → 100. Trim and reject non-digits; also trim par. This was raised earlier; aligning now prevents silent coercions.

-    const authorized = parseInt(opts.authorized || '10000000', 10);
-    const par = opts.par ? parseFloat(opts.par) : undefined;
+    const authorizedStr = String(opts.authorized ?? '10000000').trim();
+    if (!/^\d+$/.test(authorizedStr)) {
+      return {
+        success: false,
+        message: '❌ Invalid --authorized. Provide a positive integer (digits only).',
+      };
+    }
+    const authorized = Number.parseInt(authorizedStr, 10);
+    const par =
+      opts.par !== undefined ? Number.parseFloat(String(opts.par).trim()) : undefined;

217-239: Match add/update validation and reject junk inputs for --authorized

Trim and reject non-digits; also confirm whether zero is allowed, keeping behavior consistent with add.

-    if (opts.authorized !== undefined) {
-      const newAuthorized = parseInt(opts.authorized, 10);
+    if (opts.authorized !== undefined) {
+      const authStr = String(opts.authorized).trim();
+      if (!/^\d+$/.test(authStr)) {
+        return {
+          success: false,
+          message: '❌ Invalid --authorized. Provide a positive integer (digits only).',
+        };
+      }
+      const newAuthorized = Number.parseInt(authStr, 10);

If zero should be allowed, adjust the guard accordingly:

-      if (!Number.isFinite(newAuthorized) || newAuthorized <= 0) {
+      if (!Number.isFinite(newAuthorized) || newAuthorized < 0) {
         return {
           success: false,
-          message: '❌ Invalid authorized shares. Please provide a positive integer.',
+          message: '❌ Invalid authorized shares. Please provide a non-negative integer.',
         };
       }
src/services/helpers.ts (2)

93-111: Rename unused optionPoolId param and validate qty/exercisePrice

This was flagged previously; rename to _optionPoolId to silence TS, and add numeric guards.

 export function createOptionGrant(
   stakeholderId: string,
-  optionPoolId: string, // Not stored but used for validation
+  _optionPoolId: string, // Not stored but used for validation
   qty: number,
   exercisePrice: number,
   date: string = getCurrentDate(),
   vesting?: Vesting
 ): OptionGrant {
   // Note: optionPoolId is not part of the OptionGrant model
   // It's only used for validation during creation
+  if (!Number.isFinite(qty) || qty <= 0) {
+    throw new Error(`qty must be a positive number, got ${qty}`);
+  }
+  if (!Number.isFinite(exercisePrice) || exercisePrice < 0) {
+    throw new Error(`exercisePrice must be >= 0, got ${exercisePrice}`);
+  }
   return {
     id: `og_${randomUUID()}`,
     stakeholderId,
     qty,
     exercise: exercisePrice,
     grantDate: date,
     vesting,
   };
 }

153-206: Fix post‑money SAFE conversion formula, add validation guards, and drop unused _preMoneyValuation

Post‑money cap price should be (cap − amount) / outstandingShares (single‑SAFE approximation). Also validate prices and remove the unused parameter.

-export function calculateSAFEConversions(
-  captable: Captable,
-  pricePerShare: number,
-  _preMoneyValuation: number
-): SAFEConversion[] {
-  if (!captable.safes || captable.safes.length === 0) {
+export function calculateSAFEConversions(
+  captable: Captable,
+  pricePerShare: number
+): SAFEConversion[] {
+  if (!Number.isFinite(pricePerShare) || pricePerShare <= 0) {
+    throw new Error(`pricePerShare must be a positive finite number; got ${pricePerShare}`);
+  }
+  if (!captable.safes || captable.safes.length === 0) {
     return [];
   }
@@
-    if (safe.cap && outstandingShares > 0) {
-      const capPrice =
-        safe.type === 'post'
-          ? safe.cap / (outstandingShares + safe.amount / pricePerShare)
-          : safe.cap / outstandingShares;
-
-      if (capPrice < conversionPrice) {
+    if (safe.cap != null && outstandingShares > 0) {
+      const capPrice =
+        safe.type === 'post'
+          ? (safe.cap - safe.amount) / outstandingShares
+          : safe.cap / outstandingShares;
+
+      if (Number.isFinite(capPrice) && capPrice > 0 && capPrice < conversionPrice) {
         conversionPrice = capPrice;
         conversionReason = 'cap';
       }
     }
@@
-    if (safe.discount) {
-      const discountPrice = pricePerShare * (1 - safe.discount);
-      if (discountPrice < conversionPrice) {
+    if (safe.discount) {
+      const discountPrice = pricePerShare * (1 - safe.discount);
+      if (Number.isFinite(discountPrice) && discountPrice > 0 && discountPrice < conversionPrice) {
         conversionPrice = discountPrice;
         conversionReason = 'discount';
       }
     }
 
+    if (!Number.isFinite(conversionPrice) || conversionPrice <= 0) {
+      conversionPrice = pricePerShare;
+      conversionReason = 'price';
+    }
     const shares = Math.floor(safe.amount / conversionPrice);

Update call sites removing the third parameter:

#!/bin/bash
# Find calls still passing the removed _preMoneyValuation argument
rg -nP "calculateSAFEConversions\s*\([^,]+,\s*[^,]+,\s*[^)]+\)" --type=ts -C2
🧹 Nitpick comments (35)
src/services/reporting-service.ts (2)

80-89: Don’t default unknown price-per-share to 0 in CSV; leave it blank.

Using issuance.pps ?? 0 changes semantics (unknown vs zero) and can mislead consumers and spreadsheets. Prefer emitting an empty field when absent.

Proposed change:

-          issuance.pps ?? 0,
+          issuance.pps,

If there are tests or integrations depending on a default 0, please confirm before changing. If you want explicit “0” in some flows, consider adding a CLI flag or downstream formatting option rather than changing raw export semantics.


97-106: Option grant CSV looks correct.

Fields map cleanly; exercise price is placed under price_per_share, which aligns with the header. If grant.exercise can be undefined in any legacy data, consider the same “blank when missing” approach used above.

README.md (2)

220-233: Sample output is readable; consider pinning locale in code for consistency with docs.

Since the formatting shows commas for thousands, ensure runtime uses en-US or document that grouping may vary by locale (tests currently pin via vitest setup).


393-403: Fix markdownlint MD026: remove trailing punctuation in heading.

“Simulating Different Conversion Scenarios:” ends with a colon. Remove it to appease linters.

Apply this diff:

-### Simulating Different Conversion Scenarios:
+### Simulating Different Conversion Scenarios
src/store.ts (1)

24-28: Audit entry timestamp now testable; consider ISO-with-UTC note if displayed to users.

Using getCurrentTimestamp() is correct. If audit logs are user-visible, ensure consistency that timestamps are UTC (docs/readme examples show local currency/locale; clarifying UTC for audit helps).

src/services/security-service.test.ts (1)

348-361: Edge-case tests add useful guardrails.

  • MAX_SAFE_INTEGER coverage helps expose overflow issues early.
  • Negative authorized rejection test is essential.

Consider adding one more case for zero not allowed (explicit assertion of the thrown message) to document intent.

src/services/stakeholder-service.test.ts (3)

205-208: Assert the error contract, not just that “some” error is thrown.

Tighten the assertion to verify we throw for the right reason (empty/invalid email), which guards against unrelated exceptions passing the test.

-        expect(() => {
-          service.addStakeholder('Test3', 'person', '');
-        }).toThrow();
+        expect(() => {
+          service.addStakeholder('Test3', 'person', '');
+        }).toThrow(/email|empty|invalid/i);

210-219: Add a whitespace-only name test to lock in trimming policy.

Right now, trimming vs preserving whitespace isn’t specified. Add a quick case to either reject or preserve " " explicitly so downstream handlers/UX can rely on it.

Example addition (outside this hunk):

it('should define behavior for whitespace-only names', () => {
  // choose one policy and assert it:
  // 1) reject:
  expect(() => service.addStakeholder('   ', 'person')).toThrow(/name/i);
  // or 2) preserve:
  // const sh = service.addStakeholder('   ', 'person');
  // expect(sh.name).toBe('   ');
});

221-229: Consider exercising downstream surfaces (handlers/CLI) for untrusted strings.

Service-level pass-through is OK, but make sure renderers (handlers/CLI output) don’t introduce terminal control injection or improper escaping. A targeted CLI/handler test for listing/printing these names would catch that class of issues.

Would you like me to propose a handler-level test to assert safe rendering of stakeholder names containing quotes and script-like text?

src/utils/date-utils.ts (1)

5-12: Document explicit UTC semantics to avoid timezone surprises.

These helpers return UTC-based values. Calling this out reduces ambiguity for consumers who might expect local time.

- * Get the current date as an ISO string (YYYY-MM-DD)
+ * Get the current date in UTC as an ISO string (YYYY-MM-DD).
+ * Note: Uses UTC (Date.toISOString), not local time, to avoid DST/offset drift.
src/services/equity-service.test.ts (2)

299-305: Precision: tiny price check is fine; also assert stored precision invariants.

If the system rounds/normalizes pps internally, add an assertion that pps is persisted with the expected precision (e.g., no scientific notation, correct decimal places) to catch future formatting changes.


320-324: Explicit reason on zero-quantity rejection.

As with stakeholder tests, assert the error message pattern to ensure we’re rejecting for the right reason (zero quantity), not incidental failures.

-        }).toThrow();
+        }).toThrow(/qty|quantity|zero/i);
src/utils/test-utils.ts (1)

12-27: Solid time control; one small enhancement for determinism.

Current fake timers + date-utils spies are solid. Consider adding a seeded PRNG helper here too so performance tests don’t rely on Math.random(), which can introduce runtime variance.

Example addition (outside this hunk):

// Deterministic PRNG (Mulberry32)
export function createPRNG(seed: number) {
  return function next() {
    let t = (seed += 0x6D2B79F5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Usage in tests:

const rand = createPRNG(42);
// instead of Math.random():
const shares = Math.floor(rand() * 10000) + 1000;
src/performance.test.ts (6)

20-20: Gate the heaviest test behind an opt-in flag to avoid CI flakiness.

A 60s timeout on 10k transactions can be costly on shared runners. Make it opt-in (e.g., PERF=1) so routine CI stays fast, while still allowing targeted perf runs.

-it('should handle 10,000 transactions efficiently', { timeout: 60000 }, () => {
+it.runIf(process.env.PERF === '1')('should handle 10,000 transactions efficiently', { timeout: 60000 }, () => {

157-181: Cross-century date workload looks reasonable; minor note on threshold.

500ms should be safe given the pair count, but if CI variance spikes, consider ≤750ms to stay non-flaky.


185-217: Memory test can be environment-sensitive — make it opt-in.

Heap delta thresholds vary across Node versions and runners. Gate this under PERF to prevent intermittent CI failures.

-it(
-  'should handle large number of stakeholders without memory issues',
-  { timeout: 30000 },
-  () => {
+it.runIf(process.env.PERF === '1')(
+  'should handle large number of stakeholders without memory issues',
+  { timeout: 30000 },
+  () => {

219-246: Scale test (50k stakeholders) should also be opt-in.

120s and 50k entities are great for profiling but heavy for default CI runs.

-it('should handle 50,000+ stakeholders efficiently', { timeout: 120000 }, () => {
+it.runIf(process.env.PERF === '1')('should handle 50,000+ stakeholders efficiently', { timeout: 120000 }, () => {

250-301: Sequential cap-table calculations: good stress test; consider softening SLA.

10,000 ms target is ambitious and can be border-line on ARM/mac runners. If flakes arise, bump to 12–15s or assert average per calc instead of total.


360-399: Make sparse-data SLA a touch more forgiving.

500ms is tight on some runners with cold JIT. Suggest ≤1000ms to avoid flakes while still enforcing performance.

-      expect(calcTime).toBeLessThan(500); // Should be fast despite many stakeholders
+      expect(calcTime).toBeLessThanOrEqual(1000); // Fast even on slower CI runners
src/handlers/export.handlers.ts (1)

135-161: Mirror the directory-creation fix for JSON exports

For parity and UX consistency, do the same mkdir+write for JSON export.

-import * as fs from 'fs';
+import * as fs from 'fs';
+import * as path from 'path';
@@
-    if (opts.output) {
-      fs.writeFileSync(opts.output, json);
+    if (opts.output) {
+      fs.mkdirSync(path.dirname(opts.output), { recursive: true });
+      fs.writeFileSync(opts.output, json);
src/handlers/issuance.handlers.ts (2)

210-213: Currency formatting: keep two decimals for Total Value

Total value should be formatted to 2 decimals to avoid outputs like $123.4 or $123.456789.

-      output += `Total Value:  $${(issuance.qty * issuance.pps).toLocaleString('en-US')}\n`;
+      output += `Total Value:  $${(issuance.qty * issuance.pps).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}\n`;

181-181: Avoid any for handler options

Replace _opts: any with a concrete type or Record<string, never> if unused to satisfy static analysis and improve consistency.

-export function handleIssuanceShow(id: string | undefined, _opts: any): HandlerResult {
+export function handleIssuanceShow(id: string | undefined, _opts: Record<string, never>): HandlerResult {
src/handlers/report.handlers.ts (2)

67-72: Use unknown in catch blocks and normalize error messages

Avoid any in catch. Also normalize message extraction for non-Error throws.

-  } catch (error: any) {
-    return {
-      success: false,
-      message: `❌ Error: ${error.message}`,
-    };
+  } catch (error: unknown) {
+    const msg = error instanceof Error ? error.message : String(error);
+    return { success: false, message: `❌ Error: ${msg}` };
   }

Also applies to: 142-147, 245-250, 334-339


58-61: Consistent numeric locale for human output

For consistency with other handlers, consider toLocaleString('en-US') here as well.

-    output += `  Issued Shares: ${totalShares.toLocaleString()}\n`;
-    output += `  Granted Options: ${totalOptions.toLocaleString()}\n`;
-    output += `  SAFE Investment: $${totalSafes.toLocaleString()}\n`;
+    output += `  Issued Shares: ${totalShares.toLocaleString('en-US')}\n`;
+    output += `  Granted Options: ${totalOptions.toLocaleString('en-US')}\n`;
+    output += `  SAFE Investment: $${totalSafes.toLocaleString('en-US')}\n`;
src/handlers/safe.handlers.ts (2)

200-200: Avoid any for handler options

Use a concrete type or Record<string, never> if unused.

-export function handleSafeShow(id: string | undefined, _opts: any): HandlerResult {
+export function handleSafeShow(id: string | undefined, _opts: Record<string, never>): HandlerResult {

403-409: Type the convert options and document unused flags

Prefer an explicit SafeConvertOpts shape and either wire newMoney through conversion logic or remove it from the CLI/API.

-export function handleSafeConvert(opts: {
-  preMoney: string;
-  pps: string;
-  newMoney?: string;
-  date?: string;
-  dryRun?: boolean;
-}): HandlerResult {
+interface SafeConvertOpts {
+  preMoney: string;
+  pps: string;
+  newMoney?: string;
+  date?: string;
+  dryRun?: boolean;
+}
+export function handleSafeConvert(opts: SafeConvertOpts): HandlerResult {
src/handlers/grant.handlers.ts (1)

245-245: Avoid any in handler signatures

Use a concrete type (even {}) or Record<string, never> if unused.

-export function handleGrantShow(id: string | undefined, _opts: any): HandlerResult {
+export function handleGrantShow(id: string | undefined, _opts: Record<string, never>): HandlerResult {
src/handlers/security.handlers.ts (6)

36-42: Confirm domain rule: should authorized allow 0?

You currently reject 0 (<= 0). Prior guidance suggested “non‑negative”. Decide and keep consistent with update handler.

If zero should be allowed:

-    if (!Number.isFinite(authorized) || authorized <= 0) {
+    if (!Number.isFinite(authorized) || authorized < 0) {
       return {
         success: false,
-        message: '❌ Invalid authorized shares. Please provide a positive integer.',
+        message: '❌ Invalid authorized shares. Please provide a non-negative integer.',
       };
     }

83-89: Consider returning structured data for --format json and keep message human-friendly

Right now message contains the JSON string while data also returns the array. Prefer keeping message short and rely on data for JSON consumers. Optional.

-      return {
-        success: true,
-        message: JSON.stringify(captable.securityClasses, null, 2),
-        data: captable.securityClasses,
-      };
+      return {
+        success: true,
+        message: `Returned ${captable.securityClasses.length} security class(es) as JSON.`,
+        data: captable.securityClasses,
+      };

99-111: Tighten table rendering and friendly-kind display

  • Align the separator width to header length to avoid drift.
  • Display “PREFERRED” instead of internal “PREF”.
-    let output = '🏦 Security Classes\n\n';
-    output += 'ID              Type          Label                     Authorized        Issued\n';
-    output += '─'.repeat(85) + '\n';
+    let output = '🏦 Security Classes\n\n';
+    const header = 'ID              Type          Label                     Authorized        Issued';
+    output += header + '\n';
+    output += '─'.repeat(header.length) + '\n';
@@
-      const type = sc.kind.padEnd(12);
+      const displayKind = (sc.kind === 'PREF' ? 'PREFERRED' : sc.kind).padEnd(12);
@@
-      output += `${id}  ${type}  ${label}  ${auth}  ${iss}\n`;
+      output += `${id}  ${displayKind}  ${label}  ${auth}  ${iss}\n`;

151-163: Polish details view: normalize kind and format par value

  • Show “PREFERRED” instead of “PREF”.
  • Format parValue to 2 decimals.
-    output += `Type:       ${security.kind}\n`;
+    const displayKind = security.kind === 'PREF' ? 'PREFERRED' : security.kind;
+    output += `Type:       ${displayKind}\n`;
@@
-    if (security.parValue !== undefined) {
-      output += `Par Value:  $${security.parValue}\n`;
+    if (security.parValue !== undefined) {
+      output += `Par Value:  $${security.parValue.toFixed(2)}\n`;
     }

165-176: Improve issuance line items: resolve holder fallback and format price

Guard null holder and standardize price formatting.

-      issuances.forEach((iss) => {
-        const holder = captable.stakeholders.find((sh) => sh.id === iss.stakeholderId);
-        output += `  • ${iss.qty.toLocaleString('en-US')} shares to ${holder?.name || 'Unknown'}`;
-        if (iss.pps) {
-          // Fixed: pricePerShare -> pps
-          output += ` at $${iss.pps}/share`;
-        }
-        output += `\n`;
-      });
+      issuances.forEach((iss) => {
+        const holder = captable.stakeholders.find((sh) => sh.id === iss.stakeholderId);
+        const holderLabel = holder?.name ?? holder?.email ?? holder?.id ?? 'Unknown';
+        const qtyStr = iss.qty.toLocaleString('en-US');
+        const ppsStr = iss.pps != null ? ` at $${Number(iss.pps).toFixed(2)}/share` : '';
+        output += `  • ${qtyStr} shares to ${holderLabel}${ppsStr}\n`;
+      });

301-324: Option pool delete semantics can leave orphan grants

With no optionPoolId on grants, deleting a pool (without being the last one) may leave grants that conceptually belonged to it. Consider warning the user about existing option grants when deleting any OPTION_POOL, and suggest --force + explicit cleanup.

If you want to guard gently:

-    if (issued > 0 && !opts.force) {
+    const hasGrants = (captable.optionGrants?.length ?? 0) > 0;
+    if ((issued > 0 || (security.kind === 'OPTION_POOL' && hasGrants)) && !opts.force) {
       return {
         success: false,
-        message: `❌ Security class has ${issued.toLocaleString('en-US')} issued shares. Use --force to delete anyway.`,
+        message: `❌ Security class has ${issued.toLocaleString('en-US')} issued shares${security.kind === 'OPTION_POOL' && hasGrants ? ' and existing option grants' : ''}. Use --force to delete anyway.`,
       };
     }
src/services/helpers.ts (2)

42-56: Map kind to persisted shape but validate label

Mapping PREFERRED→PREF is fine. Optional: guard against empty labels to avoid anonymous classes.

 export function createSecurityClass(
   kind: 'COMMON' | 'PREFERRED' | 'OPTION_POOL',
   label: string,
   authorized: number,
   parValue?: number
 ): SecurityClass {
+  if (!label || !label.trim()) {
+    throw new Error('label is required');
+  }
   const mappedKind = kind === 'PREFERRED' ? 'PREF' : kind;

113-119: Business rule check: vesting absent ⇒ fully vested vs zero vested

Your helper returns full qty when no schedule, while vestedQty in model returns 0 if vesting is falsy (see src/model.ts). Align the rule across the codebase.

If you want to delegate to the model’s single source of truth:

-export function calculateVestedOptions(grant: OptionGrant, asOfDate: string): number {
-  if (!grant.vesting) {
-    return grant.qty;
-  }
-  return vestedQty(asOfDate, grant.qty, grant.vesting);
-}
+export function calculateVestedOptions(grant: OptionGrant, asOfDate: string): number {
+  return vesting ? vestedQty(asOfDate, grant.qty, grant.vesting) : 0;
+}

Or, conversely, update the model’s vestedQty rule and tests to “fully vested when no schedule.”

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c2bdbed and e8b55cf.

📒 Files selected for processing (31)
  • README.md (7 hunks)
  • src/handlers/export.handlers.test.ts (1 hunks)
  • src/handlers/export.handlers.ts (1 hunks)
  • src/handlers/grant.handlers.test.ts (1 hunks)
  • src/handlers/grant.handlers.ts (1 hunks)
  • src/handlers/issuance.handlers.test.ts (1 hunks)
  • src/handlers/issuance.handlers.ts (1 hunks)
  • src/handlers/report.handlers.test.ts (1 hunks)
  • src/handlers/report.handlers.ts (1 hunks)
  • src/handlers/safe.handlers.test.ts (1 hunks)
  • src/handlers/safe.handlers.ts (1 hunks)
  • src/handlers/security.handlers.test.ts (1 hunks)
  • src/handlers/security.handlers.ts (1 hunks)
  • src/handlers/stakeholder.handlers.test.ts (1 hunks)
  • src/handlers/stakeholder.handlers.ts (1 hunks)
  • src/handlers/system.handlers.test.ts (1 hunks)
  • src/handlers/system.handlers.ts (1 hunks)
  • src/handlers/types.ts (1 hunks)
  • src/identifier-resolver.test.ts (1 hunks)
  • src/identifier-resolver.ts (1 hunks)
  • src/performance.test.ts (8 hunks)
  • src/services/equity-service.test.ts (1 hunks)
  • src/services/helpers.ts (1 hunks)
  • src/services/reporting-service.ts (4 hunks)
  • src/services/security-service.test.ts (1 hunks)
  • src/services/stakeholder-service.test.ts (1 hunks)
  • src/store.ts (2 hunks)
  • src/utils/date-utils.ts (1 hunks)
  • src/utils/test-utils.ts (1 hunks)
  • vitest.config.ts (1 hunks)
  • vitest.setup.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (13)
  • src/handlers/types.ts
  • src/handlers/stakeholder.handlers.ts
  • src/identifier-resolver.ts
  • src/handlers/report.handlers.test.ts
  • src/handlers/safe.handlers.test.ts
  • src/handlers/export.handlers.test.ts
  • src/handlers/stakeholder.handlers.test.ts
  • src/handlers/system.handlers.test.ts
  • src/identifier-resolver.test.ts
  • src/handlers/issuance.handlers.test.ts
  • src/handlers/system.handlers.ts
  • src/handlers/grant.handlers.test.ts
  • src/handlers/security.handlers.test.ts
🧰 Additional context used
🧬 Code graph analysis (9)
src/store.ts (1)
src/utils/date-utils.ts (1)
  • getCurrentTimestamp (17-19)
src/handlers/report.handlers.ts (4)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (1)
  • load (6-15)
src/utils/date-utils.ts (1)
  • getCurrentDate (10-12)
src/identifier-resolver.ts (1)
  • resolveStakeholder (42-102)
src/handlers/security.handlers.ts (2)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (6-15)
  • save (17-20)
src/handlers/safe.handlers.ts (4)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (6-15)
  • save (17-20)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-102)
  • formatStakeholderReference (227-233)
src/utils/date-utils.ts (1)
  • getCurrentDate (10-12)
src/services/helpers.ts (2)
src/model.ts (8)
  • FileModel (157-157)
  • Stakeholder (59-59)
  • SecurityClass (69-69)
  • Issuance (81-81)
  • Vesting (50-50)
  • OptionGrant (92-92)
  • vestedQty (226-237)
  • SAFE (105-105)
src/utils/date-utils.ts (2)
  • getCurrentDate (10-12)
  • getCurrentTimestamp (17-19)
src/performance.test.ts (2)
src/model.ts (1)
  • FileModel (157-157)
src/services/stakeholder-service.ts (1)
  • StakeholderService (4-71)
src/handlers/export.handlers.ts (3)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (1)
  • load (6-15)
src/utils/date-utils.ts (1)
  • getCurrentDate (10-12)
src/handlers/grant.handlers.ts (4)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (6-15)
  • save (17-20)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-102)
  • formatStakeholderReference (227-233)
src/utils/date-utils.ts (1)
  • getCurrentDate (10-12)
src/handlers/issuance.handlers.ts (4)
src/handlers/types.ts (1)
  • HandlerResult (5-9)
src/store.ts (2)
  • load (6-15)
  • save (17-20)
src/identifier-resolver.ts (2)
  • resolveStakeholder (42-102)
  • formatStakeholderReference (227-233)
src/utils/date-utils.ts (1)
  • getCurrentDate (10-12)
🪛 GitHub Check: test (22)
src/handlers/report.handlers.ts

[warning] 334-334:
Unexpected any. Specify a different type


[warning] 253-253:
Unexpected any. Specify a different type


[warning] 245-245:
Unexpected any. Specify a different type


[warning] 150-150:
Unexpected any. Specify a different type


[warning] 142-142:
Unexpected any. Specify a different type


[warning] 67-67:
Unexpected any. Specify a different type

src/handlers/security.handlers.ts

[warning] 52-52:
Unexpected any. Specify a different type

src/handlers/safe.handlers.ts

[warning] 200-200:
Unexpected any. Specify a different type

src/handlers/grant.handlers.ts

[warning] 245-245:
Unexpected any. Specify a different type

src/handlers/issuance.handlers.ts

[warning] 181-181:
Unexpected any. Specify a different type

🪛 GitHub Check: test (20)
src/handlers/report.handlers.ts

[warning] 334-334:
Unexpected any. Specify a different type


[warning] 253-253:
Unexpected any. Specify a different type


[warning] 245-245:
Unexpected any. Specify a different type


[warning] 150-150:
Unexpected any. Specify a different type


[warning] 142-142:
Unexpected any. Specify a different type


[warning] 67-67:
Unexpected any. Specify a different type

src/handlers/security.handlers.ts

[warning] 52-52:
Unexpected any. Specify a different type

src/handlers/safe.handlers.ts

[warning] 200-200:
Unexpected any. Specify a different type

src/handlers/grant.handlers.ts

[warning] 245-245:
Unexpected any. Specify a different type

src/handlers/issuance.handlers.ts

[warning] 181-181:
Unexpected any. Specify a different type

🪛 LanguageTool
README.md

[grammar] ~31-~31: There might be a mistake here.
Context: ...V/JSON for investors & lawyers --- ## 📖 Usage Captan uses a resource-oriented co...

(QB_NEW_EN)


[grammar] ~31-~31: There might be a mistake here.
Context: ...or investors & lawyers --- ## 📖 Usage Captan uses a resource-oriented command s...

(QB_NEW_EN)

🪛 markdownlint-cli2 (0.17.2)
README.md

393-393: Trailing punctuation in heading
Punctuation: ':'

(MD026, no-trailing-punctuation)

🔇 Additional comments (29)
vitest.setup.ts (1)

6-9: Deterministic test locale/time looks good.

Setting LC_ALL/LANG to en_US.UTF-8 and TZ to UTC will stabilize toLocaleString and date behavior across environments. No further action needed.

src/services/reporting-service.ts (3)

36-38: CSV row formatter is simple and correct.

Mapping escapeCSVValue over fields and joining with commas is appropriate.


60-73: Header construction via formatter is a solid hardening step.

Routing the header through formatCSVRow keeps behavior consistent and protects against future edits introducing unsafe characters.


115-124: SAFE CSV rows: good coverage of core fields.

Emitting amount in quantity and cap in price_per_share is consistent with the header. If a discount exists without a cap, you may want to consider adding a separate “terms” export in the future, but this is fine for now.

README.md (12)

108-121: Destructive-operations safeguards are well stated.

Good clarity on when --force is required.


171-176: QuickStart commands align with the new report flow.

The switch to report ownership reads well. No issues.


201-216: SAFE commands section is concise and matches the new CLI verbs.

Including --dry-run for conversion is a great UX addition.


349-354: SAFE add examples are helpful.

Examples clearly show cap and discount variants. No changes needed.


357-358: Conversion preview examples look accurate.

Good to set expectations about the “no changes made” behavior.


376-378: EXECUTE conversion example complements preview nicely.

This pairing reduces user error. LGTM.


386-390: Follow-up commands after conversion reinforce state changes.

Showing safe list and report ownership post-conversion is great.


396-399: Scenario simulations are a nice touch.

Encouraging exploration of different inputs without committing is excellent UX.


420-425: Advanced vesting examples are clear.

Flags are consistent with the earlier usage section. No issues.


430-434: Multiple security classes example aligns with the new nouns/verbs.

Minor verification: ensure --kind PREFERRED matches code-enum/aliases as noted earlier.


439-443: JSON ownership report and audit log filtering examples look good.

The action name ISSUANCE_ADD matches the new terminology. LGTM.


31-106: Security Kind Mapping Verified
The CLI correctly supports both PREFERRED and the internal PREF enum:

• In src/cli-handlers.ts, we map the user’s preferred (case-insensitive) to 'PREF' before invoking the service – so --kind PREFERRED (or --kind PREF) both work as intended.
• Internally, model.ts and Zod schemas use the 'PREF' literal, and createSecurityClass in services/helpers.ts transforms 'PREFERRED' into 'PREF'. Tests (cli.test.ts, helpers.test.ts, service tests) all confirm this mapping.

No changes to the docs are required.

src/store.ts (1)

4-4: Good move: centralize timestamps via date-utils.

This enables deterministic tests (mockable) and avoids scattering new Date() usage.

src/services/stakeholder-service.test.ts (1)

196-231: Great edge-case coverage for string handling.

The new tests solidly pin down behavior for undefined vs empty strings, international characters, and pass-through of potentially malicious inputs. This will help prevent surprising regressions in input normalization.

src/utils/date-utils.ts (1)

17-19: LGTM — simple, test-friendly time sources.

Clear, minimal wrappers that are easy to mock and already integrated with test-utils. Nice separation from direct Date usage.

src/services/equity-service.test.ts (3)

342-359: Nice concurrency scenario.

Well-chosen distribution across two classes with a correctness check (total 4,000). This gives confidence in accumulation logic.


297-360: No duplicate “Edge Cases” suite detected

I searched for additional describe('Edge Cases', …) blocks in src/services/equity-service.test.ts and found only the single occurrence starting at line 297. There is no accidental duplicate to merge or remove.


297-360: Update EquityService Edge Case Tests and Uniqueness Policy

The SecurityService enforces unique labels (not kinds), so creating multiple classes of the same kind with distinct labels is valid and won’t break if run in sequence.

– SecurityService behavior

  • The addSecurityClass implementation in src/services/security-service.ts throws only when a label already exists (throw new Error(\Security class "${label}" already exists`)) and does _not_ enforce uniqueness on the kind` argument. Tests that add two “COMMON” kinds with different labels will pass without brittleness.

– Negative quantity guardrail

  • Since issueShares(..., qty, ...) already throws on zero or negative quantities (covered by the existing “should reject zero quantities” test), adding a dedicated negative-quantity test will complete your validation matrix.

– Fractional quantity consideration

  • The current implementation does not explicitly reject fractional share quantities, so a test expecting a throw on a fractional qty will fail unless fractional shares are disallowed by design. If your business rules forbid fractional shares, add this test; otherwise, omit it.

Points of attention:
• File: src/services/equity-service.test.ts
– Add:

it('should reject negative quantities', () => {
  expect(() => service.issueShares(commonId, aliceId, -1, 1.0, '2024-01-01')).toThrow();
});

– Optionally add (only if fractional shares are not supported):

it('should reject fractional share quantities', () => {
  expect(() => service.issueShares(commonId, aliceId, 0.5 as unknown as number, 1.0, '2024-01-01')).toThrow();
});

• No change needed to the concurrent-issuance test around adding a second “COMMON” class, since uniqueness is enforced on the label only.

src/utils/test-utils.ts (2)

32-36: Redundant cleanup is acceptable; keep as-is.

You restore mocks in setupFakeTimers’ afterEach and clear them here; the redundancy is harmless and helps suites that use only one of the setups. No change needed.


43-46: LGTM — predictable IDs for tests.

Simple and effective. Consider incrementing from 1 to match human expectations in snapshots (already does).

src/performance.test.ts (2)

129-156: LGTM — long-horizon vesting benchmark is concise and meaningful.

Nice monotonicity assertion plus tight time budget. No changes needed.


305-358: Extreme SAFE conversions look tight and focused.

Small but high-variance grid; assertions cover numeric stability. Looks good.

src/handlers/export.handlers.ts (1)

50-68: CSV header/data assembly looks correct with centralized escaping

You’ve correctly routed every field through formatCSVRow/escapeCSVValue for both issuances and option grants. Nice.

Also applies to: 70-87, 89-110

src/handlers/grant.handlers.ts (1)

212-229: Nice: table rendering with aligned columns and vested calculation

Good balance of readability and correctness; calculateVestedOptions usage is appropriate.

src/services/helpers.ts (1)

242-265: LGTM on audit logging

Concise, deterministic actor, and timestamp via util. No changes needed.

Comment on lines +281 to +301
if (security.kind === 'OPTION_POOL') {
// Option pool report
// Note: optionPoolId is not tracked in the model, so we assume all grants come from this pool
const grants = captable.optionGrants || [];
const totalGranted = grants.reduce((sum, g) => sum + g.qty, 0);
const remaining = security.authorized - totalGranted;
const utilization =
security.authorized > 0 ? ((totalGranted / security.authorized) * 100).toFixed(1) : '0.0';

output += `📊 Pool Utilization:\n`;
output += ` Granted: ${totalGranted.toLocaleString()}\n`;
output += ` Remaining: ${remaining.toLocaleString()}\n`;
output += ` Utilization: ${utilization}%\n\n`;

if (grants.length > 0) {
output += `🎯 Grants (${grants.length}):\n`;
grants.forEach((grant) => {
const holder = captable.stakeholders.find((sh) => sh.id === grant.stakeholderId);
output += ` • ${holder?.name || 'Unknown'}: ${grant.qty.toLocaleString()} at $${grant.exercise}\n`;
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

OPTION_POOL utilization should only count grants from this pool

Current code sums all grants across all pools, which misstates utilization when multiple pools exist. Filter by security.id.

-    if (security.kind === 'OPTION_POOL') {
-      // Option pool report
-      // Note: optionPoolId is not tracked in the model, so we assume all grants come from this pool
-      const grants = captable.optionGrants || [];
+    if (security.kind === 'OPTION_POOL') {
+      // Option pool report — restrict to grants against this pool
+      const grants = (captable.optionGrants || []).filter(
+        (g) => g.securityClassId === security.id
+      );
       const totalGranted = grants.reduce((sum, g) => sum + g.qty, 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (security.kind === 'OPTION_POOL') {
// Option pool report
// Note: optionPoolId is not tracked in the model, so we assume all grants come from this pool
const grants = captable.optionGrants || [];
const totalGranted = grants.reduce((sum, g) => sum + g.qty, 0);
const remaining = security.authorized - totalGranted;
const utilization =
security.authorized > 0 ? ((totalGranted / security.authorized) * 100).toFixed(1) : '0.0';
output += `📊 Pool Utilization:\n`;
output += ` Granted: ${totalGranted.toLocaleString()}\n`;
output += ` Remaining: ${remaining.toLocaleString()}\n`;
output += ` Utilization: ${utilization}%\n\n`;
if (grants.length > 0) {
output += `🎯 Grants (${grants.length}):\n`;
grants.forEach((grant) => {
const holder = captable.stakeholders.find((sh) => sh.id === grant.stakeholderId);
output += ` • ${holder?.name || 'Unknown'}: ${grant.qty.toLocaleString()} at $${grant.exercise}\n`;
});
}
if (security.kind === 'OPTION_POOL') {
// Option pool report — restrict to grants against this pool
const grants = (captable.optionGrants || []).filter(
(g) => g.securityClassId === security.id
);
const totalGranted = grants.reduce((sum, g) => sum + g.qty, 0);
const remaining = security.authorized - totalGranted;
const utilization =
security.authorized > 0 ? ((totalGranted / security.authorized) * 100).toFixed(1) : '0.0';
output += `📊 Pool Utilization:\n`;
output += ` Granted: ${totalGranted.toLocaleString()}\n`;
output += ` Remaining: ${remaining.toLocaleString()}\n`;
output += ` Utilization: ${utilization}%\n\n`;
if (grants.length > 0) {
output += `🎯 Grants (${grants.length}):\n`;
grants.forEach((grant) => {
const holder = captable.stakeholders.find((sh) => sh.id === grant.stakeholderId);
output += ` • ${holder?.name || 'Unknown'}: ${grant.qty.toLocaleString()} at $${grant.exercise}\n`;
});
}
}
🤖 Prompt for AI Agents
In src/handlers/report.handlers.ts around lines 281 to 301, the OPTION_POOL
branch currently sums all optionGrants across the captable; change it to only
consider grants that belong to this pool by filtering captable.optionGrants
where the grant's optionPoolId (or equivalent pool identifier) equals
security.id, then compute totalGranted, remaining and utilization from that
filtered list and list only those grants in the detailed section so utilization
reflects this specific pool only.

Comment on lines +25 to +33
const kind = opts.kind.toUpperCase();
if (!['COMMON', 'PREFERRED', 'OPTION_POOL'].includes(kind)) {
return {
success: false,
message: '❌ Invalid security kind. Must be COMMON, PREFERRED, or OPTION_POOL.',
};
}

const authorized = parseInt(opts.authorized || '10000000', 10);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Remove any-cast and make kind a properly narrowed union

Avoid as any; narrow to a typed union after validation. Also trim input to guard whitespace.

-    const kind = opts.kind.toUpperCase();
-    if (!['COMMON', 'PREFERRED', 'OPTION_POOL'].includes(kind)) {
+    const allowedKinds = ['COMMON', 'PREFERRED', 'OPTION_POOL'] as const;
+    type Kind = typeof allowedKinds[number];
+    const kindRaw = (opts.kind ?? '').toUpperCase().trim();
+    if (!allowedKinds.includes(kindRaw as Kind)) {
       return {
         success: false,
         message: '❌ Invalid security kind. Must be COMMON, PREFERRED, or OPTION_POOL.',
       };
     }
@@
-    const security = helpers.createSecurityClass(kind as any, opts.label, authorized, par);
+    const kind = kindRaw as Kind;
+    const security = helpers.createSecurityClass(kind, opts.label, authorized, par);

This also resolves the static analysis “Unexpected any” warning.

Run to confirm no remaining any-casts in this file:

Also applies to: 52-52


🏁 Script executed:

#!/bin/bash
rg -nP "as\s+any\b" src/handlers/security.handlers.ts

Length of output: 150


Action Required: Narrow kind to a union and remove the remaining as any cast

The cast at line 52 is still present and will continue to trigger the “Unexpected any” warning. You need to introduce a properly typed union for kind, trim the raw input, and eliminate the as any. Specifically:

• File: src/handlers/security.handlers.ts
– Lines 25–33: replace the current runtime check on the string literal array with a typed constant and union
– Line 52: remove as any when calling helpers.createSecurityClass

Suggested diff:

@@ Lines 25–33
-    const kind = opts.kind.toUpperCase();
-    if (!['COMMON', 'PREFERRED', 'OPTION_POOL'].includes(kind)) {
+    const allowedKinds = ['COMMON', 'PREFERRED', 'OPTION_POOL'] as const;
+    type Kind = typeof allowedKinds[number];
+    const kindRaw = (opts.kind ?? '').toUpperCase().trim();
+    if (!allowedKinds.includes(kindRaw as Kind)) {
       return {
         success: false,
         message: '❌ Invalid security kind. Must be COMMON, PREFERRED, or OPTION_POOL.',
       };
     }
@@ Line 52
-    const security = helpers.createSecurityClass(kind as any, opts.label, authorized, par);
+    const kind = kindRaw as Kind;
+    const security = helpers.createSecurityClass(kind, opts.label, authorized, par);

This will fully eliminate the as any usage and satisfy static analysis.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const kind = opts.kind.toUpperCase();
if (!['COMMON', 'PREFERRED', 'OPTION_POOL'].includes(kind)) {
return {
success: false,
message: '❌ Invalid security kind. Must be COMMON, PREFERRED, or OPTION_POOL.',
};
}
const authorized = parseInt(opts.authorized || '10000000', 10);
// --- around lines 25–33 ---
// Narrow `kind` to a typed union and trim input
const allowedKinds = ['COMMON', 'PREFERRED', 'OPTION_POOL'] as const;
type Kind = typeof allowedKinds[number];
const kindRaw = (opts.kind ?? '').toUpperCase().trim();
if (!allowedKinds.includes(kindRaw as Kind)) {
return {
success: false,
message: '❌ Invalid security kind. Must be COMMON, PREFERRED, or OPTION_POOL.',
};
}
const authorized = parseInt(opts.authorized || '10000000', 10);
// --- later, around line 52 ---
// Cast to the safe `Kind` and drop the `any` cast
const kind = kindRaw as Kind;
const security = helpers.createSecurityClass(kind, opts.label, authorized, par);

Comment on lines +127 to +134
export function handleSecurityShow(id: string | undefined, _opts: any): HandlerResult {
try {
if (!id) {
return {
success: false,
message: '❌ Please provide a security class ID',
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Remove unused _opts or type it as unknown

Avoid any; if kept for signature compatibility, mark as unknown.

-export function handleSecurityShow(id: string | undefined, _opts: any): HandlerResult {
+export function handleSecurityShow(id: string | undefined, _opts: unknown): HandlerResult {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function handleSecurityShow(id: string | undefined, _opts: any): HandlerResult {
try {
if (!id) {
return {
success: false,
message: '❌ Please provide a security class ID',
};
}
export function handleSecurityShow(id: string | undefined, _opts: unknown): HandlerResult {
try {
if (!id) {
return {
success: false,
message: '❌ Please provide a security class ID',
};
}
// …
🤖 Prompt for AI Agents
In src/handlers/security.handlers.ts around lines 127 to 134, the function
parameter `_opts: any` is unused and typed as `any`; remove the parameter
entirely if not required, or change its type to `unknown` to avoid using `any`
while preserving the signature for compatibility, and update any call sites if
you remove it (or keep the underscore prefix to indicate unused). Ensure there
are no remaining references to `_opts` and run TypeScript typecheck after the
change.

Comment on lines +70 to +87
export function createIssuance(
stakeholderId: string,
securityClassId: string,
qty: number,
pricePerShare?: number,
date: string = getCurrentDate(),
certificateNumber?: string
): Issuance {
return {
id: `is_${randomUUID()}`,
stakeholderId,
securityClassId,
qty,
pps: pricePerShare,
date,
cert: certificateNumber,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Validate issuance inputs (qty > 0, pps ≥ 0) to avoid corrupting the model

Currently no input checks; add minimal guards.

 export function createIssuance(
   stakeholderId: string,
   securityClassId: string,
   qty: number,
   pricePerShare?: number,
   date: string = getCurrentDate(),
   certificateNumber?: string
 ): Issuance {
+  if (!Number.isFinite(qty) || qty <= 0) {
+    throw new Error(`qty must be a positive number, got ${qty}`);
+  }
+  if (pricePerShare !== undefined && (!Number.isFinite(pricePerShare) || pricePerShare < 0)) {
+    throw new Error(`pricePerShare must be a non-negative number, got ${pricePerShare}`);
+  }
   return {
     id: `is_${randomUUID()}`,
     stakeholderId,
     securityClassId,
     qty,
     pps: pricePerShare,
     date,
     cert: certificateNumber,
   };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createIssuance(
stakeholderId: string,
securityClassId: string,
qty: number,
pricePerShare?: number,
date: string = getCurrentDate(),
certificateNumber?: string
): Issuance {
return {
id: `is_${randomUUID()}`,
stakeholderId,
securityClassId,
qty,
pps: pricePerShare,
date,
cert: certificateNumber,
};
}
export function createIssuance(
stakeholderId: string,
securityClassId: string,
qty: number,
pricePerShare?: number,
date: string = getCurrentDate(),
certificateNumber?: string
): Issuance {
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error(`qty must be a positive number, got ${qty}`);
}
if (
pricePerShare !== undefined &&
(!Number.isFinite(pricePerShare) || pricePerShare < 0)
) {
throw new Error(
`pricePerShare must be a non-negative number, got ${pricePerShare}`
);
}
return {
id: `is_${randomUUID()}`,
stakeholderId,
securityClassId,
qty,
pps: pricePerShare,
date,
cert: certificateNumber,
};
}
🤖 Prompt for AI Agents
In src/services/helpers.ts around lines 70 to 87, add input validation to
createIssuance so qty must be a positive number (> 0) and pricePerShare, if
provided, must be non-negative (>= 0); perform these checks at the top of the
function and throw a clear Error/TypeError with a descriptive message when
violated (e.g., "qty must be > 0" or "pricePerShare must be >= 0"), ensuring
undefined pricePerShare remains allowed but invalid numeric values are rejected;
keep the rest of the return object unchanged.

Comment on lines +125 to +144
export function createSAFE(
stakeholderId: string,
amount: number,
valuationCap?: number,
discountPct?: number,
isPostMoney: boolean = false,
date: string = getCurrentDate(),
note?: string
): SAFE {
return {
id: `safe_${randomUUID()}`,
stakeholderId,
amount,
cap: valuationCap,
discount: discountPct ? discountPct / 100 : undefined, // Convert to 0-1 range
type: isPostMoney ? 'post' : 'pre',
date,
note,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Validate SAFE inputs (amount > 0, discount in [0,1])

Prevent bad terms from entering the model.

 export function createSAFE(
   stakeholderId: string,
   amount: number,
   valuationCap?: number,
   discountPct?: number,
   isPostMoney: boolean = false,
   date: string = getCurrentDate(),
   note?: string
 ): SAFE {
+  if (!Number.isFinite(amount) || amount <= 0) {
+    throw new Error(`amount must be a positive number, got ${amount}`);
+  }
+  if (valuationCap !== undefined && (!Number.isFinite(valuationCap) || valuationCap <= 0)) {
+    throw new Error(`valuationCap must be a positive number if provided, got ${valuationCap}`);
+  }
+  const discount = discountPct != null ? discountPct / 100 : undefined;
+  if (discount != null && (!Number.isFinite(discount) || discount < 0 || discount >= 1)) {
+    throw new Error(`discountPct must be in [0, 100), got ${discountPct}`);
+  }
   return {
     id: `safe_${randomUUID()}`,
     stakeholderId,
     amount,
     cap: valuationCap,
-    discount: discountPct ? discountPct / 100 : undefined, // Convert to 0-1 range
+    discount, // already normalized
     type: isPostMoney ? 'post' : 'pre',
     date,
     note,
   };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createSAFE(
stakeholderId: string,
amount: number,
valuationCap?: number,
discountPct?: number,
isPostMoney: boolean = false,
date: string = getCurrentDate(),
note?: string
): SAFE {
return {
id: `safe_${randomUUID()}`,
stakeholderId,
amount,
cap: valuationCap,
discount: discountPct ? discountPct / 100 : undefined, // Convert to 0-1 range
type: isPostMoney ? 'post' : 'pre',
date,
note,
};
}
export function createSAFE(
stakeholderId: string,
amount: number,
valuationCap?: number,
discountPct?: number,
isPostMoney: boolean = false,
date: string = getCurrentDate(),
note?: string
): SAFE {
// Validate inputs
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error(`amount must be a positive number, got ${amount}`);
}
if (valuationCap !== undefined && (!Number.isFinite(valuationCap) || valuationCap <= 0)) {
throw new Error(`valuationCap must be a positive number if provided, got ${valuationCap}`);
}
const discount = discountPct != null ? discountPct / 100 : undefined;
if (discount != null && (!Number.isFinite(discount) || discount < 0 || discount >= 1)) {
throw new Error(`discountPct must be in [0, 100), got ${discountPct}`);
}
return {
id: `safe_${randomUUID()}`,
stakeholderId,
amount,
cap: valuationCap,
discount, // already normalized
type: isPostMoney ? 'post' : 'pre',
date,
note,
};
}

Comment on lines +12 to +31
function escapeCSVValue(value: string | number | undefined | null): string {
if (value === undefined || value === null) {
return '';
}

const str = String(value);

// Prevent formula injection - prepend single quote to formulas
if (str.length > 0 && ['=', '+', '-', '@', '\t', '\r'].includes(str[0])) {
return `"'${str.replace(/"/g, '""')}"`;
}

// Check if value needs escaping
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
// Escape quotes by doubling them and wrap in quotes
return `"${str.replace(/"/g, '""')}"`;
}

return str;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid prefixing a single quote on numeric negatives; only apply formula-protection to string inputs.

As written, -123 (number) will be emitted as "'-123" and quoted, causing spreadsheets to treat it as text instead of a number. This silently breaks downstream calculations and sorting. Limit formula-injection protection to string inputs and check the first non-whitespace character. Keep CSV quoting/escaping unified.

Apply this diff:

-function escapeCSVValue(value: string | number | undefined | null): string {
-  if (value === undefined || value === null) {
-    return '';
-  }
-
-  const str = String(value);
-
-  // Prevent formula injection - prepend single quote to formulas
-  if (str.length > 0 && ['=', '+', '-', '@', '\t', '\r'].includes(str[0])) {
-    return `"'${str.replace(/"/g, '""')}"`;
-  }
-
-  // Check if value needs escaping
-  if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
-    // Escape quotes by doubling them and wrap in quotes
-    return `"${str.replace(/"/g, '""')}"`;
-  }
-
-  return str;
-}
+function escapeCSVValue(value: string | number | undefined | null): string {
+  if (value === undefined || value === null) return '';
+
+  const isNumber = typeof value === 'number';
+  let str = String(value);
+
+  // Only apply formula-injection protection to string inputs
+  if (!isNumber) {
+    const firstNonWS = str.trimStart()[0];
+    if (firstNonWS && /[=+\-@]/.test(firstNonWS)) {
+      str = `'${str}`;
+    }
+  }
+
+  // Needs CSV quoting if commas, quotes, or newlines exist, or if we added a leading '
+  if (/[,"\r\n]/.test(str) || (!isNumber && str.startsWith("'"))) {
+    return `"${str.replace(/"/g, '""')}"`;
+  }
+
+  return str;
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function escapeCSVValue(value: string | number | undefined | null): string {
if (value === undefined || value === null) {
return '';
}
const str = String(value);
// Prevent formula injection - prepend single quote to formulas
if (str.length > 0 && ['=', '+', '-', '@', '\t', '\r'].includes(str[0])) {
return `"'${str.replace(/"/g, '""')}"`;
}
// Check if value needs escaping
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
// Escape quotes by doubling them and wrap in quotes
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function escapeCSVValue(value: string | number | undefined | null): string {
if (value === undefined || value === null) return '';
const isNumber = typeof value === 'number';
let str = String(value);
// Only apply formula-injection protection to string inputs
if (!isNumber) {
const firstNonWS = str.trimStart()[0];
if (firstNonWS && /[=+\-@]/.test(firstNonWS)) {
str = `'${str}`;
}
}
// Needs CSV quoting if commas, quotes, or newlines exist, or if we added a leading '
if (/[,"\r\n]/.test(str) || (!isNumber && str.startsWith("'"))) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
🤖 Prompt for AI Agents
In src/services/reporting-service.ts around lines 12 to 31, the current
escapeCSVValue function applies formula-injection protection to numeric inputs
(e.g., -123 becomes "'-123") which turns numbers into text; restrict the
formula-protection logic to string inputs only: if the original value is a
string, trim leading whitespace and inspect the first non-whitespace character
for formula characters (=,+,-,@,\t,\r) and prepend a single quote then perform
normal CSV quoting/escaping; if the value is a number (or non-string), convert
to string and only apply CSV quoting/escaping (double internal quotes and wrap
in quotes when necessary) so numeric negatives remain unquoted numeric strings
in the CSV output.

…ility

Type Safety Improvements:
- Replace all catch(error: any) with catch(error: unknown) and safe error extraction
- Update HandlerResult interface to use generic type with unknown default
- Remove any type usage in favor of unknown throughout handlers

Data Handling:
- Add case-insensitive email comparison in identifier resolver
- Add input trimming to handle whitespace in identifiers
- Lock all number formatting to 'en-US' locale for consistency

Performance & Testing:
- Add seeded PRNG (mulberry32) for deterministic performance tests
- Wrap performance logs in DEBUG_PERF environment flag
- Increase timeout thresholds to reduce CI flakiness
- Improve test stability with more generous assertions

Code Quality:
- CSV escaping already implemented with injection prevention
- Proper TypeScript error handling patterns throughout
- Consistent formatting across different environments

All 886 tests passing with improved type safety and deterministic behavior.
@acossta acossta merged commit 74e3f34 into main Aug 22, 2025
7 of 8 checks passed
@acossta acossta deleted the feature/cli-redesign-v4 branch August 22, 2025 16:32
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