diff --git a/analyzer/src/main.py b/analyzer/src/main.py index 1fc1cceb..5343f6b0 100644 --- a/analyzer/src/main.py +++ b/analyzer/src/main.py @@ -24,6 +24,7 @@ from .planning.plan_generator import generate_plan, save_plan from .scoring.impact_scorer import ImpactScorer from .utils.git_helper import GitHelper, GitTimeoutConfig +from .utils.shortuuid import is_valid as is_valid_shortuuid from .utils.state_manager import StateManager from .utils.workflow_state_manager import WorkflowStateManager from .writer.docstring_writer import DocstringWriter @@ -31,22 +32,29 @@ def validate_session_id(session_id: str) -> bool: - """Validate that session_id is a valid UUID format. + """Validate that session_id is a valid UUID or shortuuid format. - Session IDs are UUIDs generated by the TypeScript CLI layer. - This validation ensures Python commands receive properly formatted IDs. + Session IDs are generated by the TypeScript CLI layer. Accepts two formats: + - Standard UUID: 36 chars with hyphens (e.g., 550e8400-e29b-41d4-a716-446655440000) + - ShortUUID: 22 chars base57 encoded (e.g., vytxeTZskVKR7C7WgdSP3d) + + New sessions use shortuuid format, but existing UUID sessions remain valid. Args: session_id: The session ID string to validate. Returns: - True if valid UUID format, False otherwise. + True if valid UUID or shortuuid format, False otherwise. """ + # Check for standard UUID format try: uuid.UUID(session_id) return True except ValueError: - return False + pass + + # Check for shortuuid format + return is_valid_shortuuid(session_id) def compare_file_checksums( diff --git a/analyzer/src/utils/shortuuid.py b/analyzer/src/utils/shortuuid.py new file mode 100644 index 00000000..973fa61e --- /dev/null +++ b/analyzer/src/utils/shortuuid.py @@ -0,0 +1,197 @@ +"""ShortUUID utilities for DocImp session and transaction IDs. + +Generates concise, URL-safe UUIDs using base57 encoding (excludes ambiguous +characters: 0, 1, I, O, l). Produces 22-character strings from UUID4. + +Display formatting inserts hyphens every 4 characters from the right for +improved readability (e.g., `vytx-eTZs-kVKR`). + +Based on shortuuid library (https://github.com/skorokithakis/shortuuid). +""" + +import math +import uuid as _uu +from typing import Final, NewType + +ShortUUIDStr = NewType("ShortUUIDStr", str) + +# Base57 alphabet - excludes similar-looking characters (0, 1, I, O, l) +DEFAULT_ALPHABET: Final = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +# Pre-computed values for default alphabet +_ALPHABET_LIST: Final = list(DEFAULT_ALPHABET) +_ALPHABET_SET: Final = frozenset(DEFAULT_ALPHABET) +_ALPHABET_LEN: Final = len(DEFAULT_ALPHABET) +_ENCODED_LENGTH: Final = int(math.ceil(math.log(2**128, _ALPHABET_LEN))) # 22 + + +def _int_to_string(number: int, alphabet: list[str], padding: int | None = None) -> str: + """Convert an integer to a string using the given alphabet. + + The output has the most significant digit first. + + Args: + number: Non-negative integer to convert. + alphabet: List of characters to use as digits. + padding: Minimum output length (pads with first alphabet char). + + Returns: + Encoded string representation. + """ + alpha_len = len(alphabet) + digits: list[str] = [] + while number: + number, digit = divmod(number, alpha_len) + digits.append(alphabet[digit]) + if padding: + remainder = max(padding - len(digits), 0) + digits.extend(alphabet[0] for _ in range(remainder)) + digits.reverse() + return "".join(digits) + + +def _string_to_int(string: str, alphabet: list[str]) -> int: + """Convert a string to an integer using the given alphabet. + + The input is assumed to have the most significant digit first. + + Args: + string: Encoded string to convert. + alphabet: List of characters used as digits. + + Returns: + Decoded integer value. + + Raises: + ValueError: If string contains characters not in alphabet. + """ + number = 0 + alpha_len = len(alphabet) + for char in string: + try: + index = alphabet.index(char) + except ValueError: + raise ValueError(f"Invalid character '{char}' not in alphabet") from None + number = number * alpha_len + index + return number + + +def generate() -> ShortUUIDStr: + """Generate a new short UUID from a random UUID4. + + Returns: + 22-character base57-encoded string. + """ + return encode(_uu.uuid4()) + + +def encode(uuid_obj: _uu.UUID) -> ShortUUIDStr: + """Encode a UUID object to a short UUID string. + + Args: + uuid_obj: Standard library UUID object. + + Returns: + 22-character base57-encoded string. + + Raises: + TypeError: If uuid_obj is not a UUID instance. + """ + if not isinstance(uuid_obj, _uu.UUID): + raise TypeError(f"Expected UUID, got {type(uuid_obj).__name__}") + encoded = _int_to_string(uuid_obj.int, _ALPHABET_LIST, padding=_ENCODED_LENGTH) + return ShortUUIDStr(encoded) + + +def decode(short_uuid: str) -> _uu.UUID: + """Decode a short UUID string back to a UUID object. + + Automatically strips hyphens before processing. + + Args: + short_uuid: Base57-encoded string (hyphens are stripped). + + Returns: + Standard library UUID object. + + Raises: + ValueError: If string contains invalid characters or has wrong length. + """ + cleaned = strip_hyphens(short_uuid) + if len(cleaned) != _ENCODED_LENGTH: + raise ValueError( + f"Invalid short UUID length: {len(cleaned)} (expected {_ENCODED_LENGTH})" + ) + return _uu.UUID(int=_string_to_int(cleaned, _ALPHABET_LIST)) + + +def format_display(short_uuid: str, truncate: int | None = None) -> str: + """Format short UUID for display with hyphens every 4 chars from right. + + Args: + short_uuid: Raw short UUID string (22 chars) or hyphenated format. + truncate: Optional truncation length (e.g., 8 or 12). If provided, + takes first N characters before adding hyphens. + + Returns: + Formatted string with hyphens inserted every 4 characters from right. + Examples: + - Full (22 chars): `vy-txeT-ZskV-KR7C-7Wgd-SP3d` + - Truncate 8: `vytx-eTZs` + - Truncate 12: `vytx-eTZs-kVKR` + """ + # Strip any existing hyphens first + cleaned = strip_hyphens(short_uuid) + + # Apply truncation if requested + if truncate is not None: + cleaned = cleaned[:truncate] + + # Insert hyphens every 4 characters from the right + if len(cleaned) <= 4: + return cleaned + + remainder = len(cleaned) % 4 + result: list[str] = [cleaned[:remainder]] if remainder else [] + result.extend(cleaned[i : i + 4] for i in range(remainder, len(cleaned), 4)) + return "-".join(result) + + +def strip_hyphens(formatted: str) -> str: + """Strip all hyphens from a formatted short UUID. + + Args: + formatted: Short UUID string, possibly with hyphens. + + Returns: + String with all hyphens removed. + """ + return formatted.replace("-", "") + + +def is_valid(value: str) -> bool: + """Check if a string is a valid short UUID (hyphens allowed). + + Validates that after stripping hyphens: + - Length is exactly 22 characters + - All characters are in the base57 alphabet + + Args: + value: String to validate. + + Returns: + True if valid short UUID format, False otherwise. + """ + cleaned = strip_hyphens(value) + if len(cleaned) != _ENCODED_LENGTH: + return False + return all(char in _ALPHABET_SET for char in cleaned) + + +def get_alphabet() -> str: + """Return the alphabet used for encoding. + + Returns: + 57-character string of allowed characters. + """ + return DEFAULT_ALPHABET diff --git a/analyzer/tests/test_shortuuid.py b/analyzer/tests/test_shortuuid.py new file mode 100644 index 00000000..7e9e1c3d --- /dev/null +++ b/analyzer/tests/test_shortuuid.py @@ -0,0 +1,224 @@ +"""Unit tests for shortuuid module. + +Tests encode/decode functions, display formatting, and validation against +shared test vectors for cross-language compatibility verification. +""" + +import json +import uuid +from pathlib import Path + +import pytest + +from src.utils.shortuuid import ( + DEFAULT_ALPHABET, + decode, + encode, + format_display, + generate, + get_alphabet, + is_valid, + strip_hyphens, +) + + +@pytest.fixture +def test_vectors() -> dict: + """Load shared test vectors for cross-language compatibility testing.""" + vectors_path = ( + Path(__file__).parent.parent.parent / "test-fixtures" / "shortuuid-vectors.json" + ) + with vectors_path.open() as f: + return json.load(f) + + +class TestEncodeDecode: + """Tests for encode and decode functions.""" + + def test_encode_decode_roundtrip(self) -> None: + """Verify encode/decode roundtrip produces original UUID.""" + original = uuid.uuid4() + encoded = encode(original) + decoded = decode(encoded) + assert decoded == original + + def test_encode_produces_22_chars(self) -> None: + """Verify encoded output is exactly 22 characters.""" + for _ in range(10): + encoded = generate() + assert len(encoded) == 22, f"Expected 22 chars, got {len(encoded)}" + + def test_encode_uses_valid_alphabet(self) -> None: + """Verify encoded output only uses base57 alphabet characters.""" + for _ in range(10): + encoded = generate() + for char in encoded: + assert char in DEFAULT_ALPHABET, f"Invalid char '{char}'" + + def test_encode_decode_vectors(self, test_vectors: dict) -> None: + """Verify encode/decode against shared test vectors.""" + for vector in test_vectors["encode_decode_vectors"]: + uuid_str = vector["uuid"] + expected_short = vector["shortuuid"] + + # Test encode + uuid_obj = uuid.UUID(uuid_str) + actual_short = encode(uuid_obj) + assert actual_short == expected_short, ( + f"Encode mismatch for {uuid_str}: " + f"expected {expected_short}, got {actual_short}" + ) + + # Test decode + decoded = decode(expected_short) + assert str(decoded) == uuid_str, ( + f"Decode mismatch for {expected_short}: " + f"expected {uuid_str}, got {decoded}" + ) + + def test_decode_strips_hyphens(self) -> None: + """Verify decode accepts hyphenated input.""" + uuid_str = "3b1f8b40-222c-4a6e-b77e-779d5a94e21c" + shortuuid = "CXc85b4rqinB7s5J52TRYb" + hyphenated = "CXc8-5b4r-qinB-7s5J-52TR-Yb" + + # Both should decode to same UUID + decoded_plain = decode(shortuuid) + decoded_hyphenated = decode(hyphenated) + assert decoded_plain == decoded_hyphenated + assert str(decoded_plain) == uuid_str + + def test_decode_invalid_length(self) -> None: + """Verify decode raises error for wrong length.""" + with pytest.raises(ValueError, match="Invalid short UUID length"): + decode("tooshort") + + with pytest.raises(ValueError, match="Invalid short UUID length"): + decode("waytoolongtobevalidshortuuid") + + def test_decode_invalid_char(self) -> None: + """Verify decode raises error for invalid characters.""" + # Contains '0' which is not in alphabet + with pytest.raises(ValueError, match="Invalid character"): + decode("CXc85b4rqinB7s5J52TR0b") + + def test_encode_type_error(self) -> None: + """Verify encode raises error for non-UUID input.""" + with pytest.raises(TypeError): + encode("not-a-uuid") # type: ignore[arg-type] + + +class TestFormatDisplay: + """Tests for format_display function.""" + + def test_format_display_full(self, test_vectors: dict) -> None: + """Verify full display formatting matches test vectors.""" + for vector in test_vectors["format_display_vectors"]: + input_short = vector["input"] + expected = vector["full"] + actual = format_display(input_short) + assert actual == expected, ( + f"Format mismatch for {input_short}: expected {expected}, got {actual}" + ) + + def test_format_display_truncate_8(self, test_vectors: dict) -> None: + """Verify 8-char truncation formatting matches test vectors.""" + for vector in test_vectors["format_display_vectors"]: + input_short = vector["input"] + expected = vector["truncate_8"] + actual = format_display(input_short, truncate=8) + assert actual == expected, ( + f"Truncate 8 mismatch for {input_short}: " + f"expected {expected}, got {actual}" + ) + + def test_format_display_truncate_12(self, test_vectors: dict) -> None: + """Verify 12-char truncation formatting matches test vectors.""" + for vector in test_vectors["format_display_vectors"]: + input_short = vector["input"] + expected = vector["truncate_12"] + actual = format_display(input_short, truncate=12) + assert actual == expected, ( + f"Truncate 12 mismatch for {input_short}: " + f"expected {expected}, got {actual}" + ) + + def test_format_display_strips_existing_hyphens(self) -> None: + """Verify format_display handles already-hyphenated input.""" + hyphenated = "CXc8-5b4r-qinB" + result = format_display(hyphenated, truncate=8) + assert result == "CXc8-5b4r" + + def test_format_display_short_input(self) -> None: + """Verify format_display handles input shorter than 4 chars.""" + assert format_display("abc") == "abc" + assert format_display("ab") == "ab" + assert format_display("a") == "a" + + def test_format_display_exactly_4_chars(self) -> None: + """Verify format_display handles exactly 4 chars (no hyphen needed).""" + assert format_display("abcd") == "abcd" + + +class TestValidation: + """Tests for is_valid function.""" + + def test_valid_shortuuids(self, test_vectors: dict) -> None: + """Verify valid shortuuids pass validation.""" + for vector in test_vectors["validation_vectors"]: + if vector["valid"]: + assert is_valid(vector["input"]), ( + f"Expected valid: {vector['input']} ({vector['comment']})" + ) + + def test_invalid_shortuuids(self, test_vectors: dict) -> None: + """Verify invalid shortuuids fail validation.""" + for vector in test_vectors["validation_vectors"]: + if not vector["valid"]: + assert not is_valid(vector["input"]), ( + f"Expected invalid: {vector['input']} ({vector['comment']})" + ) + + def test_generated_is_valid(self) -> None: + """Verify generate() produces valid shortuuids.""" + for _ in range(10): + shortuuid = generate() + assert is_valid(shortuuid) + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_strip_hyphens(self) -> None: + """Verify strip_hyphens removes all hyphens.""" + assert strip_hyphens("CXc8-5b4r-qinB") == "CXc85b4rqinB" + assert strip_hyphens("no-hyphens") == "nohyphens" + assert strip_hyphens("nohyphens") == "nohyphens" + assert strip_hyphens("") == "" + + def test_get_alphabet(self) -> None: + """Verify get_alphabet returns expected alphabet.""" + alphabet = get_alphabet() + assert alphabet == DEFAULT_ALPHABET + assert len(alphabet) == 57 + + # Verify excluded characters + excluded = "01IOl" + for char in excluded: + assert char not in alphabet, f"Char '{char}' should be excluded" + + +class TestGenerate: + """Tests for generate function.""" + + def test_generate_unique(self) -> None: + """Verify generate produces unique values.""" + generated = {generate() for _ in range(100)} + assert len(generated) == 100, "Generated shortuuids should be unique" + + def test_generate_valid_format(self) -> None: + """Verify generate produces valid 22-char strings.""" + for _ in range(10): + shortuuid = generate() + assert len(shortuuid) == 22 + assert is_valid(shortuuid) diff --git a/cli/src/__tests__/integration/audit-sessions.test.ts b/cli/src/__tests__/integration/audit-sessions.test.ts index b5acc606..bc589a39 100644 --- a/cli/src/__tests__/integration/audit-sessions.test.ts +++ b/cli/src/__tests__/integration/audit-sessions.test.ts @@ -264,7 +264,7 @@ describe('Audit Session Management Integration Tests', () => { await expect( deleteAuditSessionCore(invalidSessionId, {}) ).rejects.toThrow( - 'Invalid session ID format: not-a-valid-uuid. Expected UUID format' + 'Invalid session ID format: not-a-valid-uuid. Expected UUID (36 chars) or shortuuid (22 chars base57).' ); // Verify session lookup was never attempted diff --git a/cli/src/__tests__/integration/improve-sessions.test.ts b/cli/src/__tests__/integration/improve-sessions.test.ts index 8ff5c834..e2d0a660 100644 --- a/cli/src/__tests__/integration/improve-sessions.test.ts +++ b/cli/src/__tests__/integration/improve-sessions.test.ts @@ -379,7 +379,7 @@ describe('Improve Session Management Integration Tests', () => { await expect( deleteImproveSessionCore(invalidSessionId, { all: false, force: false }) ).rejects.toThrow( - 'Invalid session ID format: not-a-valid-uuid. Expected UUID format' + 'Invalid session ID format: not-a-valid-uuid. Expected UUID (36 chars) or shortuuid (22 chars base57).' ); // Verify session lookup was never attempted diff --git a/cli/src/__tests__/session/InteractiveSession.test.ts b/cli/src/__tests__/session/InteractiveSession.test.ts index b7229aef..b1ec40b6 100644 --- a/cli/src/__tests__/session/InteractiveSession.test.ts +++ b/cli/src/__tests__/session/InteractiveSession.test.ts @@ -242,7 +242,8 @@ describe('InteractiveSession', () => { expect(mockPythonBridge.beginTransaction).toHaveBeenCalledWith( expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + // Shortuuid format: 22 chars, base57 alphabet + /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$/ ) ); }); @@ -320,7 +321,8 @@ describe('InteractiveSession', () => { expect(mockPythonBridge.recordWrite).toHaveBeenCalledWith( expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + // Shortuuid format: 22 chars, base57 alphabet + /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$/ ), mockPlanItem.filepath, expect.stringContaining('.bak'), @@ -411,7 +413,8 @@ describe('InteractiveSession', () => { expect(mockPythonBridge.commitTransaction).toHaveBeenCalledWith( expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + // Shortuuid format: 22 chars, base57 alphabet + /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$/ ) ); expect(mockPythonBridge.commitTransaction).toHaveBeenCalledTimes(1); @@ -1196,7 +1199,8 @@ describe('InteractiveSession', () => { expect(mockPythonBridge.beginTransaction).toHaveBeenCalledTimes(1); expect(mockPythonBridge.beginTransaction).toHaveBeenCalledWith( expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + // Shortuuid format: 22 chars, base57 alphabet + /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$/ ) ); diff --git a/cli/src/__tests__/utils/shortuuid.test.ts b/cli/src/__tests__/utils/shortuuid.test.ts new file mode 100644 index 00000000..0c221809 --- /dev/null +++ b/cli/src/__tests__/utils/shortuuid.test.ts @@ -0,0 +1,240 @@ +/** + * Unit tests for shortuuid module. + * + * Tests encode/decode functions, display formatting, and validation against + * shared test vectors for cross-language compatibility verification. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + decode, + DEFAULT_ALPHABET, + encode, + formatDisplay, + generate, + getAlphabet, + isValid, + stripHyphens, +} from '../../utils/shortuuid.js'; + +interface TestVectors { + alphabet: string; + encode_decode_vectors: Array<{ + comment: string; + uuid: string; + shortuuid: string; + }>; + format_display_vectors: Array<{ + input: string; + full: string; + truncate_8: string; + truncate_12: string; + }>; + validation_vectors: Array<{ + input: string; + valid: boolean; + comment: string; + }>; +} + +function loadTestVectors(): TestVectors { + const vectorsPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'test-fixtures', + 'shortuuid-vectors.json' + ); + return JSON.parse(readFileSync(vectorsPath, 'utf-8')); +} + +describe('shortuuid', () => { + const testVectors = loadTestVectors(); + + describe('encode/decode', () => { + it('should roundtrip encode/decode correctly', () => { + // Generate a random UUID and verify roundtrip + const shortuuid = generate(); + const decoded = decode(shortuuid); + const reEncoded = encode(decoded); + expect(reEncoded).toBe(shortuuid); + }); + + it('should produce 22-character output', () => { + for (let i = 0; i < 10; i++) { + const encoded = generate(); + expect(encoded).toHaveLength(22); + } + }); + + it('should only use valid alphabet characters', () => { + for (let i = 0; i < 10; i++) { + const encoded = generate(); + for (const char of encoded) { + expect(DEFAULT_ALPHABET).toContain(char); + } + } + }); + + it('should match encode/decode test vectors', () => { + for (const vector of testVectors.encode_decode_vectors) { + // Test encode + const encoded = encode(vector.uuid); + expect(encoded).toBe(vector.shortuuid); + + // Test decode + const decoded = decode(vector.shortuuid); + expect(decoded).toBe(vector.uuid); + } + }); + + it('should decode hyphenated input', () => { + const uuidStr = '3b1f8b40-222c-4a6e-b77e-779d5a94e21c'; + const shortuuid = 'CXc85b4rqinB7s5J52TRYb'; + const hyphenated = 'CXc8-5b4r-qinB-7s5J-52TR-Yb'; + + // Both should decode to same UUID + expect(decode(shortuuid)).toBe(uuidStr); + expect(decode(hyphenated)).toBe(uuidStr); + }); + + it('should throw on invalid length', () => { + expect(() => decode('tooshort')).toThrow('Invalid short UUID length'); + expect(() => decode('waytoolongtobevalidshortuuid')).toThrow( + 'Invalid short UUID length' + ); + }); + + it('should throw on invalid character', () => { + // Contains '0' which is not in alphabet + expect(() => decode('CXc85b4rqinB7s5J52TR0b')).toThrow( + 'Invalid character' + ); + }); + }); + + describe('formatDisplay', () => { + it('should format full shortuuid with hyphens', () => { + for (const vector of testVectors.format_display_vectors) { + expect(formatDisplay(vector.input)).toBe(vector.full); + } + }); + + it('should format with truncate 8', () => { + for (const vector of testVectors.format_display_vectors) { + expect(formatDisplay(vector.input, { truncate: 8 })).toBe( + vector.truncate_8 + ); + } + }); + + it('should format with truncate 12', () => { + for (const vector of testVectors.format_display_vectors) { + expect(formatDisplay(vector.input, { truncate: 12 })).toBe( + vector.truncate_12 + ); + } + }); + + it('should strip existing hyphens before formatting', () => { + const hyphenated = 'CXc8-5b4r-qinB'; + expect(formatDisplay(hyphenated, { truncate: 8 })).toBe('CXc8-5b4r'); + }); + + it('should handle input shorter than 4 chars', () => { + expect(formatDisplay('abc')).toBe('abc'); + expect(formatDisplay('ab')).toBe('ab'); + expect(formatDisplay('a')).toBe('a'); + }); + + it('should handle exactly 4 chars without hyphen', () => { + expect(formatDisplay('abcd')).toBe('abcd'); + }); + }); + + describe('isValid', () => { + it('should accept valid shortuuids', () => { + for (const vector of testVectors.validation_vectors) { + if (vector.valid) { + expect(isValid(vector.input)).toBe(true); + } + } + }); + + it('should reject invalid shortuuids', () => { + for (const vector of testVectors.validation_vectors) { + if (!vector.valid) { + expect(isValid(vector.input)).toBe(false); + } + } + }); + + it('should validate generated shortuuids', () => { + for (let i = 0; i < 10; i++) { + const shortuuid = generate(); + expect(isValid(shortuuid)).toBe(true); + } + }); + }); + + describe('stripHyphens', () => { + it('should remove all hyphens', () => { + expect(stripHyphens('CXc8-5b4r-qinB')).toBe('CXc85b4rqinB'); + expect(stripHyphens('no-hyphens')).toBe('nohyphens'); + expect(stripHyphens('nohyphens')).toBe('nohyphens'); + expect(stripHyphens('')).toBe(''); + }); + }); + + describe('getAlphabet', () => { + it('should return expected alphabet', () => { + const alphabet = getAlphabet(); + expect(alphabet).toBe(DEFAULT_ALPHABET); + expect(alphabet).toHaveLength(57); + }); + + it('should exclude ambiguous characters', () => { + const alphabet = getAlphabet(); + const excluded = '01IOl'; + for (const char of excluded) { + expect(alphabet).not.toContain(char); + } + }); + }); + + describe('generate', () => { + it('should produce unique values', () => { + const generated = new Set(); + for (let i = 0; i < 100; i++) { + generated.add(generate()); + } + expect(generated.size).toBe(100); + }); + + it('should produce valid 22-char strings', () => { + for (let i = 0; i < 10; i++) { + const shortuuid = generate(); + expect(shortuuid).toHaveLength(22); + expect(isValid(shortuuid)).toBe(true); + } + }); + }); + + describe('cross-language compatibility', () => { + it('should use matching alphabet', () => { + expect(getAlphabet()).toBe(testVectors.alphabet); + }); + + it('should produce deterministic encoding', () => { + // Verify that encoding is deterministic by checking all test vectors + for (const vector of testVectors.encode_decode_vectors) { + const encoded = encode(vector.uuid); + expect(encoded).toBe(vector.shortuuid); + } + }); + }); +}); diff --git a/cli/src/commands/audit-sessions.ts b/cli/src/commands/audit-sessions.ts index 4e716868..19230722 100644 --- a/cli/src/commands/audit-sessions.ts +++ b/cli/src/commands/audit-sessions.ts @@ -11,7 +11,10 @@ import prompts from 'prompts'; import { EXIT_CODE, type ExitCode } from '../constants/exit-codes.js'; import type { AuditSessionState } from '../types/audit-session-state.js'; import { SessionStateManager } from '../utils/session-state-manager.js'; -import { isValidUuid } from '../utils/validation.js'; +import { + formatSessionIdForDisplay, + isValidSessionId, +} from '../utils/validation.js'; /** * Format elapsed time in human-readable format. @@ -68,7 +71,7 @@ export async function listAuditSessionsCore(): Promise { for (const session of sessions) { const auditSession = session as AuditSessionState; - const sessionId = auditSession.session_id.slice(0, 12); + const sessionId = formatSessionIdForDisplay(auditSession.session_id, 12); const started = formatElapsedTime(auditSession.started_at); const completed = auditSession.completed_at ? formatElapsedTime(auditSession.completed_at) @@ -184,11 +187,11 @@ export async function deleteAuditSessionCore( throw new Error('Session ID is required'); } - // Validate UUID format - if (!isValidUuid(sessionId)) { + // Validate session ID format (UUID or shortuuid) + if (!isValidSessionId(sessionId)) { throw new Error( `Invalid session ID format: ${sessionId}. ` + - 'Expected UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000)' + 'Expected UUID (36 chars) or shortuuid (22 chars base57).' ); } @@ -210,7 +213,7 @@ export async function deleteAuditSessionCore( const response = await prompts({ type: 'confirm', name: 'value', - message: `Delete audit session ${sessionId.slice(0, 12)}?`, + message: `Delete audit session ${formatSessionIdForDisplay(sessionId, 12)}?`, initial: false, }); @@ -223,7 +226,11 @@ export async function deleteAuditSessionCore( // Delete session await SessionStateManager.deleteSessionState(sessionId, 'audit'); - console.log(chalk.green(`Deleted audit session ${sessionId.slice(0, 12)}.`)); + console.log( + chalk.green( + `Deleted audit session ${formatSessionIdForDisplay(sessionId, 12)}.` + ) + ); } /** diff --git a/cli/src/commands/audit.ts b/cli/src/commands/audit.ts index 591ee325..e0f126ad 100644 --- a/cli/src/commands/audit.ts +++ b/cli/src/commands/audit.ts @@ -5,7 +5,6 @@ * documented items to the user for interactive rating. */ -import { randomUUID } from 'node:crypto'; import chalk from 'chalk'; import Table from 'cli-table3'; import prompts from 'prompts'; @@ -26,7 +25,9 @@ import { CodeExtractor } from '../utils/code-extractor.js'; import { FileTracker } from '../utils/file-tracker.js'; import { PathValidator } from '../utils/path-validator.js'; import { SessionStateManager } from '../utils/session-state-manager.js'; +import { generate as generateSessionId } from '../utils/shortuuid.js'; import { StateManager } from '../utils/state-manager.js'; +import { formatSessionIdForDisplay } from '../utils/validation.js'; import { WorkflowValidator } from '../utils/workflow-validator.js'; /** @@ -118,7 +119,7 @@ async function initializeAuditSession( maxLines: number; } ): Promise { - const sessionId = randomUUID(); + const sessionId = generateSessionId(); // Extract unique filepaths from items const filepaths = [...new Set(items.map((item) => item.filepath))]; @@ -240,7 +241,7 @@ async function promptSelectSession( for (const [index, session] of incomplete.entries()) { const auditSession = session as AuditSessionState; - const sessionId = auditSession.session_id.slice(0, 12); + const sessionId = formatSessionIdForDisplay(auditSession.session_id, 12); const progress = `${auditSession.current_index}/${auditSession.total_items} rated`; const started = formatElapsedTime(String(auditSession.started_at)); table.push([index + 1, sessionId, progress, started]); @@ -313,7 +314,7 @@ async function loadResumeSession( const validated = AuditSessionStateSchema.parse(sessionState); // Show concise banner - const shortSessionId = validated.session_id.slice(0, 8); + const shortSessionId = formatSessionIdForDisplay(validated.session_id, 8); const progress = `${validated.current_index}/${validated.total_items} rated`; display.showMessage(`Resuming session ${shortSessionId} (${progress})`); @@ -507,7 +508,7 @@ async function detectAndPromptResume( const latest = incomplete[0] as AuditSessionState; // listSessions returns sorted by started_at desc const elapsed = formatElapsedTime(String(latest.started_at)); const progress = `${latest.current_index}/${latest.total_items} rated`; - const sessionId = latest.session_id.slice(0, 8); + const sessionId = formatSessionIdForDisplay(latest.session_id, 8); // Prompt user (default Yes) const shouldResume = await promptYesNo( diff --git a/cli/src/commands/improve-sessions.ts b/cli/src/commands/improve-sessions.ts index 37f78d59..afcb236d 100644 --- a/cli/src/commands/improve-sessions.ts +++ b/cli/src/commands/improve-sessions.ts @@ -14,7 +14,10 @@ import { EXIT_CODE, type ExitCode } from '../constants/exit-codes.js'; import type { ImproveSessionState } from '../types/improve-session-state.js'; import { SessionStateManager } from '../utils/session-state-manager.js'; import { StateManager } from '../utils/state-manager.js'; -import { isValidUuid } from '../utils/validation.js'; +import { + formatSessionIdForDisplay, + isValidSessionId, +} from '../utils/validation.js'; const execPromise = promisify(exec); @@ -168,8 +171,11 @@ export async function listImproveSessionsCore(): Promise { for (const session of sessions) { const improveSession = session as ImproveSessionState; - const sessionId = improveSession.session_id.slice(0, 12); - const transactionId = improveSession.transaction_id.slice(0, 12); + const sessionId = formatSessionIdForDisplay(improveSession.session_id, 12); + const transactionId = formatSessionIdForDisplay( + improveSession.transaction_id, + 12 + ); const started = formatElapsedTime(improveSession.started_at); const completed = improveSession.completed_at ? formatElapsedTime(improveSession.completed_at) @@ -291,11 +297,11 @@ export async function deleteImproveSessionCore( throw new Error('Session ID is required'); } - // Validate UUID format - if (!isValidUuid(sessionId)) { + // Validate session ID format (UUID or shortuuid) + if (!isValidSessionId(sessionId)) { throw new Error( `Invalid session ID format: ${sessionId}. ` + - 'Expected UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000)' + 'Expected UUID (36 chars) or shortuuid (22 chars base57).' ); } @@ -317,7 +323,7 @@ export async function deleteImproveSessionCore( const response = await prompts({ type: 'confirm', name: 'value', - message: `Delete improve session ${sessionId.slice(0, 12)}?`, + message: `Delete improve session ${formatSessionIdForDisplay(sessionId, 12)}?`, initial: false, }); @@ -331,7 +337,9 @@ export async function deleteImproveSessionCore( // Delete session await SessionStateManager.deleteSessionState(sessionId, 'improve'); console.log( - chalk.green(`Deleted improve session ${sessionId.slice(0, 12)}.`) + chalk.green( + `Deleted improve session ${formatSessionIdForDisplay(sessionId, 12)}.` + ) ); } diff --git a/cli/src/commands/improve.ts b/cli/src/commands/improve.ts index 4a66ff05..3a1df6de 100644 --- a/cli/src/commands/improve.ts +++ b/cli/src/commands/improve.ts @@ -40,6 +40,7 @@ import { FileTracker } from '../utils/file-tracker.js'; import { PathValidator } from '../utils/path-validator.js'; import { SessionStateManager } from '../utils/session-state-manager.js'; import { StateManager } from '../utils/state-manager.js'; +import { formatSessionIdForDisplay } from '../utils/validation.js'; import { WorkflowValidator, formatStalenessWarning, @@ -135,7 +136,7 @@ async function promptSelectSession( for (const [index, session] of incomplete.entries()) { const improveSession = session as ImproveSessionState; - const sessionId = improveSession.session_id.slice(0, 12); + const sessionId = formatSessionIdForDisplay(improveSession.session_id, 12); // Count processed items (with status records) let processed = 0; @@ -237,7 +238,7 @@ async function loadResumeImproveSession( const remaining = validated.total_items - processed; // Show concise banner - const shortSessionId = validated.session_id.slice(0, 8); + const shortSessionId = formatSessionIdForDisplay(validated.session_id, 8); display.showMessage( `Resuming session ${shortSessionId} (${processed} processed, ${remaining} remaining)` ); @@ -428,7 +429,7 @@ async function detectAndPromptResumeImprove( const remaining = latest.total_items - processed; const elapsed = formatElapsedTime(String(latest.started_at)); - const sessionId = latest.session_id.slice(0, 8); + const sessionId = formatSessionIdForDisplay(latest.session_id, 8); // Prompt user (default Yes) const shouldResume = await promptYesNo( diff --git a/cli/src/session/interactive-session.ts b/cli/src/session/interactive-session.ts index e0032eb6..282ebfe0 100644 --- a/cli/src/session/interactive-session.ts +++ b/cli/src/session/interactive-session.ts @@ -11,7 +11,6 @@ import chalk from 'chalk'; import prompts from 'prompts'; -import { v4 as uuidv4 } from 'uuid'; import { UserCancellationError } from '../commands/improve.js'; import type { IConfig } from '../config/i-config.js'; import type { IEditorLauncher } from '../editor/i-editor-launcher.js'; @@ -22,6 +21,8 @@ import type { PlanItem, SupportedLanguage } from '../types/analysis.js'; import type { ImproveSessionState } from '../types/improve-session-state.js'; import { FileTracker } from '../utils/file-tracker.js'; import { SessionStateManager } from '../utils/session-state-manager.js'; +import { generate as generateSessionId } from '../utils/shortuuid.js'; +import { formatSessionIdForDisplay } from '../utils/validation.js'; import type { IInteractiveSession } from './i-interactive-session.js'; import { ProgressTracker } from './progress-tracker.js'; @@ -154,7 +155,7 @@ export class InteractiveSession implements IInteractiveSession { // Transaction not found - branch may have been deleted manually console.log( chalk.yellow( - `Warning: Transaction branch not found for session ${this.sessionState.session_id.slice(0, 8)}.` + `Warning: Transaction branch not found for session ${formatSessionIdForDisplay(this.sessionState.session_id, 8)}.` ) ); console.log( @@ -194,7 +195,7 @@ export class InteractiveSession implements IInteractiveSession { // Transaction was committed - create new transaction as continuation console.log( chalk.yellow( - `Session ${this.sessionState.session_id.slice(0, 8)} was previously committed.` + `Session ${formatSessionIdForDisplay(this.sessionState.session_id, 8)} was previously committed.` ) ); console.log( @@ -202,7 +203,7 @@ export class InteractiveSession implements IInteractiveSession { ); // Generate new transaction ID for continuation - const newSessionId = uuidv4(); + const newSessionId = generateSessionId(); const oldSessionId = this.sessionState.session_id; try { @@ -234,21 +235,21 @@ export class InteractiveSession implements IInteractiveSession { case 'rolled_back': { // Transaction was rolled back - cannot resume throw new Error( - `Cannot resume session ${this.sessionState.session_id.slice(0, 8)}: transaction has been rolled back.\n` + + `Cannot resume session ${formatSessionIdForDisplay(this.sessionState.session_id, 8)}: transaction has been rolled back.\n` + `Use 'docimp improve --new' to start a fresh session.` ); } case 'partial_rollback': { // Transaction has partial rollback - cannot resume throw new Error( - `Cannot resume session ${this.sessionState.session_id.slice(0, 8)}: transaction has partial rollback.\n` + + `Cannot resume session ${formatSessionIdForDisplay(this.sessionState.session_id, 8)}: transaction has partial rollback.\n` + `Use 'docimp improve --new' to start a fresh session.` ); } // No default } } else { - this.sessionId = uuidv4(); + this.sessionId = generateSessionId(); try { await this.pythonBridge.beginTransaction(this.sessionId); @@ -280,7 +281,7 @@ export class InteractiveSession implements IInteractiveSession { if (this.transactionActive) { console.log( chalk.dim( - `Transaction tracking: enabled (session ${this.sessionId?.slice(0, 8)}...)\n` + `Transaction tracking: enabled (session ${this.sessionId ? formatSessionIdForDisplay(this.sessionId, 8) : 'unknown'}...)\n` ) ); } else { @@ -337,7 +338,7 @@ export class InteractiveSession implements IInteractiveSession { try { await this.pythonBridge.commitTransaction(this.sessionId); console.log(chalk.green('\nSession finalized. Changes committed.')); - const shortId = this.sessionId.slice(0, 8); + const shortId = formatSessionIdForDisplay(this.sessionId, 8); console.log( chalk.dim(`Session ID: ${shortId}... (full: ${this.sessionId})`) ); diff --git a/cli/src/types/audit-session-state.ts b/cli/src/types/audit-session-state.ts index 62c9aa8d..a4246552 100644 --- a/cli/src/types/audit-session-state.ts +++ b/cli/src/types/audit-session-state.ts @@ -6,6 +6,7 @@ */ import { z } from 'zod'; +import { SessionIdSchema } from './session-id.js'; /** * Schema for FileSnapshot. @@ -47,7 +48,7 @@ export type AuditConfig = z.infer; */ export const AuditSessionStateSchema = z .object({ - session_id: z.string().uuid(), + session_id: SessionIdSchema, schema_version: z.string().default('1.0'), // Schema version for migration support started_at: z.string().datetime(), // ISO 8601 timestamp current_index: z.number().int().nonnegative(), diff --git a/cli/src/types/improve-session-state.ts b/cli/src/types/improve-session-state.ts index 2ecb239a..21f53e44 100644 --- a/cli/src/types/improve-session-state.ts +++ b/cli/src/types/improve-session-state.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { FileSnapshotSchema } from './audit-session-state.js'; +import { SessionIdSchema } from './session-id.js'; /** * Schema for improve status record. @@ -47,9 +48,9 @@ export type ImproveConfig = z.infer; */ export const ImproveSessionStateSchema = z .object({ - session_id: z.string().uuid(), + session_id: SessionIdSchema, schema_version: z.string().default('1.0'), // Schema version for migration support - transaction_id: z.string().uuid(), // Links to git transaction branch + transaction_id: SessionIdSchema, // Links to git transaction branch started_at: z.string().datetime(), // ISO 8601 timestamp current_index: z.number().int().nonnegative(), total_items: z.number().int().positive(), @@ -63,7 +64,7 @@ export const ImproveSessionStateSchema = z file_snapshot: z.record(z.string(), FileSnapshotSchema), // filepath -> FileSnapshot config: ImproveConfigSchema, completed_at: z.string().datetime().nullable(), // ISO 8601 timestamp or null if in-progress - previous_session_id: z.string().uuid().optional(), // Links to previous session if this is a continuation + previous_session_id: SessionIdSchema.optional(), // Links to previous session if this is a continuation }) .passthrough(); // Keep .passthrough() for forward compatibility - allows loading sessions from newer versions diff --git a/cli/src/types/session-id.ts b/cli/src/types/session-id.ts new file mode 100644 index 00000000..76a89ba4 --- /dev/null +++ b/cli/src/types/session-id.ts @@ -0,0 +1,29 @@ +/** + * Zod schema for session ID validation. + * + * Accepts both standard UUIDs and shortuuids for backward compatibility. + * New sessions use shortuuid format (22 chars), but existing sessions + * with standard UUIDs (36 chars) remain valid indefinitely. + */ + +import { z } from 'zod'; +import { isValidSessionId } from '../utils/validation.js'; + +/** + * Custom Zod schema for session ID validation. + * + * Accepts both standard UUID and shortuuid formats for backward compatibility. + * - Standard UUID: 36 chars with hyphens (e.g., 550e8400-e29b-41d4-a716-446655440000) + * - ShortUUID: 22 chars base57 encoded (e.g., vytxeTZskVKR7C7WgdSP3d) + * + * New sessions use shortuuid format, but existing sessions with UUIDs remain valid. + */ +export const SessionIdSchema = z.string().refine(isValidSessionId, { + message: + 'Invalid session ID format. Expected UUID (36 chars) or shortuuid (22 chars base57)', +}); + +/** + * Type alias for session ID. + */ +export type SessionId = z.infer; diff --git a/cli/src/utils/shortuuid.ts b/cli/src/utils/shortuuid.ts new file mode 100644 index 00000000..e75bb346 --- /dev/null +++ b/cli/src/utils/shortuuid.ts @@ -0,0 +1,241 @@ +/** + * ShortUUID utilities for DocImp session and transaction IDs. + * + * Generates concise, URL-safe UUIDs using base57 encoding (excludes ambiguous + * characters: 0, 1, I, O, l). Produces 22-character strings from UUID4. + * + * Display formatting inserts hyphens every 4 characters from the right for + * improved readability (e.g., `vytx-eTZs-kVKR`). + * + * IMPORTANT: This implementation must produce byte-for-byte identical output + * to the Python shortuuid module for cross-language compatibility. + * + * Based on shortuuid library (https://github.com/skorokithakis/shortuuid). + */ + +import { randomUUID } from 'node:crypto'; + +/** Base57 alphabet - excludes similar-looking characters (0, 1, I, O, l) */ +export const DEFAULT_ALPHABET = + '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +/** Pre-computed values for default alphabet */ +const ALPHABET_LIST = [...DEFAULT_ALPHABET]; +const ALPHABET_SET = new Set(DEFAULT_ALPHABET); +const ENCODED_LENGTH = 22; // ceil(log(2^128) / log(57)) + +/** + * Convert a BigInt to a string using the given alphabet. + * + * The output has the most significant digit first. + * + * @param number - Non-negative BigInt to convert. + * @param alphabet - Array of characters to use as digits. + * @param padding - Minimum output length (pads with first alphabet char). + * @returns Encoded string representation. + */ +function intToString( + number: bigint, + alphabet: string[], + padding?: number +): string { + const alphabetLength = BigInt(alphabet.length); + const digits: string[] = []; + + while (number > 0n) { + const remainder = number % alphabetLength; + number = number / alphabetLength; + digits.push(alphabet[Number(remainder)]); + } + + if (padding) { + const remainderCount = Math.max(padding - digits.length, 0); + for (let i = 0; i < remainderCount; i++) { + digits.push(alphabet[0]); + } + } + + digits.reverse(); + return digits.join(''); +} + +/** + * Convert a string to a BigInt using the given alphabet. + * + * The input is assumed to have the most significant digit first. + * + * @param encoded - Encoded string to convert. + * @param alphabet - Array of characters used as digits. + * @returns Decoded BigInt value. + * @throws Error if string contains characters not in alphabet. + */ +function stringToInt(encoded: string, alphabet: string[]): bigint { + const alphabetLength = BigInt(alphabet.length); + let number = 0n; + + for (const char of encoded) { + const index = alphabet.indexOf(char); + if (index === -1) { + throw new Error(`Invalid character '${char}' not in alphabet`); + } + number = number * alphabetLength + BigInt(index); + } + + return number; +} + +/** + * Convert a UUID string to a BigInt. + * + * @param uuid - UUID string in standard format (with or without hyphens). + * @returns BigInt representation of the UUID. + */ +function uuidToInt(uuid: string): bigint { + const hex = uuid.replaceAll('-', ''); + return BigInt('0x' + hex); +} + +/** + * Convert a BigInt to a UUID string. + * + * @param value - BigInt representation of a UUID (0 to 2^128 - 1). + * @returns UUID string in standard format with hyphens. + */ +function intToUuid(value: bigint): string { + const hex = value.toString(16).padStart(32, '0'); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +/** + * Generate a new short UUID from a random UUID4. + * + * @returns 22-character base57-encoded string. + */ +export function generate(): string { + const uuid = randomUUID(); + return encode(uuid); +} + +/** + * Encode a UUID string to a short UUID string. + * + * @param uuid - Standard UUID string (36 chars with hyphens). + * @returns 22-character base57-encoded string. + */ +export function encode(uuid: string): string { + const value = uuidToInt(uuid); + return intToString(value, ALPHABET_LIST, ENCODED_LENGTH); +} + +/** + * Decode a short UUID string back to a standard UUID string. + * + * Automatically strips hyphens before processing. + * + * @param shortUuid - Base57-encoded string (hyphens are stripped). + * @returns Standard UUID string (36 chars with hyphens). + * @throws Error if string contains invalid characters or has wrong length. + */ +export function decode(shortUuid: string): string { + const cleaned = stripHyphens(shortUuid); + if (cleaned.length !== ENCODED_LENGTH) { + throw new Error( + `Invalid short UUID length: ${cleaned.length} (expected ${ENCODED_LENGTH})` + ); + } + const value = stringToInt(cleaned, ALPHABET_LIST); + return intToUuid(value); +} + +/** + * Format short UUID for display with hyphens every 4 chars from right. + * + * @param shortUuid - Raw short UUID string (22 chars) or hyphenated format. + * @param options - Optional configuration. + * @param options.truncate - Truncation length (e.g., 8 or 12). If provided, + * takes first N characters before adding hyphens. + * @returns Formatted string with hyphens inserted every 4 characters from right. + * + * @example + * formatDisplay('vytxeTZskVKR7C7WgdSP3d') + * // Returns: 'vy-txeT-ZskV-KR7C-7Wgd-SP3d' + * + * formatDisplay('vytxeTZskVKR7C7WgdSP3d', { truncate: 8 }) + * // Returns: 'vytx-eTZs' + * + * formatDisplay('vytxeTZskVKR7C7WgdSP3d', { truncate: 12 }) + * // Returns: 'vytx-eTZs-kVKR' + */ +export function formatDisplay( + shortUuid: string, + options?: { truncate?: number } +): string { + // Strip any existing hyphens first + let cleaned = stripHyphens(shortUuid); + + // Apply truncation if requested + if (options?.truncate !== undefined) { + cleaned = cleaned.slice(0, options.truncate); + } + + // Insert hyphens every 4 characters from the right + if (cleaned.length <= 4) { + return cleaned; + } + + const result: string[] = []; + const remainder = cleaned.length % 4; + + if (remainder > 0) { + result.push(cleaned.slice(0, remainder)); + } + + for (let i = remainder; i < cleaned.length; i += 4) { + result.push(cleaned.slice(i, i + 4)); + } + + return result.join('-'); +} + +/** + * Strip all hyphens from a formatted short UUID. + * + * @param formatted - Short UUID string, possibly with hyphens. + * @returns String with all hyphens removed. + */ +export function stripHyphens(formatted: string): string { + return formatted.replaceAll('-', ''); +} + +/** + * Check if a string is a valid short UUID (hyphens allowed). + * + * Validates that after stripping hyphens: + * - Length is exactly 22 characters + * - All characters are in the base57 alphabet + * + * @param value - String to validate. + * @returns True if valid short UUID format, false otherwise. + */ +export function isValid(value: string): boolean { + const cleaned = stripHyphens(value); + if (cleaned.length !== ENCODED_LENGTH) { + return false; + } + return [...cleaned].every((char) => ALPHABET_SET.has(char)); +} + +/** + * Return the alphabet used for encoding. + * + * @returns 57-character string of allowed characters. + */ +export function getAlphabet(): string { + return DEFAULT_ALPHABET; +} diff --git a/cli/src/utils/validation.ts b/cli/src/utils/validation.ts index 9702c4c0..c0bff848 100644 --- a/cli/src/utils/validation.ts +++ b/cli/src/utils/validation.ts @@ -2,12 +2,18 @@ * Validation utilities for input parameters. */ +import { + formatDisplay, + isValid as isValidShortUuidFormat, + stripHyphens, +} from './shortuuid.js'; + /** * Validate that a string is a valid UUID format (v1-v5). * - * Session IDs in DocImp are UUIDs generated by the CLI layer. - * This validation ensures commands receive properly formatted IDs - * and provides clear error messages for malformed input. + * This validates the standard 36-character UUID format with hyphens. + * For session IDs, prefer isValidSessionId() which accepts both UUID + * and shortuuid formats. * * @param sessionId - The session ID string to validate * @returns True if valid UUID format, false otherwise @@ -18,3 +24,95 @@ export function isValidUuid(sessionId: string): boolean { /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(sessionId); } + +/** + * Validate that a string is a valid shortuuid format. + * + * Shortuuids are 22-character base57-encoded UUIDs. Hyphens are allowed + * and stripped before validation. + * + * @param value - The string to validate + * @returns True if valid shortuuid format, false otherwise + */ +export function isValidShortUuid(value: string): boolean { + return isValidShortUuidFormat(value); +} + +/** + * Validate that a string is a valid session ID (UUID or shortuuid). + * + * Accepts two formats indefinitely for backward compatibility: + * - Standard UUID: 36 chars with hyphens (e.g., 550e8400-e29b-41d4-a716-446655440000) + * - ShortUUID: 22 chars base57 encoded (e.g., vytxeTZskVKR7C7WgdSP3d) + * + * @param sessionId - The session ID string to validate + * @returns True if valid UUID or shortuuid format, false otherwise + */ +export function isValidSessionId(sessionId: string): boolean { + return isValidUuid(sessionId) || isValidShortUuid(sessionId); +} + +/** + * Detect the format of a session ID. + * + * @param sessionId - The session ID to analyze + * @returns 'uuid' for standard UUIDs, 'shortuuid' for short UUIDs, 'invalid' otherwise + */ +export function detectSessionIdFormat( + sessionId: string +): 'uuid' | 'shortuuid' | 'invalid' { + if (isValidUuid(sessionId)) return 'uuid'; + if (isValidShortUuid(sessionId)) return 'shortuuid'; + return 'invalid'; +} + +/** + * Normalize a session ID by stripping hyphens if it's a shortuuid. + * + * UUIDs are returned unchanged. Shortuuids have hyphens stripped. + * + * @param sessionId - The session ID to normalize + * @returns Normalized session ID + */ +export function normalizeSessionId(sessionId: string): string { + if (isValidUuid(sessionId)) { + return sessionId; + } + // For shortuuids, strip hyphens + return stripHyphens(sessionId); +} + +/** + * Format a session ID for display with optional truncation. + * + * For shortuuids: truncates and adds hyphens every 4 chars from right + * For UUIDs: just truncates (they already have natural hyphen breaks) + * + * @param sessionId - The session ID to format + * @param truncate - Number of characters to show (8 or 12 typical) + * @returns Formatted session ID for display + * + * @example + * // Shortuuid: formatted with hyphens + * formatSessionIdForDisplay('vytxeTZskVKR7C7WgdSP3d', 8) + * // Returns: 'vytx-eTZs' + * + * formatSessionIdForDisplay('vytxeTZskVKR7C7WgdSP3d', 12) + * // Returns: 'vytx-eTZs-kVKR' + * + * // UUID: simple truncation (already has hyphens) + * formatSessionIdForDisplay('550e8400-e29b-41d4-a716-446655440000', 8) + * // Returns: '550e8400' + */ +export function formatSessionIdForDisplay( + sessionId: string, + truncate: number +): string { + // For legacy UUIDs, just truncate (they have natural hyphen structure) + if (isValidUuid(sessionId)) { + return sessionId.slice(0, truncate); + } + + // For shortuuids, use formatDisplay with truncation and hyphenation + return formatDisplay(sessionId, { truncate }); +} diff --git a/test-fixtures/shortuuid-vectors.json b/test-fixtures/shortuuid-vectors.json new file mode 100644 index 00000000..c2e5cea4 --- /dev/null +++ b/test-fixtures/shortuuid-vectors.json @@ -0,0 +1,92 @@ +{ + "description": "Cross-language test vectors for shortuuid encode/decode verification", + "alphabet": "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", + "encode_decode_vectors": [ + { + "comment": "Known test UUID", + "uuid": "3b1f8b40-222c-4a6e-b77e-779d5a94e21c", + "shortuuid": "CXc85b4rqinB7s5J52TRYb" + }, + { + "comment": "Zero UUID - tests padding", + "uuid": "00000000-0000-0000-0000-000000000000", + "shortuuid": "2222222222222222222222" + }, + { + "comment": "Max UUID - tests full range", + "uuid": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "shortuuid": "oZEq7ovRbLq6UnGMPwc8B5" + }, + { + "comment": "UUID4 example", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "shortuuid": "H9cNmGXLEc8NWcZzSThA9S" + }, + { + "comment": "UUID1 namespace DNS", + "uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "shortuuid": "MAnkyno2VCnFzuVMWtxBda" + } + ], + "format_display_vectors": [ + { + "input": "CXc85b4rqinB7s5J52TRYb", + "full": "CX-c85b-4rqi-nB7s-5J52-TRYb", + "truncate_8": "CXc8-5b4r", + "truncate_12": "CXc8-5b4r-qinB" + }, + { + "input": "H9cNmGXLEc8NWcZzSThA9S", + "full": "H9-cNmG-XLEc-8NWc-ZzST-hA9S", + "truncate_8": "H9cN-mGXL", + "truncate_12": "H9cN-mGXL-Ec8N" + } + ], + "validation_vectors": [ + { + "input": "CXc85b4rqinB7s5J52TRYb", + "valid": true, + "comment": "Valid 22-char shortuuid" + }, + { + "input": "CXc8-5b4r-qinB-7s5J-52TR-Yb", + "valid": true, + "comment": "Valid with hyphens (stripped before validation)" + }, + { + "input": "CXc85b4rqinB7s5J52TRY", + "valid": false, + "comment": "Too short (21 chars)" + }, + { + "input": "CXc85b4rqinB7s5J52TRYbb", + "valid": false, + "comment": "Too long (23 chars)" + }, + { + "input": "CXc85b4rqinB7s5J52TR0b", + "valid": false, + "comment": "Contains invalid char (0)" + }, + { + "input": "CXc85b4rqinB7s5J52TRlb", + "valid": false, + "comment": "Contains invalid char (l)" + }, + { + "input": "CXc85b4rqinB7s5J52TRIb", + "valid": false, + "comment": "Contains invalid char (I)" + }, + { + "input": "CXc85b4rqinB7s5J52TROb", + "valid": false, + "comment": "Contains invalid char (O)" + }, + { + "input": "CXc85b4rqinB7s5J52TR1b", + "valid": false, + "comment": "Contains invalid char (1)" + } + ] +}