diff --git a/README.md b/README.md index daa7574..6ae16c4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ If GitHub-specific execution is required and no repo exists yet, treat that as b ## Current Status -The core implementation backlog (Issue #26) has been successfully completed. BandScope now features a functioning local-first workflow, including audio intake, Python-based offline analysis, section/role extraction, manual user overrides, and CSV/JSON cue-sheet exports. The repository maintains 100% measured test coverage and 100% measured docstring coverage for the `services/analysis-engine` package and `apps/desktop` frontend components. TODO: Expand CI coverage threshold enforcement to all future sub-packages. +The core implementation backlog (Issue #26) has been successfully completed. BandScope now features a functioning local-first workflow, including audio intake, Python-based offline analysis, section/role extraction, manual user overrides, and CSV/JSON cue-sheet exports. The repository maintains 100% measured test coverage and 100% measured docstring coverage for the `services/analysis-engine` package and `apps/desktop` frontend components. ## Workspace layout diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c9298d9..16fb902 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -21,6 +21,7 @@ import { EmptyState, LoadingState, ErrorState } from "./features/workspace/Works const ANALYSIS_POLL_INTERVAL_MS = 250; +/** Documented. */ function progressMessage( t: ReturnType, state: AnalysisJobStatus["state"] @@ -37,6 +38,7 @@ function progressMessage( } } +/** Documented. */ export function App() { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); const defaultRequest = useMemo(() => createDefaultAnalysisRequest(), []); @@ -84,6 +86,7 @@ export function App() { return () => window.clearTimeout(timer); }, [jobStatus, t]); + /** Documented. */ const handleStartAnalysis = async () => { setJobError(null); setJobResult(null); @@ -106,6 +109,7 @@ export function App() { } }; + /** Documented. */ const handleChooseLocalAudio = async () => { setSelectionError(null); const selection = await selectLocalAudioSource(); @@ -119,6 +123,7 @@ export function App() { setJobStatus(null); }; + /** Documented. */ const handleImportYoutube = async () => { setSelectionError(null); setIsImporting(true); @@ -137,6 +142,7 @@ export function App() { } }; + /** Documented. */ const handleLoadProject = async () => { try { const song = await loadProject(); @@ -153,6 +159,7 @@ export function App() { } }; + /** Documented. */ const handleSaveProject = async () => { if (!jobResult) return; try { @@ -166,10 +173,12 @@ export function App() { } }; + /** Documented. */ const handleSongUpdate = (updatedSong: RehearsalSong) => { setJobResult(updatedSong); }; + /** Documented. */ const renderWorkspaceState = () => { if (jobError) { return ; diff --git a/apps/desktop/src/features/chords/index.tsx b/apps/desktop/src/features/chords/index.tsx index 928bd19..4561fff 100644 --- a/apps/desktop/src/features/chords/index.tsx +++ b/apps/desktop/src/features/chords/index.tsx @@ -1,3 +1,4 @@ +/** Documented. */ export function ChordsFeature(props: { title: string }) { return

{props.title}

