From 184688175af4de2c3a2b98c7601820f538ff6a87 Mon Sep 17 00:00:00 2001 From: sonikagutha Date: Thu, 30 Jul 2026 12:08:29 +0530 Subject: [PATCH 1/2] Add repo indexer --- packages/moss-repo-indexer-js/.gitignore | 5 + packages/moss-repo-indexer-js/README.md | 51 + .../moss-repo-indexer-js/example/dry-run.ts | 32 + .../moss-repo-indexer-js/package-lock.json | 2697 +++++++++++++++++ packages/moss-repo-indexer-js/package.json | 43 + .../moss-repo-indexer-js/src/chunkers/code.ts | 61 + .../src/chunkers/common.ts | 94 + .../src/chunkers/index.ts | 3 + .../src/chunkers/markdown.ts | 111 + packages/moss-repo-indexer-js/src/clone.ts | 92 + packages/moss-repo-indexer-js/src/discover.ts | 105 + packages/moss-repo-indexer-js/src/document.ts | 6 + .../moss-repo-indexer-js/src/documents.ts | 41 + packages/moss-repo-indexer-js/src/index.ts | 21 + packages/moss-repo-indexer-js/src/language.ts | 24 + packages/moss-repo-indexer-js/src/sync.ts | 53 + packages/moss-repo-indexer-js/src/types.ts | 100 + packages/moss-repo-indexer-js/src/uploader.ts | 45 + .../test/repo-indexer.test.ts | 103 + packages/moss-repo-indexer-js/tsconfig.json | 19 + .../moss-repo-indexer-js/vitest.config.ts | 13 + packages/moss-repo-indexer/.gitignore | 10 + packages/moss-repo-indexer/README.md | 104 + packages/moss-repo-indexer/example/dry_run.py | 39 + packages/moss-repo-indexer/pyproject.toml | 54 + .../src/moss_repo_indexer/__init__.py | 58 + .../moss_repo_indexer/chunkers/__init__.py | 7 + .../src/moss_repo_indexer/chunkers/code.py | 63 + .../src/moss_repo_indexer/chunkers/common.py | 89 + .../moss_repo_indexer/chunkers/markdown.py | 114 + .../src/moss_repo_indexer/clone.py | 96 + .../src/moss_repo_indexer/discover.py | 91 + .../src/moss_repo_indexer/documents.py | 46 + .../src/moss_repo_indexer/language.py | 28 + .../src/moss_repo_indexer/sync.py | 63 + .../src/moss_repo_indexer/types.py | 104 + .../src/moss_repo_indexer/uploader.py | 66 + packages/moss-repo-indexer/tests/conftest.py | 52 + .../moss-repo-indexer/tests/test_chunking.py | 56 + .../moss-repo-indexer/tests/test_discover.py | 45 + packages/moss-repo-indexer/tests/test_sync.py | 62 + 41 files changed, 4966 insertions(+) create mode 100644 packages/moss-repo-indexer-js/.gitignore create mode 100644 packages/moss-repo-indexer-js/README.md create mode 100644 packages/moss-repo-indexer-js/example/dry-run.ts create mode 100644 packages/moss-repo-indexer-js/package-lock.json create mode 100644 packages/moss-repo-indexer-js/package.json create mode 100644 packages/moss-repo-indexer-js/src/chunkers/code.ts create mode 100644 packages/moss-repo-indexer-js/src/chunkers/common.ts create mode 100644 packages/moss-repo-indexer-js/src/chunkers/index.ts create mode 100644 packages/moss-repo-indexer-js/src/chunkers/markdown.ts create mode 100644 packages/moss-repo-indexer-js/src/clone.ts create mode 100644 packages/moss-repo-indexer-js/src/discover.ts create mode 100644 packages/moss-repo-indexer-js/src/document.ts create mode 100644 packages/moss-repo-indexer-js/src/documents.ts create mode 100644 packages/moss-repo-indexer-js/src/index.ts create mode 100644 packages/moss-repo-indexer-js/src/language.ts create mode 100644 packages/moss-repo-indexer-js/src/sync.ts create mode 100644 packages/moss-repo-indexer-js/src/types.ts create mode 100644 packages/moss-repo-indexer-js/src/uploader.ts create mode 100644 packages/moss-repo-indexer-js/test/repo-indexer.test.ts create mode 100644 packages/moss-repo-indexer-js/tsconfig.json create mode 100644 packages/moss-repo-indexer-js/vitest.config.ts create mode 100644 packages/moss-repo-indexer/.gitignore create mode 100644 packages/moss-repo-indexer/README.md create mode 100644 packages/moss-repo-indexer/example/dry_run.py create mode 100644 packages/moss-repo-indexer/pyproject.toml create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/__init__.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/__init__.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/clone.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/discover.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/documents.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/language.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/sync.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/types.py create mode 100644 packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py create mode 100644 packages/moss-repo-indexer/tests/conftest.py create mode 100644 packages/moss-repo-indexer/tests/test_chunking.py create mode 100644 packages/moss-repo-indexer/tests/test_discover.py create mode 100644 packages/moss-repo-indexer/tests/test_sync.py diff --git a/packages/moss-repo-indexer-js/.gitignore b/packages/moss-repo-indexer-js/.gitignore new file mode 100644 index 00000000..4274b519 --- /dev/null +++ b/packages/moss-repo-indexer-js/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store diff --git a/packages/moss-repo-indexer-js/README.md b/packages/moss-repo-indexer-js/README.md new file mode 100644 index 00000000..b304e9f2 --- /dev/null +++ b/packages/moss-repo-indexer-js/README.md @@ -0,0 +1,51 @@ +# @moss-tools/repo-indexer + +Clone or walk a repository, chunk **code** and **Markdown**, and build a searchable Moss index. + +TypeScript twin of the Python package [`moss-repo-indexer`](../moss-repo-indexer). Both emit the same `DocumentInfo` metadata contract. + +## Install + +```bash +npm install @moss-tools/repo-indexer @moss-dev/moss +``` + +## Usage + +```ts +import { buildDocuments, sync } from '@moss-tools/repo-indexer' + +const docs = buildDocuments('./my-repo', { repoName: 'my-repo' }) + +const dry = await sync({ + source: './my-repo', // or https://github.com/org/repo.git + creds: { + projectId: process.env.MOSS_PROJECT_ID!, + projectKey: process.env.MOSS_PROJECT_KEY!, + indexName: 'my-codebase', + }, + dryRun: true, +}) + +const uploaded = await sync({ + source: './my-repo', + creds: { + projectId: process.env.MOSS_PROJECT_ID!, + projectKey: process.env.MOSS_PROJECT_KEY!, + indexName: 'my-codebase', + }, +}) +``` + +## Document metadata contract + +Same keys as the Python package (`path`, `language`, `type`, `start_line`, `end_line`, `symbol`, `repo`, `ref`, `title`, `navigation`). See `metadataContract()`. + +## Scripts + +```bash +pnpm install +pnpm test +pnpm build +pnpm example:dry-run +``` diff --git a/packages/moss-repo-indexer-js/example/dry-run.ts b/packages/moss-repo-indexer-js/example/dry-run.ts new file mode 100644 index 00000000..d6baba6f --- /dev/null +++ b/packages/moss-repo-indexer-js/example/dry-run.ts @@ -0,0 +1,32 @@ +/** + * Dry-run a local path into Moss document chunks (no upload). + * + * pnpm example:dry-run + * pnpm exec tsx example/dry-run.ts /path/to/repo + */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { sync } from '../src/index.js' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = process.argv[2] ?? path.resolve(here, '..') + +const result = await sync({ + source, + creds: { + projectId: 'unused-in-dry-run', + projectKey: 'unused-in-dry-run', + indexName: 'repo-dry-run', + }, + dryRun: true, +}) + +console.log(`repo=${result.repoName} chunks=${result.documents.length} dryRun=${result.dryRun}`) +for (const doc of result.documents.slice(0, 5)) { + const meta = doc.metadata ?? {} + console.log(`- ${doc.id} [${meta.type}] ${meta.navigation}`) +} +if (result.documents.length > 5) { + console.log(`... and ${result.documents.length - 5} more`) +} diff --git a/packages/moss-repo-indexer-js/package-lock.json b/packages/moss-repo-indexer-js/package-lock.json new file mode 100644 index 00000000..06d94093 --- /dev/null +++ b/packages/moss-repo-indexer-js/package-lock.json @@ -0,0 +1,2697 @@ +{ + "name": "@moss-tools/repo-indexer", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@moss-tools/repo-indexer", + "version": "0.0.1", + "license": "BSD-2-Clause", + "devDependencies": { + "@types/node": "22.13.14", + "tsup": "8.4.0", + "tsx": "4.19.3", + "typescript": "5.8.2", + "vitest": "3.2.6" + }, + "peerDependencies": { + "@moss-dev/moss": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@moss-dev/moss": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss/-/moss-1.4.0.tgz", + "integrity": "sha512-RWSNWh+Gfg3+lGXMt0tyAZoL+ATqdiPU/hXBuedB3dCh75h2WBnWwYgqWI82WjhfdVzourUXFLx5R9z3C9Lsaw==", + "license": "SEE LICENSE IN LICENSE", + "peer": true, + "dependencies": { + "@moss-dev/moss-core": "0.20.0" + }, + "engines": { + "node": ">=20.4" + } + }, + "node_modules/@moss-dev/moss-core": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core/-/moss-core-0.20.0.tgz", + "integrity": "sha512-EDyNbd7JEtVZxNo6ED0EzCcdi3xS8mVS1evyy7QnT26xzUV9HL0cqi9NF/p7s0kk/QfkdTF4AuB6JHHopxz8Ug==", + "license": "Proprietary", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@moss-dev/moss-core-darwin-arm64": "0.20.0", + "@moss-dev/moss-core-darwin-x64": "0.20.0", + "@moss-dev/moss-core-linux-arm64-gnu": "0.20.0", + "@moss-dev/moss-core-linux-x64-gnu": "0.20.0", + "@moss-dev/moss-core-win32-x64-msvc": "0.20.0" + } + }, + "node_modules/@moss-dev/moss-core-darwin-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-darwin-arm64/-/moss-core-darwin-arm64-0.20.0.tgz", + "integrity": "sha512-eqntmIWt4ITzqR25LcXzrk37T0XcAQW84IqGs8ES407zMGoaXjUGVlUe6EHGnt9T1yp6Ys0wHCeF+glBEE+Slg==", + "cpu": [ + "arm64" + ], + "license": "Proprietary", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@moss-dev/moss-core-darwin-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-darwin-x64/-/moss-core-darwin-x64-0.20.0.tgz", + "integrity": "sha512-qAgINHM283XWYaSZX5ZR7tGhbYzPbFoFp1WgAvyt9rYMgjGBMR4vurFyuYe4gtvtfHdR4iGHdWfb86IzGSH7Nw==", + "cpu": [ + "x64" + ], + "license": "Proprietary", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@moss-dev/moss-core-linux-arm64-gnu": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-linux-arm64-gnu/-/moss-core-linux-arm64-gnu-0.20.0.tgz", + "integrity": "sha512-sVo+8WeBNVdFNKMr7GLB7AEbRW+3nAZdve/nDoFqAzVJfrTFUVBo5EEM3TT503/k512BzzAaEQH7iPdcprEkIA==", + "cpu": [ + "arm64" + ], + "license": "Proprietary", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@moss-dev/moss-core-linux-x64-gnu": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-linux-x64-gnu/-/moss-core-linux-x64-gnu-0.20.0.tgz", + "integrity": "sha512-dSONfWqy6sIv947U4zxFDyQajbeh9oCchVYkQWbbsMGGsYkBLo/yl4cc3SqZJW3gX1nKx9d+uLuqsArvceELkg==", + "cpu": [ + "x64" + ], + "license": "Proprietary", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@moss-dev/moss-core-win32-x64-msvc": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-win32-x64-msvc/-/moss-core-win32-x64-msvc-0.20.0.tgz", + "integrity": "sha512-f6+6N8b0vKDW/1JyKQk7Df4sQbh4iNUjE72EOKugb9KFHlEsuLK9YW/tGp/UFgVqCz3qWnfxDvQWtUYU4U4+yA==", + "cpu": [ + "x64" + ], + "license": "Proprietary", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", + "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.4.0.tgz", + "integrity": "sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.25.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", + "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/packages/moss-repo-indexer-js/package.json b/packages/moss-repo-indexer-js/package.json new file mode 100644 index 00000000..068087b0 --- /dev/null +++ b/packages/moss-repo-indexer-js/package.json @@ -0,0 +1,43 @@ +{ + "name": "@moss-tools/repo-indexer", + "version": "0.0.1", + "description": "Clone or walk a repository, chunk code and Markdown, and index into Moss.", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "example:dry-run": "tsx example/dry-run.ts" + }, + "keywords": [ + "moss", + "repo", + "indexer", + "semantic-search", + "chunking", + "inferedge" + ], + "author": "InferEdge Inc.", + "license": "BSD-2-Clause", + "peerDependencies": { + "@moss-dev/moss": "^1.0.0" + }, + "devDependencies": { + "@types/node": "22.13.14", + "tsup": "8.4.0", + "tsx": "4.19.3", + "typescript": "5.8.2", + "vitest": "3.2.6" + } +} diff --git a/packages/moss-repo-indexer-js/src/chunkers/code.ts b/packages/moss-repo-indexer-js/src/chunkers/code.ts new file mode 100644 index 00000000..510a9326 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/chunkers/code.ts @@ -0,0 +1,61 @@ +import path from 'node:path' + +import type { RepoDocument } from '../document.js' + +import { CHUNK_TYPE_CODE } from '../types.js' +import { FileChunkRequest, makeDocument, splitLines } from './common.js' + +export const WINDOW_LINES = 60 +export const OVERLAP_LINES = 15 + +const SYMBOL_PATTERNS = [ + /^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)/, + /^\s*class\s+([A-Za-z_][\w]*)/, + /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_][\w]*)/, + /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][\w]*)\s*=/, + /^\s*func\s+([A-Za-z_][\w]*)/, + /^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/, +] + +export function chunkCode(request: FileChunkRequest): RepoDocument[] { + const lines = splitLines(request.content) + if (lines.length === 0) { + return [] + } + const docs: RepoDocument[] = [] + let start = 1 + const total = lines.length + while (start <= total) { + const end = Math.min(start + WINDOW_LINES - 1, total) + const body = lines.slice(start - 1, end).join('\n') + const symbol = detectSymbol(lines.slice(start - 1, end)) ?? '' + const title = symbol || path.basename(request.path) + docs.push( + makeDocument(request, { + startLine: start, + endLine: end, + body, + chunkType: CHUNK_TYPE_CODE, + symbol, + title, + }), + ) + if (end >= total) { + break + } + start = Math.max(end - OVERLAP_LINES + 1, start + 1) + } + return docs +} + +function detectSymbol(windowLines: string[]): string | undefined { + for (const line of windowLines) { + for (const pattern of SYMBOL_PATTERNS) { + const match = pattern.exec(line) + if (match) { + return match[1] + } + } + } + return undefined +} diff --git a/packages/moss-repo-indexer-js/src/chunkers/common.ts b/packages/moss-repo-indexer-js/src/chunkers/common.ts new file mode 100644 index 00000000..b1277b4a --- /dev/null +++ b/packages/moss-repo-indexer-js/src/chunkers/common.ts @@ -0,0 +1,94 @@ +import path from 'node:path' + +import type { RepoDocument } from '../document.js' + +import { + METADATA_END_LINE, + METADATA_LANGUAGE, + METADATA_NAVIGATION, + METADATA_PATH, + METADATA_REF, + METADATA_REPO, + METADATA_START_LINE, + METADATA_SYMBOL, + METADATA_TITLE, + METADATA_TYPE, +} from '../types.js' + +export interface FileChunkRequest { + path: string + root: string + content: string + language: string + repoName?: string + ref?: string +} + +export interface ChunkSlice { + startLine: number + endLine: number + body: string + chunkType: string + symbol: string + title: string +} + +export function relativePath(filePath: string, root: string): string { + return path.relative(root, filePath).split(path.sep).join('/') +} + +export function slugify(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return slug || 'section' +} + +export function makeDocument(request: FileChunkRequest, slice: ChunkSlice): RepoDocument { + const rel = relativePath(request.path, request.root) + const chunkId = chunkIdFor(rel, slice.symbol, slice.startLine, slice.endLine) + const prefix = slice.symbol ? `${rel} :: ${slice.symbol}` : rel + const text = `${prefix}\n\n${slice.body}`.trim() + return { + id: chunkId, + text, + metadata: metadataFor(request, slice, rel), + } +} + +function chunkIdFor(rel: string, symbol: string, startLine: number, endLine: number): string { + if (symbol) { + return `${rel}#${slugify(symbol)}:${startLine}-${endLine}` + } + return `${rel}:${startLine}-${endLine}` +} + +function metadataFor( + request: FileChunkRequest, + slice: ChunkSlice, + rel: string, +): Record { + const meta: Record = { + [METADATA_PATH]: rel, + [METADATA_LANGUAGE]: request.language, + [METADATA_TYPE]: slice.chunkType, + [METADATA_START_LINE]: String(slice.startLine), + [METADATA_END_LINE]: String(slice.endLine), + [METADATA_TITLE]: slice.title, + [METADATA_NAVIGATION]: `${rel}:${slice.startLine}`, + [METADATA_SYMBOL]: slice.symbol, + } + if (request.repoName) { + meta[METADATA_REPO] = request.repoName + } + if (request.ref) { + meta[METADATA_REF] = request.ref + } + return meta +} + +export function splitLines(content: string): string[] { + return content.split(/\r?\n/) +} diff --git a/packages/moss-repo-indexer-js/src/chunkers/index.ts b/packages/moss-repo-indexer-js/src/chunkers/index.ts new file mode 100644 index 00000000..f783684e --- /dev/null +++ b/packages/moss-repo-indexer-js/src/chunkers/index.ts @@ -0,0 +1,3 @@ +export { chunkCode } from './code.js' +export { chunkMarkdown } from './markdown.js' +export type { FileChunkRequest } from './common.js' diff --git a/packages/moss-repo-indexer-js/src/chunkers/markdown.ts b/packages/moss-repo-indexer-js/src/chunkers/markdown.ts new file mode 100644 index 00000000..845a9f68 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/chunkers/markdown.ts @@ -0,0 +1,111 @@ +import path from 'node:path' + +import type { RepoDocument } from '../document.js' + +import { CHUNK_TYPE_HEADER, CHUNK_TYPE_PAGE, CHUNK_TYPE_TEXT } from '../types.js' +import { FileChunkRequest, makeDocument, relativePath, splitLines } from './common.js' + +const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/ + +export function chunkMarkdown(request: FileChunkRequest): RepoDocument[] { + const lines = splitLines(request.content) + const docs: RepoDocument[] = [pageDocument(request)] + const sections = parseSections(lines, relativePath(request.path, request.root)) + for (const section of sections) { + docs.push(...documentsForSection(request, section)) + } + return docs +} + +function pageDocument(request: FileChunkRequest): RepoDocument { + const rel = relativePath(request.path, request.root) + const title = path.basename(request.path, path.extname(request.path)) + const endLine = Math.max(1, splitLines(request.content).length) + const generated = makeDocument(request, { + startLine: 1, + endLine, + body: title, + chunkType: CHUNK_TYPE_PAGE, + symbol: '', + title, + }) + return { id: rel, text: generated.text, metadata: generated.metadata } +} + +type Section = { heading: string; startLine: number; endLine: number; body: string } + +function parseSections(lines: string[], fallbackTitle: string): Section[] { + const buffered: Array<{ heading: string; start: number; body: string[] }> = [] + let heading = fallbackTitle + let start = 1 + let body: string[] = [] + + lines.forEach((line, index) => { + const lineNo = index + 1 + const match = HEADING_RE.exec(line) + if (match) { + flushSection(buffered, heading, start, body) + heading = match[2].trim() + start = lineNo + body = [line] + return + } + body.push(line) + }) + flushSection(buffered, heading, start, body) + + return buffered + .filter((item) => item.body.length > 0) + .map((item) => ({ + heading: item.heading, + startLine: item.start, + endLine: item.start + item.body.length - 1, + body: item.body.join('\n').trim(), + })) +} + +function flushSection( + sections: Array<{ heading: string; start: number; body: string[] }>, + heading: string, + start: number, + body: string[], +): void { + if (body.length > 0) { + sections.push({ heading, start, body: [...body] }) + } +} + +function documentsForSection(request: FileChunkRequest, section: Section): RepoDocument[] { + const docs: RepoDocument[] = [ + makeDocument(request, { + startLine: section.startLine, + endLine: section.startLine, + body: section.heading, + chunkType: CHUNK_TYPE_HEADER, + symbol: section.heading, + title: section.heading, + }), + ] + const bodyWithoutHeading = stripLeadingHeading(section.body) + if (bodyWithoutHeading) { + docs.push( + makeDocument(request, { + startLine: section.startLine, + endLine: section.endLine, + body: bodyWithoutHeading, + chunkType: CHUNK_TYPE_TEXT, + symbol: section.heading, + title: section.heading, + }), + ) + } + return docs +} + +function stripLeadingHeading(body: string): string { + const lines = body.split(/\r?\n/) + if (lines.length > 0 && HEADING_RE.test(lines[0])) { + return lines.slice(1).join('\n').trim() + } + return body.trim() +} diff --git a/packages/moss-repo-indexer-js/src/clone.ts b/packages/moss-repo-indexer-js/src/clone.ts new file mode 100644 index 00000000..da747f63 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/clone.ts @@ -0,0 +1,92 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const GIT_SSH_RE = /^git@[^:]+:.+\.git$/ +const GIT_URL_RE = /^(https?|git):\/\//i + +export interface ResolvedSource { + root: string + repoName: string + ref?: string + cleanup: boolean + close: () => void +} + +export function resolveSource(source: string, ref?: string): ResolvedSource { + const stripped = source.trim() + if (isGitUrl(stripped)) { + return cloneGitUrl(stripped, ref) + } + return resolveLocalPath(stripped, ref) +} + +export function isGitUrl(source: string): boolean { + if (GIT_SSH_RE.test(source) || GIT_URL_RE.test(source)) { + return true + } + return source.endsWith('.git') && source.includes('://') +} + +export function repoNameFromUrl(url: string): string { + let name = GIT_SSH_RE.test(url) ? url.split(':').pop() ?? url : new URL(url).pathname + name = path.basename(name) + if (name.endsWith('.git')) { + name = name.slice(0, -4) + } + return name || 'repo' +} + +function resolveLocalPath(source: string, ref?: string): ResolvedSource { + const root = path.resolve(source) + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + throw new Error(`Source path is not a directory: ${root}`) + } + return { + root, + repoName: path.basename(root), + ref, + cleanup: false, + close: () => undefined, + } +} + +function cloneGitUrl(url: string, ref?: string): ResolvedSource { + if (!hasGit()) { + throw new Error('git is required to clone a repository URL') + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-repo-')) + try { + runGitClone(url, tempDir, ref) + } catch (error) { + fs.rmSync(tempDir, { recursive: true, force: true }) + throw error + } + return { + root: tempDir, + repoName: repoNameFromUrl(url), + ref, + cleanup: true, + close: () => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }, + } +} + +function hasGit(): boolean { + return spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0 +} + +function runGitClone(url: string, dest: string, ref?: string): void { + const args = ['clone', '--depth', '1'] + if (ref) { + args.push('--branch', ref) + } + args.push(url, dest) + const result = spawnSync('git', args, { encoding: 'utf8' }) + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || 'git clone failed').trim() + throw new Error(`Failed to clone ${url}: ${detail}`) + } +} diff --git a/packages/moss-repo-indexer-js/src/discover.ts b/packages/moss-repo-indexer-js/src/discover.ts new file mode 100644 index 00000000..704de780 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/discover.ts @@ -0,0 +1,105 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +import { IndexOptions, resolveIndexOptions } from './types.js' + +export function discoverFiles(root: string, options: IndexOptions = {}): string[] { + const opts = resolveIndexOptions(options) + const resolvedRoot = path.resolve(root) + if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { + throw new Error(`Repository root is not a directory: ${resolvedRoot}`) + } + + const candidates = listCandidates(resolvedRoot, opts) + return candidates + .filter((filePath) => matchesInclude(filePath, resolvedRoot, opts.includeGlobs)) + .filter((filePath) => withinSizeLimit(filePath, opts.maxFileBytes)) + .sort() +} + +function listCandidates(root: string, options: ReturnType): string[] { + if (options.respectGitignore && isGitRepo(root)) { + const tracked = gitTrackedFiles(root) + if (tracked) { + return tracked + } + } + return walkFilesystem(root, new Set(options.excludeDirs)) +} + +function isGitRepo(root: string): boolean { + return fs.existsSync(path.join(root, '.git')) +} + +function gitTrackedFiles(root: string): string[] | null { + const version = spawnSync('git', ['--version'], { encoding: 'utf8' }) + if (version.status !== 0) { + return null + } + const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'buffer' }) + if (result.status !== 0 || !result.stdout) { + return null + } + const relPaths = result.stdout.toString('utf8').split('\0').filter(Boolean) + return relPaths + .map((rel) => path.join(root, rel)) + .filter((filePath) => fs.existsSync(filePath) && fs.statSync(filePath).isFile()) +} + +function walkFilesystem(root: string, excludeDirs: Set): string[] { + const files: string[] = [] + const stack = [root] + while (stack.length > 0) { + const current = stack.pop()! + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name) + if (entry.isDirectory()) { + if (!excludeDirs.has(entry.name)) { + stack.push(full) + } + continue + } + if (entry.isFile()) { + files.push(full) + } + } + } + return files +} + +function matchesInclude(filePath: string, root: string, includeGlobs: string[]): boolean { + const rel = toPosix(path.relative(root, filePath)) + const name = path.basename(filePath) + for (const pattern of includeGlobs) { + if (globMatch(rel, pattern) || globMatch(name, pattern)) { + return true + } + if (pattern.startsWith('**/') && globMatch(name, pattern.slice(3))) { + return true + } + } + return false +} + +function withinSizeLimit(filePath: string, maxFileBytes: number): boolean { + try { + return fs.statSync(filePath).size <= maxFileBytes + } catch { + return false + } +} + +function toPosix(value: string): string { + return value.split(path.sep).join('/') +} + +/** Minimal glob matcher for include patterns (star and double-star globs). */ +function globMatch(value: string, pattern: string): boolean { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '{{GS}}') + .replace(/\*/g, '[^/]*') + .replace(/{{GS}}/g, '.*') + return new RegExp(`^${escaped}$`).test(value) +} diff --git a/packages/moss-repo-indexer-js/src/document.ts b/packages/moss-repo-indexer-js/src/document.ts new file mode 100644 index 00000000..9dfbd7d5 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/document.ts @@ -0,0 +1,6 @@ +/** Local document shape matching Moss DocumentInfo (id/text/metadata). */ +export interface RepoDocument { + id: string + text: string + metadata?: Record +} diff --git a/packages/moss-repo-indexer-js/src/documents.ts b/packages/moss-repo-indexer-js/src/documents.ts new file mode 100644 index 00000000..0f5d5a68 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/documents.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs' + +import type { RepoDocument } from './document.js' +import { chunkCode, chunkMarkdown, type FileChunkRequest } from './chunkers/index.js' +import { discoverFiles } from './discover.js' +import { isMarkdownPath, languageForPath } from './language.js' +import { IndexOptions, resolveIndexOptions } from './types.js' + +export function buildDocuments(root: string, options: IndexOptions = {}): RepoDocument[] { + const opts = resolveIndexOptions(options) + const documents: RepoDocument[] = [] + for (const filePath of discoverFiles(root, opts)) { + documents.push(...chunkFile(filePath, root, opts)) + } + return documents +} + +function chunkFile( + filePath: string, + root: string, + options: ReturnType, +): RepoDocument[] { + let content: string + try { + content = fs.readFileSync(filePath, 'utf8') + } catch { + return [] + } + const request: FileChunkRequest = { + path: filePath, + root, + content, + language: languageForPath(filePath), + repoName: options.repoName, + ref: options.ref, + } + if (isMarkdownPath(filePath)) { + return chunkMarkdown(request) + } + return chunkCode(request) +} diff --git a/packages/moss-repo-indexer-js/src/index.ts b/packages/moss-repo-indexer-js/src/index.ts new file mode 100644 index 00000000..fbfa8a61 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/index.ts @@ -0,0 +1,21 @@ +export { resolveSource, isGitUrl, repoNameFromUrl, type ResolvedSource } from './clone.js' +export { discoverFiles } from './discover.js' +export { buildDocuments } from './documents.js' +export { chunkCode, chunkMarkdown, type FileChunkRequest } from './chunkers/index.js' +export { uploadDocuments, type UploadOptions, type MutationResultLike } from './uploader.js' +export { sync, type SyncResult } from './sync.js' +export type { RepoDocument } from './document.js' +export { + CHUNK_TYPE_CODE, + CHUNK_TYPE_HEADER, + CHUNK_TYPE_MARKDOWN, + CHUNK_TYPE_PAGE, + CHUNK_TYPE_TEXT, + DEFAULT_EXCLUDE_DIRS, + DEFAULT_INCLUDE_GLOBS, + DEFAULT_MODEL_NAME, + metadataContract, + type IndexOptions, + type MossCreds, + type SyncOptions, +} from './types.js' diff --git a/packages/moss-repo-indexer-js/src/language.ts b/packages/moss-repo-indexer-js/src/language.ts new file mode 100644 index 00000000..7cb96731 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/language.ts @@ -0,0 +1,24 @@ +import path from 'node:path' + +const EXTENSION_LANGUAGE: Record = { + '.py': 'python', + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.md': 'markdown', + '.mdx': 'markdown', +} + +const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx']) + +export function languageForPath(filePath: string): string { + return EXTENSION_LANGUAGE[path.extname(filePath).toLowerCase()] ?? 'text' +} + +export function isMarkdownPath(filePath: string): boolean { + return MARKDOWN_EXTENSIONS.has(path.extname(filePath).toLowerCase()) +} diff --git a/packages/moss-repo-indexer-js/src/sync.ts b/packages/moss-repo-indexer-js/src/sync.ts new file mode 100644 index 00000000..f44188b7 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/sync.ts @@ -0,0 +1,53 @@ +import type { RepoDocument } from './document.js' +import { resolveSource, type ResolvedSource } from './clone.js' +import { buildDocuments } from './documents.js' +import { SyncOptions, resolveIndexOptions, type IndexOptions } from './types.js' +import { uploadDocuments, type MutationResultLike } from './uploader.js' + +export interface SyncResult { + documents: RepoDocument[] + mutation?: MutationResultLike + dryRun: boolean + repoName: string + root: string +} + +export async function sync(options: SyncOptions): Promise { + const index = resolveIndexOptions(options.index ?? {}) + const resolved = resolveSource(options.source, index.ref) + try { + return await syncResolved(options, resolved, index) + } finally { + resolved.close() + } +} + +async function syncResolved( + options: SyncOptions, + resolved: ResolvedSource, + index: ReturnType, +): Promise { + const indexOpts: IndexOptions = { + ...index, + repoName: index.repoName ?? resolved.repoName, + } + const documents = buildDocuments(resolved.root, indexOpts) + if (options.dryRun) { + return { + documents, + dryRun: true, + repoName: resolved.repoName, + root: resolved.root, + } + } + const mutation = await uploadDocuments(documents, options.creds, { + upsert: options.upsert ?? false, + }) + return { + documents, + mutation, + dryRun: false, + repoName: resolved.repoName, + root: resolved.root, + } +} diff --git a/packages/moss-repo-indexer-js/src/types.ts b/packages/moss-repo-indexer-js/src/types.ts new file mode 100644 index 00000000..5bbc2a9d --- /dev/null +++ b/packages/moss-repo-indexer-js/src/types.ts @@ -0,0 +1,100 @@ +/** Shared credentials, options, and document metadata contract (mirrors Python). */ + +export const METADATA_PATH = 'path' +export const METADATA_LANGUAGE = 'language' +export const METADATA_TYPE = 'type' +export const METADATA_START_LINE = 'start_line' +export const METADATA_END_LINE = 'end_line' +export const METADATA_SYMBOL = 'symbol' +export const METADATA_REPO = 'repo' +export const METADATA_REF = 'ref' +export const METADATA_TITLE = 'title' +export const METADATA_NAVIGATION = 'navigation' + +export const CHUNK_TYPE_CODE = 'code' +export const CHUNK_TYPE_MARKDOWN = 'markdown' +export const CHUNK_TYPE_HEADER = 'header' +export const CHUNK_TYPE_TEXT = 'text' +export const CHUNK_TYPE_PAGE = 'page' + +export const DEFAULT_MODEL_NAME = 'moss-minilm' + +export const DEFAULT_INCLUDE_GLOBS = [ + '**/*.py', + '**/*.ts', + '**/*.tsx', + '**/*.js', + '**/*.jsx', + '**/*.go', + '**/*.rs', + '**/*.java', + '**/*.md', + '**/*.mdx', +] + +export const DEFAULT_EXCLUDE_DIRS = [ + '.git', + 'node_modules', + 'vendor', + 'dist', + 'build', + '__pycache__', + '.venv', + 'venv', + '.pytest_cache', + '.ruff_cache', + '.mypy_cache', +] + +export interface MossCreds { + projectId: string + projectKey: string + indexName: string + modelName?: string +} + +export interface IndexOptions { + includeGlobs?: string[] + excludeDirs?: string[] + respectGitignore?: boolean + ref?: string + maxFileBytes?: number + repoName?: string +} + +export interface SyncOptions { + source: string + creds: MossCreds + index?: IndexOptions + dryRun?: boolean + upsert?: boolean +} + +export function metadataContract(): Record { + return { + [METADATA_PATH]: 'Repository-relative file path', + [METADATA_LANGUAGE]: 'Language id (python, typescript, markdown, ...)', + [METADATA_TYPE]: 'Chunk kind: code | markdown | header | text | page', + [METADATA_START_LINE]: '1-based start line (string)', + [METADATA_END_LINE]: '1-based end line (string)', + [METADATA_SYMBOL]: 'Optional symbol or heading name', + [METADATA_REPO]: 'Optional repository name or URL', + [METADATA_REF]: 'Optional git ref / branch', + [METADATA_TITLE]: 'Display title (symbol, heading, or basename)', + [METADATA_NAVIGATION]: 'Editor/UI target, e.g. path:startLine', + } +} + +export function resolveIndexOptions(options: IndexOptions = {}): Required< + Pick +> & + IndexOptions { + return { + includeGlobs: options.includeGlobs ?? [...DEFAULT_INCLUDE_GLOBS], + excludeDirs: options.excludeDirs ?? [...DEFAULT_EXCLUDE_DIRS], + respectGitignore: options.respectGitignore ?? true, + maxFileBytes: options.maxFileBytes ?? 1_000_000, + ref: options.ref, + repoName: options.repoName, + } +} diff --git a/packages/moss-repo-indexer-js/src/uploader.ts b/packages/moss-repo-indexer-js/src/uploader.ts new file mode 100644 index 00000000..03d16334 --- /dev/null +++ b/packages/moss-repo-indexer-js/src/uploader.ts @@ -0,0 +1,45 @@ +import type { RepoDocument } from './document.js' +import { DEFAULT_MODEL_NAME, type MossCreds } from './types.js' + +export interface UploadOptions { + upsert?: boolean +} + +export interface MutationResultLike { + jobId?: string + docCount?: number +} + +export async function uploadDocuments( + documents: RepoDocument[], + creds: MossCreds, + options: UploadOptions = {}, +): Promise { + if (documents.length === 0) { + throw new Error('No documents to upload') + } + const { MossClient } = await import('@moss-dev/moss') + const client = new MossClient(creds.projectId, creds.projectKey) + if (options.upsert) { + return client.addDocs(creds.indexName, documents, { upsert: true }) + } + await deleteIndexIfPresent(client, creds.indexName) + return client.createIndex(creds.indexName, documents, { + modelId: creds.modelName ?? DEFAULT_MODEL_NAME, + }) +} + +async function deleteIndexIfPresent( + client: { deleteIndex: (name: string) => Promise }, + indexName: string, +): Promise { + try { + await client.deleteIndex(indexName) + } catch (error) { + const message = String(error).toLowerCase() + if (message.includes('not found') || message.includes('does not exist')) { + return + } + throw error + } +} diff --git a/packages/moss-repo-indexer-js/test/repo-indexer.test.ts b/packages/moss-repo-indexer-js/test/repo-indexer.test.ts new file mode 100644 index 00000000..6287bd18 --- /dev/null +++ b/packages/moss-repo-indexer-js/test/repo-indexer.test.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { isGitUrl, repoNameFromUrl, resolveSource } from '../src/clone.js' +import { discoverFiles } from '../src/discover.js' +import { buildDocuments } from '../src/documents.js' +import { chunkCode, chunkMarkdown } from '../src/chunkers/index.js' +import { sync } from '../src/sync.js' +import { metadataContract } from '../src/types.js' + +const temps: string[] = [] + +afterEach(() => { + for (const dir of temps.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function makeRepo(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-repo-test-')) + temps.push(root) + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'src', 'app.py'), 'class AuthMiddleware:\n pass\n' + ' # pad\n'.repeat(80)) + fs.writeFileSync(path.join(root, 'README.md'), '# Install\n\nRun npm.\n\n## Setup\n\nEnv.\n') + fs.mkdirSync(path.join(root, 'node_modules')) + fs.writeFileSync(path.join(root, 'node_modules', 'x.js'), 'export default 1\n') + fs.writeFileSync(path.join(root, 'notes.txt'), 'skip\n') + return root +} + +describe('clone helpers', () => { + it('detects git urls and names', () => { + expect(isGitUrl('https://github.com/org/repo.git')).toBe(true) + expect(isGitUrl('git@github.com:org/repo.git')).toBe(true) + expect(isGitUrl('/local/path')).toBe(false) + expect(repoNameFromUrl('https://github.com/org/cool-repo.git')).toBe('cool-repo') + }) + + it('resolves local path', () => { + const root = makeRepo() + const resolved = resolveSource(root) + expect(resolved.cleanup).toBe(false) + expect(resolved.repoName).toBe(path.basename(root)) + }) +}) + +describe('discover + chunk', () => { + it('filters files and builds documents', () => { + const root = makeRepo() + const files = discoverFiles(root) + const rels = files.map((f) => path.relative(root, f).split(path.sep).join('/')).sort() + expect(rels).toEqual(['README.md', 'src/app.py']) + + const docs = buildDocuments(root, { repoName: 'demo' }) + expect(docs.length).toBeGreaterThan(2) + expect(docs.every((d) => d.metadata?.repo === 'demo')).toBe(true) + expect(docs.some((d) => d.metadata?.type === 'page')).toBe(true) + expect(docs.some((d) => d.metadata?.symbol === 'AuthMiddleware')).toBe(true) + }) + + it('chunks markdown and code directly', () => { + const root = makeRepo() + const md = chunkMarkdown({ + path: path.join(root, 'README.md'), + root, + content: fs.readFileSync(path.join(root, 'README.md'), 'utf8'), + language: 'markdown', + }) + expect(md[0].id).toBe('README.md') + const code = chunkCode({ + path: path.join(root, 'src', 'app.py'), + root, + content: fs.readFileSync(path.join(root, 'src', 'app.py'), 'utf8'), + language: 'python', + }) + expect(code[0].metadata?.type).toBe('code') + }) +}) + +describe('sync dry-run', () => { + it('returns chunks without uploading', async () => { + const root = makeRepo() + const result = await sync({ + source: root, + creds: { projectId: 'p', projectKey: 'k', indexName: 'idx' }, + dryRun: true, + }) + expect(result.dryRun).toBe(true) + expect(result.mutation).toBeUndefined() + expect(result.documents.length).toBeGreaterThan(0) + }) +}) + +describe('metadata contract', () => { + it('exposes shared keys', () => { + expect(Object.keys(metadataContract())).toContain('path') + expect(Object.keys(metadataContract())).toContain('navigation') + }) +}) diff --git a/packages/moss-repo-indexer-js/tsconfig.json b/packages/moss-repo-indexer-js/tsconfig.json new file mode 100644 index 00000000..af6af9cb --- /dev/null +++ b/packages/moss-repo-indexer-js/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "example", "test"] +} diff --git a/packages/moss-repo-indexer-js/vitest.config.ts b/packages/moss-repo-indexer-js/vitest.config.ts new file mode 100644 index 00000000..4f9dc4ae --- /dev/null +++ b/packages/moss-repo-indexer-js/vitest.config.ts @@ -0,0 +1,13 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, + resolve: { + alias: { + '@moss-tools/repo-indexer': path.resolve('src/index.ts'), + }, + }, +}) diff --git a/packages/moss-repo-indexer/.gitignore b/packages/moss-repo-indexer/.gitignore new file mode 100644 index 00000000..b86b0644 --- /dev/null +++ b/packages/moss-repo-indexer/.gitignore @@ -0,0 +1,10 @@ +build/ +dist/ +*.egg-info/ +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.env diff --git a/packages/moss-repo-indexer/README.md b/packages/moss-repo-indexer/README.md new file mode 100644 index 00000000..7bcde17f --- /dev/null +++ b/packages/moss-repo-indexer/README.md @@ -0,0 +1,104 @@ +# moss-repo-indexer + +Clone or walk a repository, chunk **code** and **Markdown**, and build a searchable Moss index. + +Python library (no CLI). TypeScript twin: [`@moss-tools/repo-indexer`](../moss-repo-indexer-js) — same document metadata contract. + +## Install (editable) + +```bash +cd packages/moss-repo-indexer +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +``` + +## Usage + +```python +from pathlib import Path +from moss_repo_indexer import ( + MossCreds, + SyncOptions, + IndexOptions, + build_documents, + sync, +) + +creds = MossCreds( + project_id="...", + project_key="...", + index_name="my-codebase", +) + +# Build DocumentInfo rows without uploading +docs = build_documents(Path("./my-repo"), IndexOptions()) + +# Dry-run (no Moss upload) +result = await sync( + SyncOptions( + source="./my-repo", # or https://github.com/org/repo.git + creds=creds, + dry_run=True, + ) +) +print(len(result.documents), "chunks") + +# Fresh index (delete + create_index) +result = await sync(SyncOptions(source="./my-repo", creds=creds)) + +# Incremental upsert +result = await sync(SyncOptions(source="./my-repo", creds=creds, upsert=True)) +``` + +## Example + +```bash +PYTHONPATH=src python example/dry_run.py ./my-repo +``` + +## Document metadata contract + +Shared with `@moss-tools/repo-indexer`. Every chunk is a Moss `DocumentInfo` with: + +| Field | Meaning | +| --- | --- | +| `id` | Stable id, e.g. `path#symbol:start-end` | +| `text` | Search text (path/symbol prefix + chunk body) | +| `metadata.path` | Repo-relative path | +| `metadata.language` | e.g. `python`, `typescript`, `markdown` | +| `metadata.type` | `code` \| `markdown` \| `header` \| `text` \| `page` | +| `metadata.start_line` / `end_line` | 1-based line range (strings) | +| `metadata.symbol` | Optional symbol or heading | +| `metadata.navigation` | UI target, e.g. `src/foo.py:12` | +| `metadata.repo` / `ref` | Optional repo name and git ref | +| `metadata.title` | Display title | + +Inspect keys via `metadata_contract()`. + +## Environment + +```bash +MOSS_PROJECT_ID=... +MOSS_PROJECT_KEY=... +``` + +## Layout + +```text +src/moss_repo_indexer/ + types.py # creds, options, metadata contract + clone.py # path / git URL resolution + discover.py # file walk + filters + chunkers/ # markdown + code + documents.py # build_documents() + uploader.py # create_index / upsert + sync.py # end-to-end sync() +tests/ +example/dry_run.py +``` + +## Tests + +```bash +PYTHONPATH=src python -m pytest tests/ -q +``` diff --git a/packages/moss-repo-indexer/example/dry_run.py b/packages/moss-repo-indexer/example/dry_run.py new file mode 100644 index 00000000..4141762b --- /dev/null +++ b/packages/moss-repo-indexer/example/dry_run.py @@ -0,0 +1,39 @@ +"""Dry-run a local path into Moss document chunks (no upload). + +Usage: + cd packages/moss-repo-indexer + PYTHONPATH=src python example/dry_run.py [path] +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +# Allow running without install when SDK is missing: prefer installed moss. +try: + from moss_repo_indexer import MossCreds, SyncOptions, sync +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from moss_repo_indexer import MossCreds, SyncOptions, sync + + +async def main() -> None: + source = sys.argv[1] if len(sys.argv) > 1 else str(Path(__file__).resolve().parents[1]) + creds = MossCreds( + project_id="unused-in-dry-run", + project_key="unused-in-dry-run", + index_name="repo-dry-run", + ) + result = await sync(SyncOptions(source=source, creds=creds, dry_run=True)) + print(f"repo={result.repo_name} chunks={len(result.documents)} dry_run={result.dry_run}") + for doc in result.documents[:5]: + meta = doc.metadata or {} + print(f"- {doc.id} [{meta.get('type')}] {meta.get('navigation')}") + if len(result.documents) > 5: + print(f"... and {len(result.documents) - 5} more") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/moss-repo-indexer/pyproject.toml b/packages/moss-repo-indexer/pyproject.toml new file mode 100644 index 00000000..f9736493 --- /dev/null +++ b/packages/moss-repo-indexer/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "moss-repo-indexer" +version = "0.0.1" +description = "Clone or walk a repository, chunk code and Markdown, and index into Moss." +readme = "README.md" +requires-python = ">=3.10,<3.15" +license = { text = "BSD-2-Clause" } +authors = [{ name = "InferEdge Inc.", email = "contact@moss.dev" }] +keywords = ["moss", "repo", "indexer", "semantic-search", "chunking"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "moss>=1.1.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "python-dotenv>=1.0.0", + "ruff>=0.5.0", +] + +[project.urls] +Homepage = "https://github.com/usemoss/moss" +Repository = "https://github.com/usemoss/moss" +Source = "https://github.com/usemoss/moss/tree/main/packages/moss-repo-indexer" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/__init__.py b/packages/moss-repo-indexer/src/moss_repo_indexer/__init__.py new file mode 100644 index 00000000..aa44295f --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/__init__.py @@ -0,0 +1,58 @@ +"""Moss repo indexer — chunk a codebase and upload it to Moss. + +Public API (implementation filled in subsequent steps): + + from moss_repo_indexer import build_documents, sync, MossCreds, SyncOptions +""" + +from __future__ import annotations + +from moss import DocumentInfo + +from .chunkers import FileChunkRequest, chunk_code, chunk_markdown +from .clone import ResolvedSource, resolve_source +from .discover import discover_files +from .documents import build_documents +from .sync import SyncResult, sync +from .types import ( + CHUNK_TYPE_CODE, + CHUNK_TYPE_HEADER, + CHUNK_TYPE_MARKDOWN, + CHUNK_TYPE_PAGE, + CHUNK_TYPE_TEXT, + DEFAULT_EXCLUDE_DIRS, + DEFAULT_INCLUDE_GLOBS, + DEFAULT_MODEL_NAME, + IndexOptions, + MossCreds, + SyncOptions, + metadata_contract, +) +from .uploader import UploadOptions, upload_documents + +__all__ = [ + "CHUNK_TYPE_CODE", + "CHUNK_TYPE_HEADER", + "CHUNK_TYPE_MARKDOWN", + "CHUNK_TYPE_PAGE", + "CHUNK_TYPE_TEXT", + "DEFAULT_EXCLUDE_DIRS", + "DEFAULT_INCLUDE_GLOBS", + "DEFAULT_MODEL_NAME", + "DocumentInfo", + "FileChunkRequest", + "IndexOptions", + "MossCreds", + "ResolvedSource", + "SyncOptions", + "SyncResult", + "UploadOptions", + "build_documents", + "chunk_code", + "chunk_markdown", + "discover_files", + "metadata_contract", + "resolve_source", + "sync", + "upload_documents", +] diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/__init__.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/__init__.py new file mode 100644 index 00000000..4112d833 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/__init__.py @@ -0,0 +1,7 @@ +"""Language-specific chunkers.""" + +from .code import chunk_code +from .common import FileChunkRequest +from .markdown import chunk_markdown + +__all__ = ["FileChunkRequest", "chunk_code", "chunk_markdown"] diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py new file mode 100644 index 00000000..216583d1 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py @@ -0,0 +1,63 @@ +"""Sliding-window code chunker with path/symbol context prefixes.""" + +from __future__ import annotations + +import re +from typing import List, Optional, Pattern + +from moss import DocumentInfo + +from ..types import CHUNK_TYPE_CODE +from .common import ChunkSlice, FileChunkRequest, make_document, split_lines + +WINDOW_LINES = 60 +OVERLAP_LINES = 15 + +_SYMBOL_PATTERNS: List[Pattern[str]] = [ + re.compile(r"^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)"), + re.compile(r"^\s*class\s+([A-Za-z_][\w]*)"), + re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_][\w]*)"), + re.compile(r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][\w]*)\s*="), + re.compile(r"^\s*func\s+([A-Za-z_][\w]*)"), + re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)"), +] + + +def chunk_code(request: FileChunkRequest) -> List[DocumentInfo]: + lines = split_lines(request.content) + if not lines: + return [] + docs: List[DocumentInfo] = [] + start = 1 + total = len(lines) + while start <= total: + end = min(start + WINDOW_LINES - 1, total) + body = "\n".join(lines[start - 1 : end]) + symbol = _detect_symbol(lines[start - 1 : end]) + title = symbol or request.path.name + docs.append( + make_document( + request, + ChunkSlice( + start_line=start, + end_line=end, + body=body, + chunk_type=CHUNK_TYPE_CODE, + symbol=symbol or "", + title=title, + ), + ) + ) + if end >= total: + break + start = max(end - OVERLAP_LINES + 1, start + 1) + return docs + + +def _detect_symbol(window_lines: List[str]) -> Optional[str]: + for line in window_lines: + for pattern in _SYMBOL_PATTERNS: + match = pattern.match(line) + if match: + return match.group(1) + return None diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py new file mode 100644 index 00000000..943f86e8 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py @@ -0,0 +1,89 @@ +"""Shared helpers for building DocumentInfo chunks.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +from moss import DocumentInfo + +from ..types import ( + METADATA_END_LINE, + METADATA_LANGUAGE, + METADATA_NAVIGATION, + METADATA_PATH, + METADATA_REF, + METADATA_REPO, + METADATA_START_LINE, + METADATA_SYMBOL, + METADATA_TITLE, + METADATA_TYPE, +) + + +@dataclass +class FileChunkRequest: + path: Path + root: Path + content: str + language: str + repo_name: Optional[str] = None + ref: Optional[str] = None + + +@dataclass +class ChunkSlice: + start_line: int + end_line: int + body: str + chunk_type: str + symbol: str + title: str + + +def relative_path(path: Path, root: Path) -> str: + return path.relative_to(root).as_posix() + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return slug or "section" + + +def make_document(request: FileChunkRequest, slice_: ChunkSlice) -> DocumentInfo: + rel = relative_path(request.path, request.root) + symbol = slice_.symbol + chunk_id = _chunk_id(rel, symbol, slice_.start_line, slice_.end_line) + prefix = f"{rel} :: {symbol}" if symbol else rel + text = f"{prefix}\n\n{slice_.body}".strip() + return DocumentInfo(id=chunk_id, text=text, metadata=_metadata(request, slice_, rel)) + + +def _chunk_id(rel: str, symbol: str, start_line: int, end_line: int) -> str: + if symbol: + return f"{rel}#{slugify(symbol)}:{start_line}-{end_line}" + return f"{rel}:{start_line}-{end_line}" + + +def _metadata(request: FileChunkRequest, slice_: ChunkSlice, rel: str) -> Dict[str, str]: + meta = { + METADATA_PATH: rel, + METADATA_LANGUAGE: request.language, + METADATA_TYPE: slice_.chunk_type, + METADATA_START_LINE: str(slice_.start_line), + METADATA_END_LINE: str(slice_.end_line), + METADATA_TITLE: slice_.title, + METADATA_NAVIGATION: f"{rel}:{slice_.start_line}", + METADATA_SYMBOL: slice_.symbol, + } + if request.repo_name: + meta[METADATA_REPO] = request.repo_name + if request.ref: + meta[METADATA_REF] = request.ref + return meta + + +def split_lines(content: str) -> List[str]: + return content.splitlines() diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py new file mode 100644 index 00000000..6c29c1ed --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py @@ -0,0 +1,114 @@ +"""Heading-aware Markdown chunker.""" + +from __future__ import annotations + +import re +from typing import List, Tuple + +from moss import DocumentInfo + +from ..types import CHUNK_TYPE_HEADER, CHUNK_TYPE_PAGE, CHUNK_TYPE_TEXT +from .common import ChunkSlice, FileChunkRequest, make_document, relative_path, split_lines + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") + + +def chunk_markdown(request: FileChunkRequest) -> List[DocumentInfo]: + lines = split_lines(request.content) + docs = [_page_document(request)] + sections = _parse_sections(lines, relative_path(request.path, request.root)) + for section in sections: + docs.extend(_documents_for_section(request, section)) + return docs + + +def _page_document(request: FileChunkRequest) -> DocumentInfo: + rel = relative_path(request.path, request.root) + title = request.path.stem + end_line = max(1, len(split_lines(request.content))) + generated = make_document( + request, + ChunkSlice( + start_line=1, + end_line=end_line, + body=title, + chunk_type=CHUNK_TYPE_PAGE, + symbol="", + title=title, + ), + ) + return DocumentInfo(id=rel, text=generated.text, metadata=generated.metadata) + + +def _parse_sections(lines: List[str], fallback_title: str) -> List[Tuple[str, int, int, str]]: + """Return (heading, start_line, end_line, body) tuples.""" + sections: List[Tuple[str, int, List[str]]] = [] + heading = fallback_title + start = 1 + body: List[str] = [] + + for index, line in enumerate(lines, start=1): + match = _HEADING_RE.match(line) + if match: + _flush_section(sections, heading, start, body) + heading = match.group(2).strip() + start = index + body = [line] + continue + body.append(line) + + _flush_section(sections, heading, start, body) + return [(h, s, s + len(b) - 1, "\n".join(b).strip()) for h, s, b in sections if b] + + +def _flush_section( + sections: List[Tuple[str, int, List[str]]], + heading: str, + start: int, + body: List[str], +) -> None: + if body: + sections.append((heading, start, list(body))) + + +def _documents_for_section( + request: FileChunkRequest, + section: Tuple[str, int, int, str], +) -> List[DocumentInfo]: + heading, start_line, end_line, body = section + docs = [ + make_document( + request, + ChunkSlice( + start_line=start_line, + end_line=start_line, + body=heading, + chunk_type=CHUNK_TYPE_HEADER, + symbol=heading, + title=heading, + ), + ) + ] + body_without_heading = _strip_leading_heading(body) + if body_without_heading: + docs.append( + make_document( + request, + ChunkSlice( + start_line=start_line, + end_line=end_line, + body=body_without_heading, + chunk_type=CHUNK_TYPE_TEXT, + symbol=heading, + title=heading, + ), + ) + ) + return docs + + +def _strip_leading_heading(body: str) -> str: + lines = body.splitlines() + if lines and _HEADING_RE.match(lines[0]): + return "\n".join(lines[1:]).strip() + return body.strip() diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py b/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py new file mode 100644 index 00000000..4b89aa32 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py @@ -0,0 +1,96 @@ +"""Resolve a local path or shallow-clone a git URL into a working directory.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +_GIT_SSH_RE = re.compile(r"^git@[^:]+:.+\.git$") +_GIT_URL_RE = re.compile(r"^(https?|git)://", re.IGNORECASE) + + +@dataclass +class ResolvedSource: + root: Path + repo_name: str + ref: Optional[str] + cleanup: bool + + def close(self) -> None: + if self.cleanup and self.root.exists(): + shutil.rmtree(self.root, ignore_errors=True) + + +def resolve_source(source: str, ref: Optional[str] = None) -> ResolvedSource: + """Return a local directory for `source` (filesystem path or git URL).""" + stripped = source.strip() + if _is_git_url(stripped): + return _clone_git_url(stripped, ref) + return _resolve_local_path(stripped, ref) + + +def _is_git_url(source: str) -> bool: + if _GIT_SSH_RE.match(source) or _GIT_URL_RE.match(source): + return True + if source.endswith(".git") and "://" in source: + return True + return False + + +def _resolve_local_path(source: str, ref: Optional[str]) -> ResolvedSource: + root = Path(source).expanduser().resolve() + if not root.is_dir(): + raise FileNotFoundError(f"Source path is not a directory: {root}") + return ResolvedSource( + root=root, + repo_name=root.name, + ref=ref, + cleanup=False, + ) + + +def _clone_git_url(url: str, ref: Optional[str]) -> ResolvedSource: + if shutil.which("git") is None: + raise RuntimeError("git is required to clone a repository URL") + + temp_dir = Path(tempfile.mkdtemp(prefix="moss-repo-")) + try: + _run_git_clone(url, temp_dir, ref) + except Exception: + shutil.rmtree(temp_dir, ignore_errors=True) + raise + + return ResolvedSource( + root=temp_dir, + repo_name=_repo_name_from_url(url), + ref=ref, + cleanup=True, + ) + + +def _run_git_clone(url: str, dest: Path, ref: Optional[str]) -> None: + command = ["git", "clone", "--depth", "1"] + if ref: + command.extend(["--branch", ref]) + command.extend([url, str(dest)]) + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "git clone failed").strip() + raise RuntimeError(f"Failed to clone {url}: {detail}") + + +def _repo_name_from_url(url: str) -> str: + if _GIT_SSH_RE.match(url): + name = url.rsplit(":", 1)[-1] + else: + name = urlparse(url).path + name = Path(name).name + if name.endswith(".git"): + name = name[:-4] + return name or "repo" diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py b/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py new file mode 100644 index 00000000..5e92a251 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py @@ -0,0 +1,91 @@ +"""Walk a repository root and yield files to index.""" + +from __future__ import annotations + +import fnmatch +import subprocess +from pathlib import Path +from typing import Iterable, List, Optional, Set + +from .types import IndexOptions + + +def discover_files(root: Path, options: IndexOptions) -> List[Path]: + """Return absolute paths of files that should be chunked.""" + root = root.resolve() + if not root.is_dir(): + raise FileNotFoundError(f"Repository root is not a directory: {root}") + + candidates = _list_candidates(root, options) + matched = [path for path in candidates if _matches_include(path, root, options.include_globs)] + sized = [path for path in matched if _within_size_limit(path, options.max_file_bytes)] + return sorted(sized) + + +def _list_candidates(root: Path, options: IndexOptions) -> List[Path]: + if options.respect_gitignore and _is_git_repo(root): + tracked = _git_tracked_files(root) + if tracked is not None: + return tracked + return _walk_filesystem(root, set(options.exclude_dirs)) + + +def _is_git_repo(root: Path) -> bool: + return (root / ".git").exists() + + +def _git_tracked_files(root: Path) -> Optional[List[Path]]: + if subprocess.run(["git", "--version"], capture_output=True, check=False).returncode != 0: + return None + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + rel_paths = [p for p in result.stdout.decode("utf-8", errors="replace").split("\0") if p] + files: List[Path] = [] + for rel in rel_paths: + path = root / rel + if path.is_file(): + files.append(path) + return files + + +def _walk_filesystem(root: Path, exclude_dirs: Set[str]) -> List[Path]: + files: List[Path] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + if _is_under_excluded_dir(path, root, exclude_dirs): + continue + files.append(path) + return files + + +def _is_under_excluded_dir(path: Path, root: Path, exclude_dirs: Set[str]) -> bool: + try: + parts = path.relative_to(root).parts + except ValueError: + return True + return any(part in exclude_dirs for part in parts[:-1]) + + +def _matches_include(path: Path, root: Path, include_globs: Iterable[str]) -> bool: + rel = path.relative_to(root).as_posix() + name = path.name + for pattern in include_globs: + if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(name, pattern): + return True + # fnmatch treats "**/*.md" as requiring a slash; allow root-level matches. + if pattern.startswith("**/") and fnmatch.fnmatch(name, pattern[3:]): + return True + return False + + +def _within_size_limit(path: Path, max_file_bytes: int) -> bool: + try: + return path.stat().st_size <= max_file_bytes + except OSError: + return False diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py b/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py new file mode 100644 index 00000000..5384798d --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py @@ -0,0 +1,46 @@ +"""Build DocumentInfo lists from a resolved repository root.""" + +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + +from moss import DocumentInfo + +from .chunkers import FileChunkRequest, chunk_code, chunk_markdown +from .discover import discover_files +from .language import is_markdown_path, language_for_path +from .types import IndexOptions + + +def build_documents(root: Path, options: Optional[IndexOptions] = None) -> List[DocumentInfo]: + """Discover files under `root`, chunk them, and return DocumentInfo rows.""" + opts = options or IndexOptions() + documents: List[DocumentInfo] = [] + for path in discover_files(root, opts): + documents.extend(_chunk_file(path, root, opts)) + return documents + + +def _chunk_file(path: Path, root: Path, options: IndexOptions) -> List[DocumentInfo]: + content = _read_text(path) + if content is None: + return [] + request = FileChunkRequest( + path=path, + root=root, + content=content, + language=language_for_path(path), + repo_name=options.repo_name, + ref=options.ref, + ) + if is_markdown_path(path): + return chunk_markdown(request) + return chunk_code(request) + + +def _read_text(path: Path) -> Optional[str]: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/language.py b/packages/moss-repo-indexer/src/moss_repo_indexer/language.py new file mode 100644 index 00000000..899e0d9e --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/language.py @@ -0,0 +1,28 @@ +"""Map file extensions to language ids used in metadata.""" + +from __future__ import annotations + +from pathlib import Path + +_EXTENSION_LANGUAGE = { + ".py": "python", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".md": "markdown", + ".mdx": "markdown", +} + +_MARKDOWN_EXTENSIONS = {".md", ".mdx"} + + +def language_for_path(path: Path) -> str: + return _EXTENSION_LANGUAGE.get(path.suffix.lower(), "text") + + +def is_markdown_path(path: Path) -> bool: + return path.suffix.lower() in _MARKDOWN_EXTENSIONS diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py b/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py new file mode 100644 index 00000000..4b2c7406 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py @@ -0,0 +1,63 @@ +"""End-to-end: resolve source → build documents → upload (unless dry_run).""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import List, Optional + +from moss import DocumentInfo, MutationResult + +from .clone import ResolvedSource, resolve_source +from .documents import build_documents +from .types import IndexOptions, SyncOptions +from .uploader import UploadOptions, upload_documents + + +@dataclass +class SyncResult: + documents: List[DocumentInfo] + mutation: Optional[MutationResult] + dry_run: bool + repo_name: str + root: str + + +async def sync(options: SyncOptions) -> SyncResult: + """Index a local path or git URL into Moss.""" + resolved = resolve_source(options.source, options.index.ref) + try: + return await _sync_resolved(options, resolved) + finally: + resolved.close() + + +async def _sync_resolved(options: SyncOptions, resolved: ResolvedSource) -> SyncResult: + index_opts = _with_repo_name(options.index, resolved.repo_name) + documents = build_documents(resolved.root, index_opts) + if options.dry_run: + return SyncResult( + documents=documents, + mutation=None, + dry_run=True, + repo_name=resolved.repo_name, + root=str(resolved.root), + ) + + mutation = await upload_documents( + documents, + options.creds, + UploadOptions(upsert=options.upsert), + ) + return SyncResult( + documents=documents, + mutation=mutation, + dry_run=False, + repo_name=resolved.repo_name, + root=str(resolved.root), + ) + + +def _with_repo_name(options: IndexOptions, repo_name: str) -> IndexOptions: + if options.repo_name: + return options + return replace(options, repo_name=repo_name) diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/types.py b/packages/moss-repo-indexer/src/moss_repo_indexer/types.py new file mode 100644 index 00000000..8ecdfce6 --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/types.py @@ -0,0 +1,104 @@ +"""Shared credentials, options, and document metadata contract. + +Metadata keys are string-valued so both the Python and TypeScript packages +emit the same JSON shape for Moss DocumentInfo.metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional + + +# --- Document metadata contract (mirrored by the TS package) --- + +METADATA_PATH = "path" +METADATA_LANGUAGE = "language" +METADATA_TYPE = "type" +METADATA_START_LINE = "start_line" +METADATA_END_LINE = "end_line" +METADATA_SYMBOL = "symbol" +METADATA_REPO = "repo" +METADATA_REF = "ref" +METADATA_TITLE = "title" +METADATA_NAVIGATION = "navigation" + +CHUNK_TYPE_CODE = "code" +CHUNK_TYPE_MARKDOWN = "markdown" +CHUNK_TYPE_HEADER = "header" +CHUNK_TYPE_TEXT = "text" +CHUNK_TYPE_PAGE = "page" + +DEFAULT_MODEL_NAME = "moss-minilm" +DEFAULT_INCLUDE_GLOBS: List[str] = [ + "**/*.py", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.go", + "**/*.rs", + "**/*.java", + "**/*.md", + "**/*.mdx", +] +DEFAULT_EXCLUDE_DIRS: List[str] = [ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "__pycache__", + ".venv", + "venv", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", +] + + +@dataclass +class MossCreds: + project_id: str + project_key: str + index_name: str + model_name: str = DEFAULT_MODEL_NAME + + +@dataclass +class IndexOptions: + """Options for discovering and chunking a repository.""" + + include_globs: List[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE_GLOBS)) + exclude_dirs: List[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE_DIRS)) + respect_gitignore: bool = True + ref: Optional[str] = None + max_file_bytes: int = 1_000_000 + repo_name: Optional[str] = None + + +@dataclass +class SyncOptions: + """End-to-end index options. dry_run skips upload.""" + + source: str + creds: MossCreds + index: IndexOptions = field(default_factory=IndexOptions) + dry_run: bool = False + upsert: bool = False + + +def metadata_contract() -> Dict[str, str]: + """Return the canonical metadata key → description mapping.""" + return { + METADATA_PATH: "Repository-relative file path", + METADATA_LANGUAGE: "Language id (python, typescript, markdown, ...)", + METADATA_TYPE: "Chunk kind: code | markdown | header | text | page", + METADATA_START_LINE: "1-based start line (string)", + METADATA_END_LINE: "1-based end line (string)", + METADATA_SYMBOL: "Optional symbol or heading name", + METADATA_REPO: "Optional repository name or URL", + METADATA_REF: "Optional git ref / branch", + METADATA_TITLE: "Display title (symbol, heading, or basename)", + METADATA_NAVIGATION: "Editor/UI target, e.g. path:startLine", + } diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py b/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py new file mode 100644 index 00000000..85ad143c --- /dev/null +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py @@ -0,0 +1,66 @@ +"""Upload DocumentInfo rows to a Moss index.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from moss import DocumentInfo, MossClient, MutationOptions, MutationResult + +from .types import MossCreds + + +@dataclass +class UploadOptions: + upsert: bool = False + + +async def upload_documents( + documents: List[DocumentInfo], + creds: MossCreds, + options: Optional[UploadOptions] = None, +) -> MutationResult: + """Create an index or upsert documents into an existing index.""" + if not documents: + raise ValueError("No documents to upload") + + opts = options or UploadOptions() + client = MossClient(creds.project_id, creds.project_key) + if opts.upsert: + return await _upsert_documents(client, creds, documents) + return await _recreate_index(client, creds, documents) + + +async def _upsert_documents( + client: MossClient, + creds: MossCreds, + documents: List[DocumentInfo], +) -> MutationResult: + return await client.add_docs( + creds.index_name, + documents, + MutationOptions(upsert=True), + ) + + +async def _recreate_index( + client: MossClient, + creds: MossCreds, + documents: List[DocumentInfo], +) -> MutationResult: + await _delete_index_if_present(client, creds.index_name) + return await client.create_index( + creds.index_name, + documents, + creds.model_name, + ) + + +async def _delete_index_if_present(client: MossClient, index_name: str) -> None: + try: + await client.delete_index(index_name) + except Exception as exc: + message = str(exc).lower() + if "not found" in message or "does not exist" in message: + return + raise diff --git a/packages/moss-repo-indexer/tests/conftest.py b/packages/moss-repo-indexer/tests/conftest.py new file mode 100644 index 00000000..13d80bf5 --- /dev/null +++ b/packages/moss-repo-indexer/tests/conftest.py @@ -0,0 +1,52 @@ +"""Ensure moss is importable for unit tests (stub when SDK is not installed).""" + +from __future__ import annotations + +import sys +import types + + +def _install_moss_stub() -> None: + moss = types.ModuleType("moss") + + class DocumentInfo: + def __init__(self, id, text, metadata=None, embedding=None): + self.id = id + self.text = text + self.metadata = metadata + self.embedding = embedding + + class MutationResult: + def __init__(self, job_id="job", doc_count=0): + self.job_id = job_id + self.doc_count = doc_count + + class MutationOptions: + def __init__(self, upsert=False): + self.upsert = upsert + + class MossClient: + def __init__(self, project_id, project_key): + self.project_id = project_id + self.project_key = project_key + + async def delete_index(self, name): + raise Exception("not found") + + async def create_index(self, name, docs, model_id=None): + return MutationResult(doc_count=len(docs)) + + async def add_docs(self, name, docs, options=None): + return MutationResult(doc_count=len(docs)) + + moss.DocumentInfo = DocumentInfo + moss.MutationResult = MutationResult + moss.MutationOptions = MutationOptions + moss.MossClient = MossClient + sys.modules["moss"] = moss + + +try: + import moss # noqa: F401 +except ImportError: + _install_moss_stub() diff --git a/packages/moss-repo-indexer/tests/test_chunking.py b/packages/moss-repo-indexer/tests/test_chunking.py new file mode 100644 index 00000000..fab00f9d --- /dev/null +++ b/packages/moss-repo-indexer/tests/test_chunking.py @@ -0,0 +1,56 @@ +"""Unit tests for chunkers and build_documents.""" + +from __future__ import annotations + +from pathlib import Path + +from moss_repo_indexer import IndexOptions, build_documents +from moss_repo_indexer.chunkers import FileChunkRequest, chunk_code, chunk_markdown + + +def test_chunk_markdown_headings(tmp_path: Path): + path = tmp_path / "guide.md" + content = "# Install\n\nRun npm.\n\n## Setup\n\nSet env.\n" + docs = chunk_markdown( + FileChunkRequest( + path=path, + root=tmp_path, + content=content, + language="markdown", + ) + ) + types = {d.metadata["type"] for d in docs} + assert "page" in types + assert "header" in types + assert "text" in types + assert docs[0].id == "guide.md" + + +def test_chunk_code_symbol(tmp_path: Path): + path = tmp_path / "auth.py" + content = "class AuthMiddleware:\n def process(self):\n return True\n" + ( + " # pad\n" * 80 + ) + docs = chunk_code( + FileChunkRequest( + path=path, + root=tmp_path, + content=content, + language="python", + ) + ) + assert docs + assert docs[0].metadata["type"] == "code" + assert docs[0].metadata["symbol"] == "AuthMiddleware" + assert "auth.py :: AuthMiddleware" in docs[0].text + + +def test_build_documents(tmp_path: Path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.py").write_text("def main():\n return 0\n") + (tmp_path / "README.md").write_text("# Demo\n\nHello.\n") + + docs = build_documents(tmp_path, IndexOptions(repo_name="demo-repo")) + assert len(docs) >= 2 + assert all(d.metadata.get("repo") == "demo-repo" for d in docs) + assert all("navigation" in d.metadata for d in docs) diff --git a/packages/moss-repo-indexer/tests/test_discover.py b/packages/moss-repo-indexer/tests/test_discover.py new file mode 100644 index 00000000..7c9c922e --- /dev/null +++ b/packages/moss-repo-indexer/tests/test_discover.py @@ -0,0 +1,45 @@ +"""Unit tests for resolve_source and discover_files.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from moss_repo_indexer import IndexOptions, discover_files, resolve_source +from moss_repo_indexer.clone import _is_git_url, _repo_name_from_url + + +def test_resolve_local_path(tmp_path: Path): + resolved = resolve_source(str(tmp_path)) + assert resolved.root == tmp_path.resolve() + assert resolved.cleanup is False + assert resolved.repo_name == tmp_path.name + + +def test_resolve_missing_path(): + with pytest.raises(FileNotFoundError): + resolve_source("/tmp/moss-repo-indexer-missing-xyz") + + +def test_git_url_helpers(): + assert _is_git_url("https://github.com/org/repo.git") + assert _is_git_url("git@github.com:org/repo.git") + assert not _is_git_url("/local/path") + assert _repo_name_from_url("https://github.com/org/cool-repo.git") == "cool-repo" + assert _repo_name_from_url("git@github.com:org/cool-repo.git") == "cool-repo" + + +def test_discover_filters(tmp_path: Path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("print(1)\n") + (tmp_path / "README.md").write_text("# hi\n") + (tmp_path / "notes.txt").write_text("skip\n") + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "x.js").write_text("export default 1\n") + huge = tmp_path / "src" / "huge.py" + huge.write_bytes(b"x" * 1_000_001) + + files = discover_files(tmp_path, IndexOptions()) + rels = sorted(p.relative_to(tmp_path).as_posix() for p in files) + assert rels == ["README.md", "src/app.py"] diff --git a/packages/moss-repo-indexer/tests/test_sync.py b/packages/moss-repo-indexer/tests/test_sync.py new file mode 100644 index 00000000..096c5cc1 --- /dev/null +++ b/packages/moss-repo-indexer/tests/test_sync.py @@ -0,0 +1,62 @@ +"""Unit tests for sync and upload paths.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from moss_repo_indexer import MossCreds, SyncOptions, sync +from moss_repo_indexer.uploader import UploadOptions, upload_documents + + +def test_sync_dry_run(tmp_path: Path): + (tmp_path / "a.py").write_text("def hello():\n return 1\n") + creds = MossCreds("pid", "pkey", "idx") + + async def run(): + with patch("moss_repo_indexer.uploader.MossClient") as client_cls: + result = await sync(SyncOptions(source=str(tmp_path), creds=creds, dry_run=True)) + client_cls.assert_not_called() + return result + + result = asyncio.run(run()) + assert result.dry_run is True + assert result.mutation is None + assert len(result.documents) >= 1 + assert result.repo_name == tmp_path.name + + +def test_upload_recreate(): + from moss import DocumentInfo + + creds = MossCreds("pid", "pkey", "idx", model_name="moss-minilm") + docs = [DocumentInfo(id="1", text="hello", metadata={"path": "a.py"})] + client = AsyncMock() + client.delete_index = AsyncMock(side_effect=Exception("not found")) + client.create_index = AsyncMock(return_value=type("R", (), {"doc_count": 1})()) + + async def run(): + with patch("moss_repo_indexer.uploader.MossClient", return_value=client): + return await upload_documents(docs, creds) + + result = asyncio.run(run()) + client.create_index.assert_awaited_once() + assert result.doc_count == 1 + + +def test_upload_upsert(): + from moss import DocumentInfo + + creds = MossCreds("pid", "pkey", "idx") + docs = [DocumentInfo(id="1", text="hello")] + client = AsyncMock() + client.add_docs = AsyncMock(return_value=type("R", (), {"doc_count": 1})()) + + async def run(): + with patch("moss_repo_indexer.uploader.MossClient", return_value=client): + await upload_documents(docs, creds, UploadOptions(upsert=True)) + + asyncio.run(run()) + options = client.add_docs.await_args.args[2] + assert options.upsert is True From 76eb3ecd19806824833cea94f6a01af69f99bfc2 Mon Sep 17 00:00:00 2001 From: sonikagutha Date: Thu, 30 Jul 2026 14:53:24 +0530 Subject: [PATCH 2/2] Add repo indexer --- packages/moss-repo-indexer-js/README.md | 11 +++ packages/moss-repo-indexer-js/src/discover.ts | 72 ++++++++++++-- .../moss-repo-indexer-js/src/documents.ts | 6 +- packages/moss-repo-indexer-js/src/sync.ts | 1 + packages/moss-repo-indexer-js/src/types.ts | 2 + packages/moss-repo-indexer-js/src/uploader.ts | 9 +- packages/moss-repo-indexer/README.md | 5 +- .../src/moss_repo_indexer/chunkers/code.py | 9 +- .../src/moss_repo_indexer/chunkers/common.py | 9 +- .../moss_repo_indexer/chunkers/markdown.py | 20 ++-- .../src/moss_repo_indexer/clone.py | 11 +-- .../src/moss_repo_indexer/discover.py | 97 +++++++++++++------ .../src/moss_repo_indexer/documents.py | 17 ++-- .../src/moss_repo_indexer/sync.py | 7 +- .../src/moss_repo_indexer/types.py | 17 ++-- .../src/moss_repo_indexer/uploader.py | 28 ++++-- .../moss-repo-indexer/tests/test_discover.py | 28 +++++- packages/moss-repo-indexer/tests/test_sync.py | 24 ++++- 18 files changed, 277 insertions(+), 96 deletions(-) diff --git a/packages/moss-repo-indexer-js/README.md b/packages/moss-repo-indexer-js/README.md index b304e9f2..7a8b91f1 100644 --- a/packages/moss-repo-indexer-js/README.md +++ b/packages/moss-repo-indexer-js/README.md @@ -35,6 +35,17 @@ const uploaded = await sync({ indexName: 'my-codebase', }, }) + +// Destructive recreate (opt-in) +await sync({ + source: './my-repo', + creds: { + projectId: process.env.MOSS_PROJECT_ID!, + projectKey: process.env.MOSS_PROJECT_KEY!, + indexName: 'my-codebase', + }, + replace: true, +}) ``` ## Document metadata contract diff --git a/packages/moss-repo-indexer-js/src/discover.ts b/packages/moss-repo-indexer-js/src/discover.ts index 704de780..6a808503 100644 --- a/packages/moss-repo-indexer-js/src/discover.ts +++ b/packages/moss-repo-indexer-js/src/discover.ts @@ -13,38 +13,49 @@ export function discoverFiles(root: string, options: IndexOptions = {}): string[ const candidates = listCandidates(resolvedRoot, opts) return candidates + .filter((filePath) => isUnderRoot(filePath, resolvedRoot)) .filter((filePath) => matchesInclude(filePath, resolvedRoot, opts.includeGlobs)) .filter((filePath) => withinSizeLimit(filePath, opts.maxFileBytes)) .sort() } function listCandidates(root: string, options: ReturnType): string[] { - if (options.respectGitignore && isGitRepo(root)) { - const tracked = gitTrackedFiles(root) - if (tracked) { + if (options.respectGitignore && isGitWorkTree(root)) { + const tracked = gitListFiles(root) + if (tracked !== null) { return tracked } } return walkFilesystem(root, new Set(options.excludeDirs)) } -function isGitRepo(root: string): boolean { - return fs.existsSync(path.join(root, '.git')) +function isGitWorkTree(root: string): boolean { + if (!fs.existsSync(path.join(root, '.git'))) { + return false + } + const result = spawnSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], { + encoding: 'utf8', + }) + return result.status === 0 && result.stdout.trim() === 'true' } -function gitTrackedFiles(root: string): string[] | null { +function gitListFiles(root: string): string[] | null { const version = spawnSync('git', ['--version'], { encoding: 'utf8' }) if (version.status !== 0) { return null } - const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'buffer' }) + const result = spawnSync( + 'git', + ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], + { encoding: 'buffer' }, + ) if (result.status !== 0 || !result.stdout) { return null } const relPaths = result.stdout.toString('utf8').split('\0').filter(Boolean) return relPaths .map((rel) => path.join(root, rel)) - .filter((filePath) => fs.existsSync(filePath) && fs.statSync(filePath).isFile()) + .filter((filePath) => isIndexableFile(filePath, root)) } function walkFilesystem(root: string, excludeDirs: Set): string[] { @@ -52,15 +63,27 @@ function walkFilesystem(root: string, excludeDirs: Set): string[] { const stack = [root] while (stack.length > 0) { const current = stack.pop()! - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(current, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { const full = path.join(current, entry.name) + if (entry.isSymbolicLink()) { + if (isIndexableFile(full, root)) { + files.push(full) + } + continue + } if (entry.isDirectory()) { if (!excludeDirs.has(entry.name)) { stack.push(full) } continue } - if (entry.isFile()) { + if (entry.isFile() && isIndexableFile(full, root)) { files.push(full) } } @@ -68,8 +91,37 @@ function walkFilesystem(root: string, excludeDirs: Set): string[] { return files } +function isIndexableFile(filePath: string, root: string): boolean { + try { + const stat = fs.lstatSync(filePath) + if (stat.isDirectory()) { + return false + } + if (stat.isSymbolicLink() || stat.isFile()) { + return isUnderRoot(filePath, root) + } + return false + } catch { + return false + } +} + +function isUnderRoot(filePath: string, root: string): boolean { + try { + const resolved = fs.realpathSync(filePath) + const resolvedRoot = fs.realpathSync(root) + const rel = path.relative(resolvedRoot, resolved) + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) + } catch { + return false + } +} + function matchesInclude(filePath: string, root: string, includeGlobs: string[]): boolean { const rel = toPosix(path.relative(root, filePath)) + if (rel.startsWith('..')) { + return false + } const name = path.basename(filePath) for (const pattern of includeGlobs) { if (globMatch(rel, pattern) || globMatch(name, pattern)) { diff --git a/packages/moss-repo-indexer-js/src/documents.ts b/packages/moss-repo-indexer-js/src/documents.ts index 0f5d5a68..9e191ac3 100644 --- a/packages/moss-repo-indexer-js/src/documents.ts +++ b/packages/moss-repo-indexer-js/src/documents.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import path from 'node:path' import type { RepoDocument } from './document.js' import { chunkCode, chunkMarkdown, type FileChunkRequest } from './chunkers/index.js' @@ -7,10 +8,11 @@ import { isMarkdownPath, languageForPath } from './language.js' import { IndexOptions, resolveIndexOptions } from './types.js' export function buildDocuments(root: string, options: IndexOptions = {}): RepoDocument[] { + const resolvedRoot = path.resolve(root) const opts = resolveIndexOptions(options) const documents: RepoDocument[] = [] - for (const filePath of discoverFiles(root, opts)) { - documents.push(...chunkFile(filePath, root, opts)) + for (const filePath of discoverFiles(resolvedRoot, opts)) { + documents.push(...chunkFile(filePath, resolvedRoot, opts)) } return documents } diff --git a/packages/moss-repo-indexer-js/src/sync.ts b/packages/moss-repo-indexer-js/src/sync.ts index f44188b7..af76c6ed 100644 --- a/packages/moss-repo-indexer-js/src/sync.ts +++ b/packages/moss-repo-indexer-js/src/sync.ts @@ -42,6 +42,7 @@ async function syncResolved( } const mutation = await uploadDocuments(documents, options.creds, { upsert: options.upsert ?? false, + replace: options.replace ?? false, }) return { documents, diff --git a/packages/moss-repo-indexer-js/src/types.ts b/packages/moss-repo-indexer-js/src/types.ts index 5bbc2a9d..89e367c0 100644 --- a/packages/moss-repo-indexer-js/src/types.ts +++ b/packages/moss-repo-indexer-js/src/types.ts @@ -68,6 +68,8 @@ export interface SyncOptions { index?: IndexOptions dryRun?: boolean upsert?: boolean + /** Opt-in destructive recreate (delete index then create). */ + replace?: boolean } export function metadataContract(): Record { diff --git a/packages/moss-repo-indexer-js/src/uploader.ts b/packages/moss-repo-indexer-js/src/uploader.ts index 03d16334..a1860c75 100644 --- a/packages/moss-repo-indexer-js/src/uploader.ts +++ b/packages/moss-repo-indexer-js/src/uploader.ts @@ -3,6 +3,8 @@ import { DEFAULT_MODEL_NAME, type MossCreds } from './types.js' export interface UploadOptions { upsert?: boolean + /** When true, delete the existing index then recreate it. Opt-in destructive path. */ + replace?: boolean } export interface MutationResultLike { @@ -18,12 +20,17 @@ export async function uploadDocuments( if (documents.length === 0) { throw new Error('No documents to upload') } + if (options.upsert && options.replace) { + throw new Error('Pass only one of upsert or replace') + } const { MossClient } = await import('@moss-dev/moss') const client = new MossClient(creds.projectId, creds.projectKey) if (options.upsert) { return client.addDocs(creds.indexName, documents, { upsert: true }) } - await deleteIndexIfPresent(client, creds.indexName) + if (options.replace) { + await deleteIndexIfPresent(client, creds.indexName) + } return client.createIndex(creds.indexName, documents, { modelId: creds.modelName ?? DEFAULT_MODEL_NAME, }) diff --git a/packages/moss-repo-indexer/README.md b/packages/moss-repo-indexer/README.md index 7bcde17f..50475d87 100644 --- a/packages/moss-repo-indexer/README.md +++ b/packages/moss-repo-indexer/README.md @@ -43,11 +43,14 @@ result = await sync( ) print(len(result.documents), "chunks") -# Fresh index (delete + create_index) +# Create index (fails if it already exists) result = await sync(SyncOptions(source="./my-repo", creds=creds)) # Incremental upsert result = await sync(SyncOptions(source="./my-repo", creds=creds, upsert=True)) + +# Destructive recreate (opt-in) +result = await sync(SyncOptions(source="./my-repo", creds=creds, replace=True)) ``` ## Example diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py index 216583d1..8910173c 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/code.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -from typing import List, Optional, Pattern from moss import DocumentInfo @@ -13,7 +12,7 @@ WINDOW_LINES = 60 OVERLAP_LINES = 15 -_SYMBOL_PATTERNS: List[Pattern[str]] = [ +_SYMBOL_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)"), re.compile(r"^\s*class\s+([A-Za-z_][\w]*)"), re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_][\w]*)"), @@ -23,11 +22,11 @@ ] -def chunk_code(request: FileChunkRequest) -> List[DocumentInfo]: +def chunk_code(request: FileChunkRequest) -> list[DocumentInfo]: lines = split_lines(request.content) if not lines: return [] - docs: List[DocumentInfo] = [] + docs: list[DocumentInfo] = [] start = 1 total = len(lines) while start <= total: @@ -54,7 +53,7 @@ def chunk_code(request: FileChunkRequest) -> List[DocumentInfo]: return docs -def _detect_symbol(window_lines: List[str]) -> Optional[str]: +def _detect_symbol(window_lines: list[str]) -> str | None: for line in window_lines: for pattern in _SYMBOL_PATTERNS: match = pattern.match(line) diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py index 943f86e8..792ace30 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/common.py @@ -5,7 +5,6 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional from moss import DocumentInfo @@ -29,8 +28,8 @@ class FileChunkRequest: root: Path content: str language: str - repo_name: Optional[str] = None - ref: Optional[str] = None + repo_name: str | None = None + ref: str | None = None @dataclass @@ -67,7 +66,7 @@ def _chunk_id(rel: str, symbol: str, start_line: int, end_line: int) -> str: return f"{rel}:{start_line}-{end_line}" -def _metadata(request: FileChunkRequest, slice_: ChunkSlice, rel: str) -> Dict[str, str]: +def _metadata(request: FileChunkRequest, slice_: ChunkSlice, rel: str) -> dict[str, str]: meta = { METADATA_PATH: rel, METADATA_LANGUAGE: request.language, @@ -85,5 +84,5 @@ def _metadata(request: FileChunkRequest, slice_: ChunkSlice, rel: str) -> Dict[s return meta -def split_lines(content: str) -> List[str]: +def split_lines(content: str) -> list[str]: return content.splitlines() diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py index 6c29c1ed..37b91d9b 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/chunkers/markdown.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -from typing import List, Tuple from moss import DocumentInfo @@ -13,7 +12,7 @@ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") -def chunk_markdown(request: FileChunkRequest) -> List[DocumentInfo]: +def chunk_markdown(request: FileChunkRequest) -> list[DocumentInfo]: lines = split_lines(request.content) docs = [_page_document(request)] sections = _parse_sections(lines, relative_path(request.path, request.root)) @@ -40,12 +39,15 @@ def _page_document(request: FileChunkRequest) -> DocumentInfo: return DocumentInfo(id=rel, text=generated.text, metadata=generated.metadata) -def _parse_sections(lines: List[str], fallback_title: str) -> List[Tuple[str, int, int, str]]: +def _parse_sections( + lines: list[str], + fallback_title: str, +) -> list[tuple[str, int, int, str]]: """Return (heading, start_line, end_line, body) tuples.""" - sections: List[Tuple[str, int, List[str]]] = [] + sections: list[tuple[str, int, list[str]]] = [] heading = fallback_title start = 1 - body: List[str] = [] + body: list[str] = [] for index, line in enumerate(lines, start=1): match = _HEADING_RE.match(line) @@ -62,10 +64,10 @@ def _parse_sections(lines: List[str], fallback_title: str) -> List[Tuple[str, in def _flush_section( - sections: List[Tuple[str, int, List[str]]], + sections: list[tuple[str, int, list[str]]], heading: str, start: int, - body: List[str], + body: list[str], ) -> None: if body: sections.append((heading, start, list(body))) @@ -73,8 +75,8 @@ def _flush_section( def _documents_for_section( request: FileChunkRequest, - section: Tuple[str, int, int, str], -) -> List[DocumentInfo]: + section: tuple[str, int, int, str], +) -> list[DocumentInfo]: heading, start_line, end_line, body = section docs = [ make_document( diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py b/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py index 4b89aa32..a1af3250 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/clone.py @@ -8,7 +8,6 @@ import tempfile from dataclasses import dataclass from pathlib import Path -from typing import Optional from urllib.parse import urlparse _GIT_SSH_RE = re.compile(r"^git@[^:]+:.+\.git$") @@ -19,7 +18,7 @@ class ResolvedSource: root: Path repo_name: str - ref: Optional[str] + ref: str | None cleanup: bool def close(self) -> None: @@ -27,7 +26,7 @@ def close(self) -> None: shutil.rmtree(self.root, ignore_errors=True) -def resolve_source(source: str, ref: Optional[str] = None) -> ResolvedSource: +def resolve_source(source: str, ref: str | None = None) -> ResolvedSource: """Return a local directory for `source` (filesystem path or git URL).""" stripped = source.strip() if _is_git_url(stripped): @@ -43,7 +42,7 @@ def _is_git_url(source: str) -> bool: return False -def _resolve_local_path(source: str, ref: Optional[str]) -> ResolvedSource: +def _resolve_local_path(source: str, ref: str | None) -> ResolvedSource: root = Path(source).expanduser().resolve() if not root.is_dir(): raise FileNotFoundError(f"Source path is not a directory: {root}") @@ -55,7 +54,7 @@ def _resolve_local_path(source: str, ref: Optional[str]) -> ResolvedSource: ) -def _clone_git_url(url: str, ref: Optional[str]) -> ResolvedSource: +def _clone_git_url(url: str, ref: str | None) -> ResolvedSource: if shutil.which("git") is None: raise RuntimeError("git is required to clone a repository URL") @@ -74,7 +73,7 @@ def _clone_git_url(url: str, ref: Optional[str]) -> ResolvedSource: ) -def _run_git_clone(url: str, dest: Path, ref: Optional[str]) -> None: +def _run_git_clone(url: str, dest: Path, ref: str | None) -> None: command = ["git", "clone", "--depth", "1"] if ref: command.extend(["--branch", ref]) diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py b/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py index 5e92a251..75d9439d 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/discover.py @@ -3,82 +3,125 @@ from __future__ import annotations import fnmatch +import os import subprocess +from collections.abc import Iterable from pathlib import Path -from typing import Iterable, List, Optional, Set from .types import IndexOptions -def discover_files(root: Path, options: IndexOptions) -> List[Path]: +def discover_files(root: Path, options: IndexOptions) -> list[Path]: """Return absolute paths of files that should be chunked.""" - root = root.resolve() + root = root.expanduser().resolve() if not root.is_dir(): raise FileNotFoundError(f"Repository root is not a directory: {root}") candidates = _list_candidates(root, options) - matched = [path for path in candidates if _matches_include(path, root, options.include_globs)] + matched = [ + path + for path in candidates + if _is_under_root(path, root) and _matches_include(path, root, options.include_globs) + ] sized = [path for path in matched if _within_size_limit(path, options.max_file_bytes)] return sorted(sized) -def _list_candidates(root: Path, options: IndexOptions) -> List[Path]: - if options.respect_gitignore and _is_git_repo(root): - tracked = _git_tracked_files(root) +def _list_candidates(root: Path, options: IndexOptions) -> list[Path]: + if options.respect_gitignore and _is_git_work_tree(root): + tracked = _git_list_files(root) if tracked is not None: return tracked return _walk_filesystem(root, set(options.exclude_dirs)) -def _is_git_repo(root: Path) -> bool: - return (root / ".git").exists() +def _is_git_work_tree(root: Path) -> bool: + if not (root / ".git").exists(): + return False + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 and result.stdout.strip() == "true" + +def _git_list_files(root: Path) -> list[Path] | None: + """List tracked and untracked non-ignored files under root. -def _git_tracked_files(root: Path) -> Optional[List[Path]]: + Uses --cached --others --exclude-standard so the candidate set matches a + normal working tree (not only committed files). Returns None to fall back + to a filesystem walk when git is unavailable or fails. + """ if subprocess.run(["git", "--version"], capture_output=True, check=False).returncode != 0: return None result = subprocess.run( - ["git", "-C", str(root), "ls-files", "-z"], + [ + "git", + "-C", + str(root), + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ], capture_output=True, check=False, ) if result.returncode != 0: return None rel_paths = [p for p in result.stdout.decode("utf-8", errors="replace").split("\0") if p] - files: List[Path] = [] + files: list[Path] = [] for rel in rel_paths: path = root / rel - if path.is_file(): + if _is_indexable_file(path, root): files.append(path) return files -def _walk_filesystem(root: Path, exclude_dirs: Set[str]) -> List[Path]: - files: List[Path] = [] - for path in root.rglob("*"): - if not path.is_file(): - continue - if _is_under_excluded_dir(path, root, exclude_dirs): - continue - files.append(path) +def _walk_filesystem(root: Path, exclude_dirs: set[str]) -> list[Path]: + files: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + current = Path(dirpath) + dirnames[:] = [ + name + for name in dirnames + if name not in exclude_dirs and not (current / name).is_symlink() + ] + for name in filenames: + path = current / name + if _is_indexable_file(path, root): + files.append(path) return files -def _is_under_excluded_dir(path: Path, root: Path, exclude_dirs: Set[str]) -> bool: +def _is_indexable_file(path: Path, root: Path) -> bool: + if path.is_symlink(): + return path.exists() and _is_under_root(path, root) + if not path.is_file(): + return False + return _is_under_root(path, root) + + +def _is_under_root(path: Path, root: Path) -> bool: try: - parts = path.relative_to(root).parts - except ValueError: + path.resolve().relative_to(root.resolve()) return True - return any(part in exclude_dirs for part in parts[:-1]) + except (ValueError, OSError): + return False def _matches_include(path: Path, root: Path, include_globs: Iterable[str]) -> bool: - rel = path.relative_to(root).as_posix() + try: + rel = path.relative_to(root).as_posix() + except ValueError: + return False name = path.name for pattern in include_globs: if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(name, pattern): return True - # fnmatch treats "**/*.md" as requiring a slash; allow root-level matches. if pattern.startswith("**/") and fnmatch.fnmatch(name, pattern[3:]): return True return False diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py b/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py index 5384798d..9c4bbb83 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/documents.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from typing import List, Optional from moss import DocumentInfo @@ -13,16 +12,20 @@ from .types import IndexOptions -def build_documents(root: Path, options: Optional[IndexOptions] = None) -> List[DocumentInfo]: +def build_documents( + root: Path | str, + options: IndexOptions | None = None, +) -> list[DocumentInfo]: """Discover files under `root`, chunk them, and return DocumentInfo rows.""" + resolved_root = Path(root).expanduser().resolve() opts = options or IndexOptions() - documents: List[DocumentInfo] = [] - for path in discover_files(root, opts): - documents.extend(_chunk_file(path, root, opts)) + documents: list[DocumentInfo] = [] + for path in discover_files(resolved_root, opts): + documents.extend(_chunk_file(path, resolved_root, opts)) return documents -def _chunk_file(path: Path, root: Path, options: IndexOptions) -> List[DocumentInfo]: +def _chunk_file(path: Path, root: Path, options: IndexOptions) -> list[DocumentInfo]: content = _read_text(path) if content is None: return [] @@ -39,7 +42,7 @@ def _chunk_file(path: Path, root: Path, options: IndexOptions) -> List[DocumentI return chunk_code(request) -def _read_text(path: Path) -> Optional[str]: +def _read_text(path: Path) -> str | None: try: return path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py b/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py index 4b2c7406..e5f6fae7 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/sync.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass, replace -from typing import List, Optional from moss import DocumentInfo, MutationResult @@ -15,8 +14,8 @@ @dataclass class SyncResult: - documents: List[DocumentInfo] - mutation: Optional[MutationResult] + documents: list[DocumentInfo] + mutation: MutationResult | None dry_run: bool repo_name: str root: str @@ -46,7 +45,7 @@ async def _sync_resolved(options: SyncOptions, resolved: ResolvedSource) -> Sync mutation = await upload_documents( documents, options.creds, - UploadOptions(upsert=options.upsert), + UploadOptions(upsert=options.upsert, replace=options.replace), ) return SyncResult( documents=documents, diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/types.py b/packages/moss-repo-indexer/src/moss_repo_indexer/types.py index 8ecdfce6..c054c879 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/types.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/types.py @@ -7,8 +7,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, List, Optional - # --- Document metadata contract (mirrored by the TS package) --- @@ -30,7 +28,7 @@ CHUNK_TYPE_PAGE = "page" DEFAULT_MODEL_NAME = "moss-minilm" -DEFAULT_INCLUDE_GLOBS: List[str] = [ +DEFAULT_INCLUDE_GLOBS: list[str] = [ "**/*.py", "**/*.ts", "**/*.tsx", @@ -42,7 +40,7 @@ "**/*.md", "**/*.mdx", ] -DEFAULT_EXCLUDE_DIRS: List[str] = [ +DEFAULT_EXCLUDE_DIRS: list[str] = [ ".git", "node_modules", "vendor", @@ -69,12 +67,12 @@ class MossCreds: class IndexOptions: """Options for discovering and chunking a repository.""" - include_globs: List[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE_GLOBS)) - exclude_dirs: List[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE_DIRS)) + include_globs: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE_GLOBS)) + exclude_dirs: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE_DIRS)) respect_gitignore: bool = True - ref: Optional[str] = None + ref: str | None = None max_file_bytes: int = 1_000_000 - repo_name: Optional[str] = None + repo_name: str | None = None @dataclass @@ -86,9 +84,10 @@ class SyncOptions: index: IndexOptions = field(default_factory=IndexOptions) dry_run: bool = False upsert: bool = False + replace: bool = False -def metadata_contract() -> Dict[str, str]: +def metadata_contract() -> dict[str, str]: """Return the canonical metadata key → description mapping.""" return { METADATA_PATH: "Repository-relative file path", diff --git a/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py b/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py index 85ad143c..8d074a75 100644 --- a/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py +++ b/packages/moss-repo-indexer/src/moss_repo_indexer/uploader.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import List, Optional from moss import DocumentInfo, MossClient, MutationOptions, MutationResult @@ -12,29 +11,46 @@ @dataclass class UploadOptions: + """Upload behavior. + + - default: create_index only (fails if the index already exists) + - upsert=True: add_docs with upsert + - replace=True: delete existing index then create_index (destructive) + """ + upsert: bool = False + replace: bool = False async def upload_documents( - documents: List[DocumentInfo], + documents: list[DocumentInfo], creds: MossCreds, - options: Optional[UploadOptions] = None, + options: UploadOptions | None = None, ) -> MutationResult: """Create an index or upsert documents into an existing index.""" if not documents: raise ValueError("No documents to upload") opts = options or UploadOptions() + if opts.upsert and opts.replace: + raise ValueError("Pass only one of upsert=True or replace=True") + client = MossClient(creds.project_id, creds.project_key) if opts.upsert: return await _upsert_documents(client, creds, documents) - return await _recreate_index(client, creds, documents) + if opts.replace: + return await _recreate_index(client, creds, documents) + return await client.create_index( + creds.index_name, + documents, + creds.model_name, + ) async def _upsert_documents( client: MossClient, creds: MossCreds, - documents: List[DocumentInfo], + documents: list[DocumentInfo], ) -> MutationResult: return await client.add_docs( creds.index_name, @@ -46,7 +62,7 @@ async def _upsert_documents( async def _recreate_index( client: MossClient, creds: MossCreds, - documents: List[DocumentInfo], + documents: list[DocumentInfo], ) -> MutationResult: await _delete_index_if_present(client, creds.index_name) return await client.create_index( diff --git a/packages/moss-repo-indexer/tests/test_discover.py b/packages/moss-repo-indexer/tests/test_discover.py index 7c9c922e..d6c5ad17 100644 --- a/packages/moss-repo-indexer/tests/test_discover.py +++ b/packages/moss-repo-indexer/tests/test_discover.py @@ -6,7 +6,7 @@ import pytest -from moss_repo_indexer import IndexOptions, discover_files, resolve_source +from moss_repo_indexer import IndexOptions, build_documents, discover_files, resolve_source from moss_repo_indexer.clone import _is_git_url, _repo_name_from_url @@ -41,5 +41,29 @@ def test_discover_filters(tmp_path: Path): huge.write_bytes(b"x" * 1_000_001) files = discover_files(tmp_path, IndexOptions()) - rels = sorted(p.relative_to(tmp_path).as_posix() for p in files) + rels = sorted(p.relative_to(tmp_path.resolve()).as_posix() for p in files) assert rels == ["README.md", "src/app.py"] + + +def test_discover_skips_symlink_escape(tmp_path: Path, tmp_path_factory): + outside = tmp_path_factory.mktemp("outside") + secret = outside / "secret.py" + secret.write_text("SECRET = 1\n") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("print(1)\n") + link = tmp_path / "leak.py" + link.symlink_to(secret) + + files = discover_files(tmp_path, IndexOptions()) + rels = [p.relative_to(tmp_path.resolve()).as_posix() for p in files] + assert "leak.py" not in rels + assert "src/app.py" in rels + + +def test_build_documents_accepts_relative_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("def main():\n return 0\n") + monkeypatch.chdir(tmp_path) + docs = build_documents(Path("./src"), IndexOptions(repo_name="demo")) + assert docs + assert all(d.metadata and d.metadata.get("path", "").startswith("app.py") for d in docs) diff --git a/packages/moss-repo-indexer/tests/test_sync.py b/packages/moss-repo-indexer/tests/test_sync.py index 096c5cc1..2023fbf6 100644 --- a/packages/moss-repo-indexer/tests/test_sync.py +++ b/packages/moss-repo-indexer/tests/test_sync.py @@ -27,13 +27,13 @@ async def run(): assert result.repo_name == tmp_path.name -def test_upload_recreate(): +def test_upload_default_does_not_delete(): from moss import DocumentInfo creds = MossCreds("pid", "pkey", "idx", model_name="moss-minilm") docs = [DocumentInfo(id="1", text="hello", metadata={"path": "a.py"})] client = AsyncMock() - client.delete_index = AsyncMock(side_effect=Exception("not found")) + client.delete_index = AsyncMock() client.create_index = AsyncMock(return_value=type("R", (), {"doc_count": 1})()) async def run(): @@ -41,6 +41,26 @@ async def run(): return await upload_documents(docs, creds) result = asyncio.run(run()) + client.delete_index.assert_not_awaited() + client.create_index.assert_awaited_once() + assert result.doc_count == 1 + + +def test_upload_replace(): + from moss import DocumentInfo + + creds = MossCreds("pid", "pkey", "idx", model_name="moss-minilm") + docs = [DocumentInfo(id="1", text="hello", metadata={"path": "a.py"})] + client = AsyncMock() + client.delete_index = AsyncMock(side_effect=Exception("not found")) + client.create_index = AsyncMock(return_value=type("R", (), {"doc_count": 1})()) + + async def run(): + with patch("moss_repo_indexer.uploader.MossClient", return_value=client): + return await upload_documents(docs, creds, UploadOptions(replace=True)) + + result = asyncio.run(run()) + client.delete_index.assert_awaited_once() client.create_index.assert_awaited_once() assert result.doc_count == 1