feat: Complete CLI redesign with comprehensive testing#24
Conversation
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
|
Caution Review failedThe pull request is closed. WalkthroughRefactors 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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
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 detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 💡 Knowledge Base configuration:
You can enable these sources in your CodeRabbit configuration. 📒 Files selected for processing (6)
✨ Finishing Touches
🧪 Generate unit tests
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 15
🧹 Nitpick comments (70)
src/performance.test.ts (3)
365-365: Reduce flakiness: relax or gate the 500ms assertionThe 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 flakinessOption 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 cleanPerf 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 testsUse 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 TypingLoose
anyfordataweakens type-safety. Replace the current interface with a generic that defaults tounknown(so existing handlers won’t break) and add an optional machine-readablecode. 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 messageSince handlers already return
data, assert onresult.datafor stronger contracts (and keepmessagefor 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 updateValidate both the mutated in-memory captable and the returned
datato 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 removedCurrently 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” assertionThis 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/ppsHandlers parse strings via
parseInt/parseFloatbut 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 surfacingIf 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.
formatTableandhasStakeholderHoldingsare 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.
handleStakeholderListrenders its own table and does not callhelpers.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
--forceis 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: Preferunknownin catch blocks and normalize error messages.Using
anyin catches weakens type safety. Useunknownand 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_POOLfound. If multiple pools exist (common in later-stage companies), the CSV will misattribute grants. Consider persisting anoptionPoolIdonOptionGrant(helpers already accept it) and resolving labels by that ID during export.Would you like a follow-up PR to thread
optionPoolIdthrough 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'satisfiesisPrefixedId, 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 throwingstore.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 outputtoLocaleString() 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 valueConsider 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 intentHard-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: Preferunknownin catch and narrow toErrorCatching 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 inputEmail 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 wayNow 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 outputFor 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 localetoLocaleString 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: Preferunknownin catch and narrow toErrorAlign 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 CLIHandlers 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 executionSince 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 throwingSame 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 formattingLock 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 formattingApply 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 deterministicallyMinor 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 localeEnsure 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: Preferunknownin catch and narrow toErrorReplace 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 itManual init writes
version: 2but the wizard path usesversion: 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 wizardWizard 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 limitCurrently 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: Preferunknownoveranyin catch blocksUse
unknownto 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,000is 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 unusedanyparameters and catchany
- Change unused
_opts: anyparameters tounknownor remove them.- Prefer
unknownincatchblocks 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-insensitiveEmail comparisons should be case-insensitive to avoid duplicates like
Alice@Example.comvsalice@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 updateMirror 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: removeany, useunknown
handleStakeholderShow’s_optsis unused; type asunknownor omit.- Use
unknownincatchblocks 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: Removeanyfor unused params and catches
- Change
_opts: anytounknownor omit.- Use
unknownincatchand 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 helpFor 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 resolutionImproves 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 certificatePre-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 logsThis 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.
📒 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)
| 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)); | ||
| }); | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
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.
| // 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, | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| // 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.
src/identifier-resolver.ts
Outdated
| const captable = load('captable.json'); | ||
| if (!captable) { | ||
| return { | ||
| success: false, | ||
| error: 'No captable.json found', | ||
| }; | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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.
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
💡 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
exerciseandgrantDate(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=tsLength 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.
| 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.
| 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; | ||
| } |
There was a problem hiding this comment.
💡 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 / pricePerSharein 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=tsLength of output: 2340
Fix post-money SAFE conversion formula, add validation guards, and remove unused parameter
- Use the single-SAFE post-money cap formula:
This solves for the conversion price directly (cap − SAFE amount) over pre-SAFE shares.
// Before: capPrice = safe.cap / (outstandingShares + safe.amount / pricePerShare) // After: capPrice = (safe.cap - safe.amount) / outstandingShares
- Validate all computed prices and the round price:
- Throw if
pricePerShareis not a positive, finite number. - Only accept
capPriceordiscountPriceif they’re > 0 and finite; otherwise fall back topricePerShare.
- Throw if
- Remove the unused
_preMoneyValuationparameter 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)
- Handlers:
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.tssrc/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.
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (11)
src/handlers/export.handlers.ts (2)
22-41: Fix CSV formula-injection quoting and simplify escapingThe 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 CSVWriting 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/parseFloatwill 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 updatesMirror the add-path parsing/validation for
--qtyand--ppsto 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 logicThe “Assuming 10M shares” heuristic is misleading; conversion depends on round PPS, cap/discount terms, and post/pre-money context. Consider either:
- Reusing
helpers.calculateSAFEConversionsin show-mode for a sample PPS, or- Dropping the heuristic to avoid confusion.
Also,
--new-moneyis 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
handleSafeAddpassesdiscountPctdirectly intocreateSAFE, yet the rest of the code treatssafe.discountas 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 onlySumming 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
parseIntwill accept “100abc” → 100. Trim and reject non-digits; also trimpar. 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--authorizedTrim 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 unusedoptionPoolIdparam and validate qty/exercisePriceThis was flagged previously; rename to
_optionPoolIdto 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_preMoneyValuationPost‑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 ?? 0changes 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.exercisecan 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-USor 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 Scenariossrc/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 runnerssrc/handlers/export.handlers.ts (1)
135-161: Mirror the directory-creation fix for JSON exportsFor 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 ValueTotal value should be formatted to 2 decimals to avoid outputs like
$123.4or$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: Avoidanyfor handler optionsReplace
_opts: anywith a concrete type orRecord<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: Useunknownin catch blocks and normalize error messagesAvoid
anyin 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 outputFor 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: Avoidanyfor handler optionsUse 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 flagsPrefer an explicit
SafeConvertOptsshape and either wirenewMoneythrough 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: Avoidanyin handler signaturesUse a concrete type (even
{}) orRecord<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 jsonand keep message human-friendlyRight now
messagecontains the JSON string whiledataalso returns the array. Prefer keepingmessageshort and rely ondatafor 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
parValueto 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 priceGuard 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 grantsWith no
optionPoolIdon 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 labelMapping 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 vestedYour helper returns full qty when no schedule, while
vestedQtyin model returns 0 ifvestingis 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
vestedQtyrule 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.
📒 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
--forceis required.
171-176: QuickStart commands align with the new report flow.The switch to
report ownershipreads well. No issues.
201-216: SAFE commands section is concise and matches the new CLI verbs.Including
--dry-runfor 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 listandreport ownershippost-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 PREFERREDmatches code-enum/aliases as noted earlier.
439-443: JSON ownership report and audit log filtering examples look good.The action name
ISSUANCE_ADDmatches the new terminology. LGTM.
31-106: Security Kind Mapping Verified
The CLI correctly supports bothPREFERREDand the internalPREFenum:• 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 detectedI searched for additional
describe('Edge Cases', …)blocks insrc/services/equity-service.test.tsand 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 PolicyThe 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
addSecurityClassimplementation insrc/services/security-service.tsthrows only when a label already exists (throw new Error(\Security class "${label}" already exists`)) and does _not_ enforce uniqueness on thekind` 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
qtywill 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 escapingYou’ve correctly routed every field through
formatCSVRow/escapeCSVValuefor 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 calculationGood balance of readability and correctness;
calculateVestedOptionsusage is appropriate.src/services/helpers.ts (1)
242-265: LGTM on audit loggingConcise, deterministic actor, and timestamp via util. No changes needed.
| 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`; | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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); |
There was a problem hiding this comment.
💡 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.tsLength 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.
| 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); |
| export function handleSecurityShow(id: string | undefined, _opts: any): HandlerResult { | ||
| try { | ||
| if (!id) { | ||
| return { | ||
| success: false, | ||
| message: '❌ Please provide a security class ID', | ||
| }; | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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.
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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.
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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, | |
| }; | |
| } |
| 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; | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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.
Summary
This PR completes the CLI redesign to a resource-oriented architecture and adds comprehensive test coverage.
✨ Features
captan stakeholder add,captan issuance list)🧪 Testing
📚 Documentation
🔧 Technical Details
Breaking Changes
Old:
captan stakeholder --name "Alice"New:
captan stakeholder add --name "Alice"Old:
captan issue --holder alice@test.com --qty 1000New:
captan issuance add --stakeholder alice@test.com --qty 1000Test Coverage Report
Full Changelog: main...feature/cli-redesign-v4
Summary by CodeRabbit
New Features
Documentation
Tests
Refactor