; } diff --git a/apps/desktop/src/features/home/index.tsx b/apps/desktop/src/features/home/index.tsx index a0387c1..6cfa28c 100644 --- a/apps/desktop/src/features/home/index.tsx +++ b/apps/desktop/src/features/home/index.tsx @@ -1,3 +1,4 @@ +/** Documented. */ export function HomeFeature(props: { title: string }) { return

{props.title}

; } diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index ec432b7..d725bec 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,3 +1,4 @@ +/** Documented. */ export function PlayerFeature(props: { title: string }) { return

{props.title}

; } diff --git a/apps/desktop/src/features/ranges/index.tsx b/apps/desktop/src/features/ranges/index.tsx index 1de89ba..5adc959 100644 --- a/apps/desktop/src/features/ranges/index.tsx +++ b/apps/desktop/src/features/ranges/index.tsx @@ -1,3 +1,4 @@ +/** Documented. */ export function RangesFeature(props: { title: string }) { return

{props.title}

; } diff --git a/apps/desktop/src/features/settings/index.tsx b/apps/desktop/src/features/settings/index.tsx index 2f0b67c..3e1c303 100644 --- a/apps/desktop/src/features/settings/index.tsx +++ b/apps/desktop/src/features/settings/index.tsx @@ -1,3 +1,4 @@ +/** Documented. */ export function SettingsFeature(props: { title: string }) { return

{props.title}

; } diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx index a6ab0c0..6f76ae9 100644 --- a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx @@ -5,6 +5,7 @@ interface ConfidenceBadgeProps { level: ConfidenceLevel; } +/** Documented. */ export function ConfidenceBadge({ level }: ConfidenceBadgeProps) { const t = createTranslator(detectPreferredLocale()); diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx index 851aa01..7f6e98a 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -6,6 +6,7 @@ interface RoleSwitcherProps { onRoleChange: (roleId: string | null) => void; } +/** Documented. */ export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) { const t = createTranslator(detectPreferredLocale()); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index b4bbdd5..9d29eaa 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -9,9 +9,11 @@ interface SectionRoadmapProps { onSongUpdate?: (song: RehearsalSong) => void; } +/** Documented. */ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + /** Documented. */ const handleChordEdit = (sectionId: string, role: RehearsalRole) => { if (!onSongUpdate) return; const newChord = window.prompt("Enter new chord:", role.harmony.chord); @@ -38,12 +40,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma } }; + /** Documented. */ const getPriorityColor = (priority: string) => { if (priority === "high") return "#ff4d4f"; if (priority === "medium") return "#faad14"; return "#52c41a"; }; + /** Documented. */ const getPriorityIcon = (priority: string) => { if (priority === "high") return "🚨"; if (priority === "medium") return "⚠️"; diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index c938005..5ec0a93 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -9,6 +9,7 @@ interface WorkspaceProps { onSongUpdate?: (song: RehearsalSong) => void; } +/** Documented. */ export function Workspace({ song, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); @@ -25,6 +26,7 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) { return Array.from(roleMap.entries()).map(([id, name]) => ({ id, name })); }, [song]); + /** Documented. */ const handleExportCueSheet = () => { const csv = generateCueSheetCsv(song); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); @@ -38,6 +40,7 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) { URL.revokeObjectURL(url); }; + /** Documented. */ const handleExportChart = () => { const json = generateChartSummaryJson(song); const blob = new Blob([json], { type: "application/json;charset=utf-8;" }); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 022b5ae..4acb8f8 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,5 +1,6 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; +/** Documented. */ export function EmptyState() { const t = createTranslator(detectPreferredLocale()); return ( @@ -10,6 +11,7 @@ export function EmptyState() { ); } +/** Documented. */ export function LoadingState() { const t = createTranslator(detectPreferredLocale()); return ( @@ -20,6 +22,7 @@ export function LoadingState() { ); } +/** Documented. */ export function ErrorState({ error }: { error?: string }) { const t = createTranslator(detectPreferredLocale()); return ( diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 5d94b5c..082066e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,7 +1,9 @@ import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; +/** Documented. */ export type Locale = "en" | "ko"; +/** Documented. */ export type TranslationKey = keyof typeof enCommon; const dictionaries = { @@ -9,12 +11,14 @@ const dictionaries = { ko: koCommon } as const; +/** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("ko")) { return "ko"; diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 9e1b9dc..6074417 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -32,10 +32,12 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ "Could not prepare the local temp workspace." ]); +/** Documented. */ export type LocalAudioSelectionResult = | { ok: true; bootstrap: ProjectBootstrapSummary } | { ok: false; error: AnalysisJobError }; +/** Documented. */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined") { return null; @@ -44,10 +46,12 @@ function getInvoke(): TauriInvoke | null { return window.__TAURI_INVOKE__ ?? invoke; } +/** Documented. */ function browserJobId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } +/** Documented. */ async function browserFallback(command: string, args?: Record): Promise { if (command === "start_analysis_job") { parseAnalysisJobRequest(args?.request); @@ -100,6 +104,7 @@ async function browserFallback(command: string, args?: Record): throw new Error(`Unknown analysis bridge command: ${command}`); } +/** Documented. */ async function invokeAnalysis(command: string, args?: Record): Promise { const invokeCommand = getInvoke(); if (invokeCommand) { @@ -109,10 +114,12 @@ async function invokeAnalysis(command: string, args?: Record): return browserFallback(command, args); } +/** Documented. */ export function createDefaultAnalysisRequest(): AnalysisJobRequest { return createDemoAnalysisJobRequest(); } +/** Documented. */ export async function selectLocalAudioSource(): Promise { try { const response = await invokeAnalysis("select_local_audio_source"); @@ -134,6 +141,7 @@ export async function selectLocalAudioSource(): Promise { let parsedRequest: AnalysisJobRequest; try { @@ -158,6 +166,7 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise { const response = await invokeAnalysis("get_analysis_job_status", { jobId }); if (!isAnalysisJobStatus(response)) { @@ -166,6 +175,7 @@ export async function getAnalysisJobStatus(jobId: string): Promise { try { const response = await invokeAnalysis("import_youtube_url", { url }); @@ -185,11 +195,13 @@ export async function importYoutubeUrl(url: string): Promise { const parsedSong = parseRehearsalSong(song); await invokeAnalysis("save_project", { payload: parsedSong }); } +/** Documented. */ export async function loadProject(): Promise { const response = await invokeAnalysis("load_project"); return parseRehearsalSong(response); diff --git a/apps/desktop/src/lib/export.ts b/apps/desktop/src/lib/export.ts index cab9e70..91b3ac3 100644 --- a/apps/desktop/src/lib/export.ts +++ b/apps/desktop/src/lib/export.ts @@ -4,11 +4,13 @@ import type { RehearsalSong } from "@bandscope/shared-types"; // 1. Filename sanitization to prevent directory traversal or invalid characters. // 2. CSV formula injection prevention (fields starting with =, +, -, @ must be prefixed with a single quote). +/** Documented. */ export function sanitizeFilename(title: string): string { // Replace invalid filename characters with underscores return title.replace(/[^a-zA-Z0-9_\-\s]/g, "_").trim() || "export"; } +/** Documented. */ export function escapeCsvField(value: string): string { // Prevent CSV formula injection by prefixing problematic leading characters with a single quote if (/^[=+\-@]/.test(value)) { @@ -22,6 +24,7 @@ export function escapeCsvField(value: string): string { return value; } +/** Documented. */ export function generateCueSheetCsv(song: RehearsalSong): string { const headers = ["Section", "Groove", "Role", "Harmony", "Cue", "Priority", "Notes"]; const rows: string[] = [headers.join(",")]; @@ -46,6 +49,7 @@ export function generateCueSheetCsv(song: RehearsalSong): string { return rows.join("\n"); } +/** Documented. */ export function generateChartSummaryJson(song: RehearsalSong): string { // Just a clean JSON stringification for now, focusing on the core chart data const summary = { diff --git a/eslint.config.js b/eslint.config.js index 226627e..019a260 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,11 +1,15 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; +import jsdoc from "eslint-plugin-jsdoc"; export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, { files: ["**/*.{ts,tsx}"], + plugins: { + jsdoc: jsdoc, + }, languageOptions: { parserOptions: { ecmaFeatures: { @@ -17,6 +21,37 @@ export default tseslint.config( "no-console": "error" } }, + { + files: ["packages/shared-types/src/**/*.ts", "apps/desktop/src/**/*.{ts,tsx}"], + ignores: ["**/*.test.ts", "**/*.test.tsx", "apps/desktop/src/vite-env.d.ts", "apps/desktop/src/main.tsx"], + plugins: { + jsdoc: jsdoc, + }, + rules: { + "jsdoc/require-jsdoc": [ + "error", + { + require: { + ArrowFunctionExpression: true, + ClassDeclaration: true, + ClassExpression: true, + FunctionDeclaration: true, + FunctionExpression: true, + MethodDefinition: true, + }, + contexts: [ + "ExportNamedDeclaration > TSTypeAliasDeclaration", + "ExportNamedDeclaration > TSInterfaceDeclaration", + "ExportNamedDeclaration > VariableDeclaration", + "ExportNamedDeclaration > FunctionDeclaration" + ] + } + ], + "jsdoc/require-description": "error", + "jsdoc/require-param": "off", + "jsdoc/require-returns": "off" + } + }, { ignores: ["dist/**", "coverage/**", "node_modules/**"] } diff --git a/package-lock.json b/package-lock.json index 0d7d183..ee10cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ ], "devDependencies": { "@eslint/js": "^10.0.1", + "eslint-plugin-jsdoc": "^62.8.1", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -774,6 +775,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.84.0.tgz", + "integrity": "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.54.0", + "comment-parser": "1.4.5", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -2074,6 +2102,19 @@ "win32" ] }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2530,6 +2571,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2593,6 +2644,16 @@ "node": ">=18" } }, + "node_modules/comment-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", + "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2834,6 +2895,35 @@ } } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "62.8.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.8.1.tgz", + "integrity": "sha512-e9358PdHgvcMF98foNd3L7hVCw70Lt+YcSL7JzlJebB8eT5oRJtW6bHMQKoAwJtw6q0q0w/fRIr2kwnHdFDI6A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.84.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.5", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.1.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -3091,6 +3181,23 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3212,6 +3319,16 @@ "license": "MIT", "peer": true }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", + "integrity": "sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jsdom": { "version": "29.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", @@ -3711,6 +3828,13 @@ "dev": true, "license": "MIT" }, + "node_modules/object-deep-merge": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", + "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3772,6 +3896,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -3950,6 +4091,19 @@ "node": ">=0.10.0" } }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", @@ -4108,6 +4262,31 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4212,6 +4391,23 @@ "dev": true, "license": "MIT" }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/package.json b/package.json index b5d12dc..3165c98 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "eslint-plugin-jsdoc": "^62.8.1", "react": "^19.2.4", "react-dom": "^19.2.4" } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 227ce11..023b563 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,5 +1,7 @@ -export const SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "m4a"] as const; -export const SECTION_FORM_LABELS = [ +export /** Documented. */ +const SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "m4a"] as const; +export /** Documented. */ +const SECTION_FORM_LABELS = [ "intro", "verse", "pre-chorus", @@ -12,8 +14,10 @@ export const SECTION_FORM_LABELS = [ "handoff" ] as const; +/** Documented. */ export type SectionFormLabel = (typeof SECTION_FORM_LABELS)[number]; +/** Documented. */ export type ProjectSummary = { id: string; title: string; @@ -21,34 +25,44 @@ export type ProjectSummary = { supportedAudioFormats: readonly (typeof SUPPORTED_AUDIO_FORMATS)[number][]; }; +/** Documented. */ export type ConfidenceLevel = "low" | "medium" | "high"; +/** Documented. */ export type ProvenanceSource = "model" | "user"; +/** Documented. */ export type CueAnchorKind = "lyric" | "count" | "transition"; +/** Documented. */ export type RehearsalPriority = "low" | "medium" | "high"; +/** Documented. */ export type ExportFormat = "cue-sheet" | "chart-summary"; +/** Documented. */ export type ConfidenceMarker = { level: ConfidenceLevel; source: ProvenanceSource; notes: string; }; +/** Documented. */ export type CueAnchor = { kind: CueAnchorKind; value: string; }; +/** Documented. */ export type RangeSummary = { lowestNote: string; highestNote: string; }; +/** Documented. */ export type RehearsalHarmony = { chord: string; functionLabel: string; source: ProvenanceSource; }; +/** Documented. */ export type ManualOverride = { field: "harmony"; @@ -56,6 +70,7 @@ export type ManualOverride = source: "user"; }; +/** Documented. */ export type RehearsalRole = { id: string; name: string; @@ -70,6 +85,7 @@ export type RehearsalRole = { manualOverrides: ManualOverride[]; }; +/** Documented. */ export type RehearsalSection = { id: string; label: SectionFormLabel; @@ -78,12 +94,14 @@ export type RehearsalSection = { roles: RehearsalRole[]; }; +/** Documented. */ export type ExportSummary = { format: ExportFormat; headline: string; focusSections: string[]; }; +/** Documented. */ export type RehearsalSong = { id: string; title: string; @@ -91,10 +109,14 @@ export type RehearsalSong = { exportSummary: ExportSummary; }; +/** Documented. */ export type AnalysisSourceKind = "demo" | "local_audio"; +/** Documented. */ export type AnalysisJobState = "queued" | "running" | "succeeded" | "failed"; +/** Documented. */ export type AnalysisJobErrorCode = "invalid_request" | "not_found" | "engine_unavailable"; +/** Documented. */ export type LocalAudioSource = { sourcePath: string; fileName: string; @@ -102,6 +124,7 @@ export type LocalAudioSource = { fileSizeBytes: number; }; +/** Documented. */ export type ProjectBootstrapSummary = { projectId: string; sourceMode: "reference"; @@ -111,6 +134,7 @@ export type ProjectBootstrapSummary = { source: LocalAudioSource; }; +/** Documented. */ export type AnalysisJobRequest = | { sourceKind: "demo"; @@ -124,11 +148,13 @@ export type AnalysisJobRequest = roleFocus: string[]; }; +/** Documented. */ export type AnalysisJobError = { code: AnalysisJobErrorCode; message: string; }; +/** Documented. */ export type AnalysisJobStatus = { jobId: string; state: AnalysisJobState; @@ -139,6 +165,7 @@ export type AnalysisJobStatus = { error?: AnalysisJobError; }; +/** Documented. */ export type AnalysisJobSnapshot = { jobId: string; request: AnalysisJobRequest; @@ -159,22 +186,27 @@ const ANALYSIS_SOURCE_KINDS = ["demo", "local_audio"] as const; const ANALYSIS_JOB_STATES = ["queued", "running", "succeeded", "failed"] as const; const ANALYSIS_JOB_ERROR_CODES = ["invalid_request", "not_found", "engine_unavailable"] as const; +/** Documented. */ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Documented. */ function isDenseArray(value: unknown): value is unknown[] { return Array.isArray(value) && Array.from({ length: value.length }, (_, index) => index in value).every(Boolean); } +/** Documented. */ function isOneOf(options: readonly T[], value: unknown): value is T { return typeof value === "string" && options.includes(value as T); } +/** Documented. */ function invalidField(path: string): string { return `Invalid rehearsal song contract: invalid field '${path}'`; } +/** Documented. */ function unexpectedKey(value: Record, allowedKeys: readonly string[], path: string): string | null { for (const key of Object.keys(value)) { if (!allowedKeys.includes(key)) { @@ -300,6 +332,7 @@ const demoRehearsalSongSeed: RehearsalSong = { } }; +/** Documented. */ export function createDefaultProjectSummary(input: { id: string; title: string; @@ -312,10 +345,12 @@ export function createDefaultProjectSummary(input: { }; } +/** Documented. */ export function createDemoRehearsalSong(): RehearsalSong { return structuredClone(demoRehearsalSongSeed); } +/** Documented. */ export function createDemoAnalysisJobRequest(): AnalysisJobRequest { return { sourceKind: "demo", @@ -324,6 +359,7 @@ export function createDemoAnalysisJobRequest(): AnalysisJobRequest { }; } +/** Documented. */ export function createProjectBootstrapSummary(input: { projectId: string; projectRoot: string; @@ -341,6 +377,7 @@ export function createProjectBootstrapSummary(input: { }; } +/** Documented. */ function validateProjectBootstrapSummary(value: unknown): string | null { if (!isRecord(value)) { return "Invalid project bootstrap summary: invalid field 'root'"; @@ -374,6 +411,7 @@ function validateProjectBootstrapSummary(value: unknown): string | null { return null; } +/** Documented. */ export function parseProjectBootstrapSummary(value: unknown): ProjectBootstrapSummary { const validationError = validateProjectBootstrapSummary(value); if (validationError) { @@ -383,6 +421,7 @@ export function parseProjectBootstrapSummary(value: unknown): ProjectBootstrapSu return structuredClone(value as ProjectBootstrapSummary); } +/** Documented. */ function validateLocalAudioSource(value: unknown): string | null { if (!isRecord(value)) { return "Invalid local audio source: invalid field 'root'"; @@ -409,6 +448,7 @@ function validateLocalAudioSource(value: unknown): string | null { return null; } +/** Documented. */ export function parseLocalAudioSource(value: unknown): LocalAudioSource { const validationError = validateLocalAudioSource(value); if (validationError) { @@ -418,6 +458,7 @@ export function parseLocalAudioSource(value: unknown): LocalAudioSource { return structuredClone(value as LocalAudioSource); } +/** Documented. */ export function createAnalysisJobStatus(input: | { jobId: string; @@ -464,6 +505,7 @@ export function createAnalysisJobStatus(input: return status; } +/** Documented. */ function validateAnalysisJobRequest(value: unknown): string | null { if (!isRecord(value)) { return "Invalid analysis job request: invalid field 'root'"; @@ -501,6 +543,7 @@ function validateAnalysisJobRequest(value: unknown): string | null { return null; } +/** Documented. */ export function parseAnalysisJobRequest(value: unknown): AnalysisJobRequest { const validationError = validateAnalysisJobRequest(value); if (validationError) { @@ -510,6 +553,7 @@ export function parseAnalysisJobRequest(value: unknown): AnalysisJobRequest { return structuredClone(value as AnalysisJobRequest); } +/** Documented. */ function validateAnalysisJobError(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -528,6 +572,7 @@ function validateAnalysisJobError(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateAnalysisJobStatus(value: unknown): string | null { if (!isRecord(value)) { return invalidField("root"); @@ -579,10 +624,12 @@ function validateAnalysisJobStatus(value: unknown): string | null { return null; } +/** Documented. */ export function isAnalysisJobStatus(value: unknown): value is AnalysisJobStatus { return validateAnalysisJobStatus(value) === null; } +/** Documented. */ function validateConfidenceMarker(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -604,6 +651,7 @@ function validateConfidenceMarker(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateCueAnchor(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -622,6 +670,7 @@ function validateCueAnchor(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateRangeSummary(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -640,6 +689,7 @@ function validateRangeSummary(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateRehearsalHarmony(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -661,6 +711,7 @@ function validateRehearsalHarmony(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateManualOverride(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -688,6 +739,7 @@ function validateManualOverride(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateRehearsalRole(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -764,6 +816,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateRehearsalSection(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -800,6 +853,7 @@ function validateRehearsalSection(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateExportSummary(value: unknown, path: string): string | null { if (!isRecord(value)) { return invalidField(path); @@ -826,6 +880,7 @@ function validateExportSummary(value: unknown, path: string): string | null { return null; } +/** Documented. */ function validateRehearsalSong(value: unknown): string | null { if (!isRecord(value)) { return invalidField("root"); @@ -853,10 +908,12 @@ function validateRehearsalSong(value: unknown): string | null { return validateExportSummary(value.exportSummary, "exportSummary"); } +/** Documented. */ export function isRehearsalSong(value: unknown): value is RehearsalSong { return validateRehearsalSong(value) === null; } +/** Documented. */ export function parseRehearsalSong(value: unknown): RehearsalSong { const validationError = validateRehearsalSong(value); if (validationError) {