diff --git a/.claude/rules/51-runtime-boundary-validation.md b/.claude/rules/51-runtime-boundary-validation.md new file mode 100644 index 0000000000..5b15ca7d98 --- /dev/null +++ b/.claude/rules/51-runtime-boundary-validation.md @@ -0,0 +1,26 @@ +# Runtime boundary validation + +External values stay `unknown` until a runtime boundary makes them safe. + +Use the current Valibot helpers for API JSON and external service responses: + +- `jsonValidator(schema)` in `apps/api/src/schemas/_validator.ts` for required Hono request bodies, followed by `c.req.valid('json')`. +- `parseOptionalBody(req, schema, fallback)` for optional request bodies where every schema field is optional. +- `parseWithSchema(schema, value, context)`, `expectJsonRecord`, `maybeJsonRecord`, `parseJsonRecord`, `readRequestJsonRecord`, and `readResponseJson` in `apps/api/src/lib/runtime-validation.ts` for non-Hono runtime JSON boundaries. + +Sanctioned bounded patterns: + +- Env access may use a narrow local env interface or a guard-then-cast when a Durable Object receives a structural subset/superset of the Worker env. Keep the cast local to the boundary and document why the fields exist. +- Durable Object stubs may use the existing typed RPC cast pattern after `env..get(id)` or service-layer helpers, because Cloudflare's generated stub type cannot express the project-specific RPC surface. +- RPC/tool handlers may use a bounded handler cast at the registration boundary when the runtime dispatcher enforces the call shape elsewhere. +- Guard-then-cast is acceptable for small structural checks when schema parsing would be excessive: check object/null/array shape and required field types immediately before the cast. + +Do not replace established bounded Zod subsystems incidentally. New API/runtime-validation work should prefer the Valibot helpers above unless the touched subsystem already has a contained Zod boundary. + +Avoid these patterns in new code: + +- `await c.req.json()` without `jsonValidator` or an equivalent parser. +- `JSON.parse(raw) as T` except for `as unknown` followed by validation. +- Local `isRecord`/`isObject` helpers that recreate shared validation helpers. +- D1/Durable Object row arrays narrowed directly from `.toArray()`, `.first()`, `.all()`, or raw SQL results without a row mapper, schema parse, or guard. +- External webhook/fetch/request payloads narrowed directly to domain types without validation. diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index f355e72968..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -dist/ -build/ -coverage/ -.turbo/ -*.min.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f1b5c48861..d0e7982895 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,6 +30,10 @@ updates: directory: '/packages/harness' schedule: interval: weekly + - package-ecosystem: 'gomod' + directory: '/scripts/quality/govulncheck-tool' + schedule: + interval: weekly - package-ecosystem: 'docker' directory: '/apps/api' schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f86d2824ba..ef7026438a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: env: DEVCONTAINERS_CLI_VERSION: 0.80.2 + GITLEAKS_VERSION: 8.30.1 permissions: contents: read @@ -39,6 +40,7 @@ jobs: devcontainer: ${{ steps.filter.outputs.devcontainer }} devcontainer-volume-mount: ${{ steps.filter.outputs.devcontainer-volume-mount }} web-ui: ${{ steps.filter.outputs.web-ui }} + go-modules: ${{ steps.filter.outputs.go-modules }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 @@ -68,6 +70,11 @@ jobs: - 'packages/terminal/**' - 'packages/acp-client/**' - '.github/workflows/ci.yml' + go-modules: + - '**/go.mod' + - '**/go.sum' + - 'scripts/quality/check-go-vulnerability-diff.ts' + - '.github/workflows/ci.yml' preflight-evidence: name: Preflight Evidence @@ -142,6 +149,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -153,9 +162,69 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint + - name: Check formatting + run: pnpm format:check + + - name: Run Oxlint shadow + run: pnpm lint:oxlint + + - name: Run ESLint authoritative layer run: pnpm lint + - name: Enforce type-boundary ratchet + run: pnpm quality:type-boundaries + + - name: Run SAM RuleTester fixtures + run: pnpm --filter @simple-agent-manager/eslint-plugin-sam test + + - name: Shadow SAM fixtures through Oxlint alpha host + continue-on-error: true + run: pnpm lint:oxlint:sam-shadow + + secret-scan: + name: Secret Scan + if: github.event_name == 'pull_request' || github.repository == 'raphaeltm/simple-agent-manager' + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Install verified Gitleaks release + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p "$RUNNER_TEMP/gitleaks" + cd "$RUNNER_TEMP/gitleaks" + gh release download "v${GITLEAKS_VERSION}" \ + --repo gitleaks/gitleaks \ + --pattern "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + --pattern "gitleaks_${GITLEAKS_VERSION}_checksums.txt" + sha256sum --ignore-missing --check "gitleaks_${GITLEAKS_VERSION}_checksums.txt" + tar -xzf "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + + - name: Scan current tree + env: + SAM_GITLEAKS_BIN: ${{ runner.temp }}/gitleaks/gitleaks + run: pnpm quality:gitleaks:current + + - name: Scan pull request commit range + if: github.event_name == 'pull_request' + env: + SAM_GITLEAKS_BIN: ${{ runner.temp }}/gitleaks/gitleaks + run: pnpm quality:gitleaks:pr + typecheck: name: Type Check if: github.event_name == 'pull_request' || github.repository == 'raphaeltm/simple-agent-manager' @@ -332,6 +401,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -369,8 +440,52 @@ jobs: - name: Dependency governance pinning check run: pnpm quality:dependency-governance + + - name: Direct dependency evidence + run: pnpm quality:direct-dependency-evidence + + - name: Runtime-boundary semantic shadow + run: pnpm quality:runtime-boundary-semantics + - name: CI workflow wiring tests - run: pnpm exec vitest run --config scripts/quality/vitest.config.ts scripts/quality/ci-worker-suite.test.ts + run: pnpm exec vitest run --config scripts/quality/vitest.config.ts ci-quality-program.test.ts ci-worker-suite.test.ts + + go-vulnerability-diff: + name: Go Vulnerability Diff + needs: [changes] + if: needs.changes.outputs.go-modules == 'true' + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25' + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Install govulncheck + run: | + mkdir -p "$RUNNER_TEMP/go/bin" + cd scripts/quality/govulncheck-tool + go build -o "$RUNNER_TEMP/go/bin/govulncheck" golang.org/x/vuln/cmd/govulncheck + + - name: Scan changed Go modules + env: + SAM_GOVULNCHECK_BIN: ${{ runner.temp }}/go/bin/govulncheck + run: pnpm quality:govulncheck-diff durable-object-workers: name: Durable Object Workers diff --git a/.github/workflows/deploy-reusable.yml b/.github/workflows/deploy-reusable.yml index 0d605377ce..f63f3865c0 100644 --- a/.github/workflows/deploy-reusable.yml +++ b/.github/workflows/deploy-reusable.yml @@ -194,7 +194,7 @@ jobs: BUCKET_NAME="${{ vars.PULUMI_STATE_BUCKET || format('{0}-pulumi-state', steps.prefix.outputs.value) }}" set +e - OUTPUT=$(npx wrangler r2 bucket create "$BUCKET_NAME" 2>&1) + OUTPUT=$(pnpm --filter @simple-agent-manager/api exec wrangler r2 bucket create "$BUCKET_NAME" 2>&1) STATUS=$? set -e echo "$OUTPUT" diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..040405f629 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,48 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "warn" + }, + "env": { + "browser": true, + "builtin": true, + "node": true, + "worker": true + }, + "ignorePatterns": ["**/coverage/**", "**/dist/**", "**/node_modules/**", "**/*.cjs", "**/*.js"], + "options": { + "denyWarnings": false, + "reportUnusedDisableDirectives": "off", + "typeAware": false, + "typeCheck": false + }, + "plugins": ["eslint", "jsx-a11y", "react", "typescript"], + "rules": { + "eslint/no-console": "off", + "typescript/consistent-type-imports": [ + "warn", + { + "disallowTypeAnnotations": false, + "fixStyle": "inline-type-imports", + "prefer": "type-imports" + } + ], + "typescript/no-explicit-any": "warn", + "typescript/no-non-null-assertion": "warn", + "typescript/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] + }, + "overrides": [ + { + "excludeFiles": ["apps/api/src/lib/logger.ts"], + "files": ["apps/api/src/**/*.ts"], + "rules": { + "eslint/no-console": "warn" + } + } + ], + "settings": { + "react": { + "version": "19.2.7" + } + } +} diff --git a/.oxlintrc.sam-shadow.json b/.oxlintrc.sam-shadow.json new file mode 100644 index 0000000000..e7b90e7cae --- /dev/null +++ b/.oxlintrc.sam-shadow.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "jsPlugins": [ + { + "name": "sam", + "specifier": "./packages/eslint-plugin-sam/src/index.js" + } + ], + "options": { + "denyWarnings": false, + "reportUnusedDisableDirectives": "off", + "typeAware": false, + "typeCheck": false + }, + "plugins": [], + "rules": { + "sam/no-local-record-guard": "warn", + "sam/no-unsafe-json-parse-assertion": "warn", + "sam/no-unvalidated-request-json": "warn" + } +} diff --git a/.prettierignore b/.prettierignore index dafae88a81..4e497e2086 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,11 @@ dist/ build/ coverage/ .turbo/ +.cache/ +apps/www/.astro/ +apps/www/public/scripts/blog-mermaid.js +apps/www/public/scripts/docs-mermaid.js +apps/www/public/scripts/tracker.js package-lock.json yarn.lock pnpm-lock.yaml diff --git a/CLAUDE.md b/CLAUDE.md index a57774a8a5..5b036bfa4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ packages/ ├── terminal/ # Shared terminal component ├── cloud-init/ # Cloud-init template generator ├── acp-client/ # Shared ACP React components (MessageBubble, MessageActions, AudioPlayer) +├── eslint-plugin-sam/ # Unpublished repository-specific ESLint boundary rules ├── ui/ # Design system tokens and shared UI components └── vm-agent/ # Go VM agent (PTY, WebSocket, ACP, MCP tool endpoints) tasks/ # Task tracking (backlog -> active -> archive) @@ -33,8 +34,13 @@ pnpm test # Run tests pnpm typecheck # Type check pnpm lint # Lint pnpm format # Format +pnpm check:fast # Deterministic local quality contract used by CI leaf commands ``` +`pnpm check:fast` runs the formatting ratchet, report-only Oxlint shadow, authoritative +ESLint workspace checks (including the SAM custom-rule tail), and the blocking type-boundary +ratchet. Scanner and quality-policy commands are documented in `scripts/quality/README.md`. + ## Build Order Build packages in dependency order: `shared` -> `providers` -> `cloud-init` -> `api` / `web` diff --git a/apps/api/tests/unit/node-agent-contract.test.ts b/apps/api/tests/unit/node-agent-contract.test.ts index 24da81f542..feaf974885 100644 --- a/apps/api/tests/unit/node-agent-contract.test.ts +++ b/apps/api/tests/unit/node-agent-contract.test.ts @@ -26,21 +26,38 @@ import { WorkspaceReadyRequestSchema, WorkspaceReadyResponseSchema, } from '@simple-agent-manager/shared'; -import { exportPKCS8, exportSPKI,generateKeyPair } from 'jose'; -import { afterEach,beforeAll, describe, expect, it, vi } from 'vitest'; +import { exportPKCS8, exportSPKI, generateKeyPair } from 'jose'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ============================================================================= // Key generation for JWT tests // ============================================================================= -let testPrivateKey: string; -let testPublicKey: string; - -beforeAll(async () => { - const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); - testPrivateKey = await exportPKCS8(privateKey); - testPublicKey = await exportSPKI(publicKey); -}); +const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); +const [testPrivateKey, testPublicKey] = await Promise.all([ + exportPKCS8(privateKey), + exportSPKI(publicKey), +]); + +const fetchWithTimeoutMock = vi.fn(); +vi.doMock('../../src/services/telemetry', () => ({ + recordNodeRoutingMetric: vi.fn(), +})); +vi.doMock('../../src/services/fetch-timeout', () => ({ + fetchWithTimeout: fetchWithTimeoutMock, + getTimeoutMs: vi.fn().mockReturnValue(30_000), +})); +const { createAgentSessionOnNode, createWorkspaceOnNode, deleteWorkspaceOnNode } = + await import('../../src/services/node-agent'); + +function makeNodeAgentTestEnv() { + return { + BASE_DOMAIN: 'example.com', + JWT_PRIVATE_KEY: testPrivateKey, + JWT_PUBLIC_KEY: testPublicKey, + NODE_AGENT_REQUEST_TIMEOUT_MS: '30000', + } as any; +} afterEach(() => { vi.restoreAllMocks(); @@ -833,47 +850,19 @@ describe('JWT Token Contract', () => { // ============================================================================= describe('Node Agent client functions send correct payloads', () => { + beforeEach(() => { + fetchWithTimeoutMock.mockReset(); + }); + it('createWorkspaceOnNode sends correct JSON body', async () => { - // Mock the JWT signing - vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), - })); - - vi.doMock('../../src/services/telemetry', () => ({ - recordNodeRoutingMetric: vi.fn(), - })); - - let capturedBody: string | null = null; - let capturedHeaders: Headers | null = null; - let capturedUrl: string | null = null; - - vi.doMock('../../src/services/fetch-timeout', () => ({ - fetchWithTimeout: vi.fn().mockImplementation((url: string, init: RequestInit) => { - capturedUrl = url; - capturedHeaders = new Headers(init.headers); - capturedBody = init.body as string; - return Promise.resolve( - new Response(JSON.stringify({ workspaceId: 'ws-test', status: 'creating' }), { - status: 202, - headers: { 'Content-Type': 'application/json' }, - }) - ); - }), - getTimeoutMs: vi.fn().mockReturnValue(30000), - })); - - // Dynamic import to pick up mocks - const { createWorkspaceOnNode } = await import('../../src/services/node-agent'); - - const env = { - BASE_DOMAIN: 'example.com', - NODE_AGENT_REQUEST_TIMEOUT_MS: '30000', - } as any; - - await createWorkspaceOnNode('node-abc', env, 'user-123', { + fetchWithTimeoutMock.mockResolvedValue( + new Response(JSON.stringify({ workspaceId: 'ws-test', status: 'creating' }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + await createWorkspaceOnNode('node-abc', makeNodeAgentTestEnv(), 'user-123', { workspaceId: 'ws-test', repository: 'owner/repo', branch: 'main', @@ -883,12 +872,15 @@ describe('Node Agent client functions send correct payloads', () => { githubId: '42', }); + const [capturedUrl, capturedInit] = fetchWithTimeoutMock.mock.calls[0] as [string, RequestInit]; + const capturedHeaders = new Headers(capturedInit.headers); + // Verify URL expect(capturedUrl).toContain('/workspaces'); expect(capturedUrl).toContain('node-abc.vm.example.com'); // Verify body shape matches contract - const parsedBody = JSON.parse(capturedBody!); + const parsedBody = JSON.parse(capturedInit.body as string); const result = CreateWorkspaceAgentRequestSchema.safeParse(parsedBody); expect(result.success).toBe(true); expect(parsedBody.workspaceId).toBe('ws-test'); @@ -897,102 +889,56 @@ describe('Node Agent client functions send correct payloads', () => { expect(parsedBody.callbackToken).toBe('cb-token'); // Verify auth header - expect(capturedHeaders!.get('Authorization')).toBe('Bearer mock-jwt'); - expect(capturedHeaders!.get('Content-Type')).toBe('application/json'); + expect(capturedHeaders.get('Authorization')).toMatch(/^Bearer ey/); + expect(capturedHeaders.get('Content-Type')).toBe('application/json'); }); it('deleteWorkspaceOnNode sends DELETE with correct path', async () => { - vi.resetModules(); - - vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), - })); - - vi.doMock('../../src/services/telemetry', () => ({ - recordNodeRoutingMetric: vi.fn(), - })); - - let capturedMethod: string | null = null; - let capturedUrl: string | null = null; - - vi.doMock('../../src/services/fetch-timeout', () => ({ - fetchWithTimeout: vi.fn().mockImplementation((url: string, init: RequestInit) => { - capturedUrl = url; - capturedMethod = init.method ?? 'GET'; - return Promise.resolve( - new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ); - }), - getTimeoutMs: vi.fn().mockReturnValue(30000), - })); - - const { deleteWorkspaceOnNode } = await import('../../src/services/node-agent'); - - await deleteWorkspaceOnNode('node-abc', 'ws-delete-me', {} as any, 'user-123'); - - expect(capturedMethod).toBe('DELETE'); + fetchWithTimeoutMock.mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + await deleteWorkspaceOnNode('node-abc', 'ws-delete-me', makeNodeAgentTestEnv(), 'user-123'); + + const [capturedUrl, capturedInit] = fetchWithTimeoutMock.mock.calls[0] as [string, RequestInit]; + expect(capturedInit.method).toBe('DELETE'); expect(capturedUrl).toContain('/workspaces/ws-delete-me'); }); it('createAgentSessionOnNode sends correct JSON body', async () => { - vi.resetModules(); - - vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), - })); - - vi.doMock('../../src/services/telemetry', () => ({ - recordNodeRoutingMetric: vi.fn(), - })); - - let capturedBody: string | null = null; - - vi.doMock('../../src/services/fetch-timeout', () => ({ - fetchWithTimeout: vi.fn().mockImplementation((_url: string, init: RequestInit) => { - capturedBody = init.body as string; - return Promise.resolve( - new Response( - JSON.stringify({ - id: 'sess-new', - workspaceId: 'ws-test', - status: 'running', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - }), - getTimeoutMs: vi.fn().mockReturnValue(30000), - })); - - const { createAgentSessionOnNode } = await import('../../src/services/node-agent'); + fetchWithTimeoutMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sess-new', + workspaceId: 'ws-test', + status: 'running', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); await createAgentSessionOnNode( 'node-abc', 'ws-test', 'sess-new', 'Test Session', - {} as any, + makeNodeAgentTestEnv(), 'user-123', 'chat-123', 'proj-123', - { url: 'https://api.example.com/mcp', token: 'mcp-token' }, + { url: 'https://api.example.com/mcp', token: 'mcp-token' } ); - const parsedBody = JSON.parse(capturedBody!); + const [, capturedInit] = fetchWithTimeoutMock.mock.calls[0] as [string, RequestInit]; + const parsedBody = JSON.parse(capturedInit.body as string); const result = CreateAgentSessionAgentRequestSchema.safeParse(parsedBody); expect(result.success).toBe(true); expect(parsedBody.sessionId).toBe('sess-new'); @@ -1005,93 +951,41 @@ describe('Node Agent client functions send correct payloads', () => { }); it('node agent request throws on non-ok response', async () => { - vi.resetModules(); - - vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), - })); - - vi.doMock('../../src/services/telemetry', () => ({ - recordNodeRoutingMetric: vi.fn(), - })); - - vi.doMock('../../src/services/fetch-timeout', () => ({ - fetchWithTimeout: vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: 'workspace not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }) - ), - getTimeoutMs: vi.fn().mockReturnValue(30000), - })); - - const { deleteWorkspaceOnNode } = await import('../../src/services/node-agent'); + fetchWithTimeoutMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'workspace not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ); await expect( - deleteWorkspaceOnNode('node-abc', 'ws-missing', {} as any, 'user-123') + deleteWorkspaceOnNode('node-abc', 'ws-missing', makeNodeAgentTestEnv(), 'user-123') ).rejects.toThrow('Node Agent request failed: 404'); }); it('node agent request detects Worker loop-back 404 and provides clear error', async () => { - vi.resetModules(); - - vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), - })); - - vi.doMock('../../src/services/telemetry', () => ({ - recordNodeRoutingMetric: vi.fn(), - })); - // Simulate the API Worker's own 404 response (loop-back via wildcard DNS) - vi.doMock('../../src/services/fetch-timeout', () => ({ - fetchWithTimeout: vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Endpoint not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }) - ), - getTimeoutMs: vi.fn().mockReturnValue(30000), - })); - - const { deleteWorkspaceOnNode } = await import('../../src/services/node-agent'); + fetchWithTimeoutMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Endpoint not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + ); await expect( - deleteWorkspaceOnNode('node-abc', 'ws-test', {} as any, 'user-123') + deleteWorkspaceOnNode('node-abc', 'ws-test', makeNodeAgentTestEnv(), 'user-123') ).rejects.toThrow('Node Agent unreachable: DNS record for node-abc.vm may be missing'); }); it('node agent request throws on timeout', async () => { - vi.resetModules(); - - vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), - })); - - vi.doMock('../../src/services/telemetry', () => ({ - recordNodeRoutingMetric: vi.fn(), - })); - - vi.doMock('../../src/services/fetch-timeout', () => ({ - fetchWithTimeout: vi.fn().mockRejectedValue( - new Error('Request timed out after 30000ms: https://node-abc.vm.example.com:8443/workspaces/ws-test') - ), - getTimeoutMs: vi.fn().mockReturnValue(30000), - })); - - const { deleteWorkspaceOnNode } = await import('../../src/services/node-agent'); + fetchWithTimeoutMock.mockRejectedValue( + new Error( + 'Request timed out after 30000ms: https://node-abc.vm.example.com:8443/workspaces/ws-test' + ) + ); await expect( - deleteWorkspaceOnNode('node-abc', 'ws-test', {} as any, 'user-123') + deleteWorkspaceOnNode('node-abc', 'ws-test', makeNodeAgentTestEnv(), 'user-123') ).rejects.toThrow('Request timed out'); }); }); diff --git a/apps/api/tests/unit/routes/node-lifecycle-deployment-heartbeat.test.ts b/apps/api/tests/unit/routes/node-lifecycle-deployment-heartbeat.test.ts index 9ad5e23d92..a646320135 100644 --- a/apps/api/tests/unit/routes/node-lifecycle-deployment-heartbeat.test.ts +++ b/apps/api/tests/unit/routes/node-lifecycle-deployment-heartbeat.test.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nodeLifecycleRoutes } from '../../../src/routes/node-lifecycle'; + type Condition = { op: 'eq'; col: string; val: unknown } | { op: 'and'; conds: Condition[] }; const updates: Array<{ table: unknown; values: Record; where: unknown }> = []; @@ -198,7 +200,6 @@ async function postHeartbeat( DEPLOY_SIGNING_PUBLIC_KEY: 'pub-key', } ) { - const { nodeLifecycleRoutes } = await import('../../../src/routes/node-lifecycle'); const app = new Hono(); app.route('/api/nodes', nodeLifecycleRoutes); return app.request( diff --git a/apps/tail-worker/package.json b/apps/tail-worker/package.json index 26029c9a8e..37a242b4d7 100644 --- a/apps/tail-worker/package.json +++ b/apps/tail-worker/package.json @@ -8,7 +8,8 @@ "deploy:staging": "wrangler deploy --env staging", "deploy:production": "wrangler deploy --env production", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' 'vitest.config.ts' --rule 'simple-import-sort/imports: off' --rule 'simple-import-sort/exports: off'" }, "devDependencies": { "@cloudflare/workers-types": "catalog:", diff --git a/apps/web/package.json b/apps/web/package.json index 47340b1f4e..289994d00f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -63,7 +63,7 @@ "@vitest/coverage-v8": "catalog:", "eslint": "catalog:", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "4.6.2", + "eslint-plugin-react-hooks": "catalog:", "jsdom": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/apps/web/src/pages/ToolsCli.tsx b/apps/web/src/pages/ToolsCli.tsx index 588179c663..e8ebda62b0 100644 --- a/apps/web/src/pages/ToolsCli.tsx +++ b/apps/web/src/pages/ToolsCli.tsx @@ -14,6 +14,12 @@ interface PlatformDownload { filename: string; } +interface NavigatorWithUserAgentData extends Navigator { + userAgentData?: { + platform?: string; + }; +} + const PLATFORMS: PlatformDownload[] = [ { os: 'darwin', arch: 'arm64', label: 'macOS Apple Silicon', filename: 'sam-darwin-arm64' }, { os: 'darwin', arch: 'amd64', label: 'macOS Intel', filename: 'sam-darwin-amd64' }, @@ -54,8 +60,7 @@ function detectPlatform(): { os: string; arch: string } { let arch = 'amd64'; if (ua.includes('mac')) os = 'darwin'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const uaData = (navigator as any).userAgentData; + const uaData = (navigator as NavigatorWithUserAgentData).userAgentData; if (uaData?.platform?.toLowerCase().includes('mac')) os = 'darwin'; if (ua.includes('arm64') || ua.includes('aarch64')) arch = 'arm64'; @@ -117,7 +122,10 @@ export function ToolsCli() { // PLATFORMS is a non-empty const array — fallback to first entry if detection doesn't match const primaryMatch = PLATFORMS.find((p) => p.os === detected.os && p.arch === detected.arch); const primary: PlatformDownload = primaryMatch ?? { - os: 'darwin', arch: 'arm64', label: 'macOS Apple Silicon', filename: 'sam-darwin-arm64', + os: 'darwin', + arch: 'arm64', + label: 'macOS Apple Silicon', + filename: 'sam-darwin-arm64', }; const others = PLATFORMS.filter((p) => p.os !== primary.os || p.arch !== primary.arch); @@ -203,9 +211,7 @@ export function ToolsCli() { {/* ── Install via curl ── */}

Install via curl

-

- One-liner for {primary.label}: -

+

One-liner for {primary.label}:

diff --git a/apps/www/package.json b/apps/www/package.json index 54f1fa6cdf..5f7f85719d 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -10,6 +10,8 @@ "build:docs-mermaid": "pnpm exec esbuild src/scripts/docs-mermaid.ts --bundle --format=esm --platform=browser --target=es2022 --outfile=public/scripts/docs-mermaid.js", "build:tracker": "npx tsx scripts/build-tracker.ts", "test": "vitest run --exclude tests/playwright/**", + "lint": "eslint 'astro.config.ts' 'playwright.config.ts' 'scripts/**/*.ts' 'src/**/*.{ts,astro}' 'tests/**/*.ts' --rule 'simple-import-sort/imports: off' --rule 'simple-import-sort/exports: off'", + "typecheck": "tsx ../../scripts/quality/check-astro-templates.ts", "build": "pnpm build:assets && astro build", "preview": "astro preview", "check:links": "python3 scripts/check-doc-links.py" @@ -21,8 +23,10 @@ "mermaid": "11.14.0" }, "devDependencies": { + "@astrojs/check": "catalog:", + "@playwright/test": "1.62.1", "esbuild": "0.28.1", - "typescript": "catalog:", - "@playwright/test": "1.62.1" + "tsx": "catalog:", + "typescript": "catalog:" } } diff --git a/apps/www/tsconfig.json b/apps/www/tsconfig.json index bcbf8b5090..b294959908 100644 --- a/apps/www/tsconfig.json +++ b/apps/www/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "astro/tsconfigs/strict" + "extends": "astro/tsconfigs/strict", + "exclude": ["dist", "node_modules", "public/scripts/*.js"] } diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..c979f806e5 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,308 @@ +import js from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import eslintPluginAstro from 'eslint-plugin-astro'; +import jsxA11y from 'eslint-plugin-jsx-a11y'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +import samPlugin from './packages/eslint-plugin-sam/src/index.js'; + +const typescriptFiles = ['**/*.{ts,tsx,mts,cts}']; +const astroFiles = ['**/*.astro']; +const samPluginFiles = ['packages/eslint-plugin-sam/**/*.js']; +const samAdvisoryRules = { + 'sam/no-local-record-guard': 'warn', + 'sam/no-unsafe-json-parse-assertion': 'warn', + 'sam/no-unvalidated-request-json': 'warn', +}; + +const upstreamNoUnusedVars = tseslint.plugin.rules['no-unused-vars']; +const noUnusedVarsEslint8Compat = { + ...upstreamNoUnusedVars, + create(context) { + const report = context.report.bind(context); + const compatContext = Object.create(context); + Object.defineProperty(compatContext, 'report', { + value: (descriptor) => { + // typescript-eslint 8 deliberately started reporting runtime values + // referenced only by a type-level `typeof`. Preserve the audited v7 + // behavior during the rule-neutral ESLint 9 host migration. + if (descriptor.messageId !== 'usedOnlyAsType') { + report(descriptor); + } + }, + writable: false, + }); + + return upstreamNoUnusedVars.create(compatContext); + }, +}; + +const typescriptEslintCompatPlugin = { + ...tseslint.plugin, + rules: { + ...tseslint.plugin.rules, + 'no-unused-vars': noUnusedVarsEslint8Compat, + }, +}; + +const withSeverity = (ruleConfig, severity) => + Array.isArray(ruleConfig) ? [severity, ...ruleConfig.slice(1)] : severity; + +// ESLint 9 and typescript-eslint 8 are the supported flat-config host. Keep +// the ESLint 8 / typescript-eslint 7 recommended semantics during this +// foundation migration; the parity evidence for the audited repository must +// change only when a later rule rollout says so explicitly. +const legacyJsRecommendedRules = { + ...js.configs.recommended.rules, + 'no-constant-binary-expression': 'off', + 'no-empty-static-block': 'off', + 'no-extra-semi': 'error', + 'no-inner-declarations': 'error', + 'no-mixed-spaces-and-tabs': 'error', + 'no-new-native-nonconstructor': 'off', + 'no-new-symbol': 'error', + 'no-unused-private-class-members': 'off', +}; + +const legacyTypescriptRecommendedRules = { + ...tseslint.configs.recommended.at(-1).rules, + '@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'always' }], + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unsafe-function-type': 'error', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-var-requires': 'error', + '@typescript-eslint/no-wrapper-object-types': 'error', + '@typescript-eslint/prefer-namespace-keyword': 'off', + 'no-unused-expressions': 'off', +}; + +const onboardingAdvisoryRules = Object.fromEntries( + Object.entries({ + ...legacyJsRecommendedRules, + ...tseslint.configs.eslintRecommended.rules, + ...legacyTypescriptRecommendedRules, + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + disallowTypeAnnotations: false, + fixStyle: 'inline-type-imports', + prefer: 'type-imports', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-var': 'error', + 'simple-import-sort/exports': 'error', + 'simple-import-sort/imports': 'error', + }).map(([name, config]) => [ + name, + config === 'off' || config === 0 ? config : withSeverity(config, 'warn'), + ]) +); + +export default defineConfig([ + { + ignores: [ + '**/coverage/**', + '**/dist/**', + '**/build/**', + '**/node_modules/**', + '**/.turbo/**', + '**/*.cjs', + '**/*.min.js', + ], + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, + { + files: samPluginFiles, + languageOptions: { + ecmaVersion: 'latest', + globals: globals.node, + sourceType: 'module', + }, + rules: legacyJsRecommendedRules, + }, + { + files: typescriptFiles, + rules: legacyJsRecommendedRules, + }, + { + files: typescriptFiles, + languageOptions: { + parser: tseslint.parser, + sourceType: 'module', + }, + plugins: { + '@typescript-eslint': typescriptEslintCompatPlugin, + }, + }, + { + ...tseslint.configs.eslintRecommended, + files: typescriptFiles, + rules: { + ...tseslint.configs.eslintRecommended.rules, + 'no-class-assign': 'error', + 'no-with': 'error', + }, + }, + { + files: typescriptFiles, + languageOptions: { + ecmaVersion: 'latest', + globals: { + ...globals.browser, + ...globals.es2022, + ...globals.node, + }, + parserOptions: { + sourceType: 'module', + }, + }, + plugins: { + 'react-hooks': reactHooks, + sam: samPlugin, + 'simple-import-sort': simpleImportSort, + }, + rules: { + ...legacyTypescriptRecommendedRules, + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + disallowTypeAnnotations: false, + fixStyle: 'inline-type-imports', + prefer: 'type-imports', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'simple-import-sort/exports': 'error', + 'simple-import-sort/imports': 'error', + ...samAdvisoryRules, + }, + }, + ...eslintPluginAstro.configs['flat/base'], + { + files: astroFiles, + languageOptions: { + globals: { + ...globals.browser, + ...globals.es2022, + ...globals.node, + }, + parserOptions: { + parser: tseslint.parser, + }, + }, + plugins: { + '@typescript-eslint': typescriptEslintCompatPlugin, + sam: samPlugin, + 'simple-import-sort': simpleImportSort, + }, + rules: { + ...legacyTypescriptRecommendedRules, + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + disallowTypeAnnotations: false, + fixStyle: 'inline-type-imports', + prefer: 'type-imports', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'simple-import-sort/exports': 'error', + 'simple-import-sort/imports': 'error', + ...samAdvisoryRules, + }, + }, + { + files: ['**/*.tsx'], + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + 'jsx-a11y': jsxA11y, + react, + 'react-hooks': reactHooks, + }, + rules: { + ...react.configs.recommended.rules, + ...jsxA11y.configs.recommended.rules, + 'jsx-a11y/aria-role': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/aria-role'], + 'warn' + ), + 'jsx-a11y/click-events-have-key-events': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/click-events-have-key-events'], + 'warn' + ), + 'jsx-a11y/interactive-supports-focus': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/interactive-supports-focus'], + 'warn' + ), + 'jsx-a11y/label-has-associated-control': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/label-has-associated-control'], + 'warn' + ), + 'jsx-a11y/no-autofocus': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/no-autofocus'], + 'warn' + ), + 'jsx-a11y/no-interactive-element-to-noninteractive-role': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/no-interactive-element-to-noninteractive-role'], + 'warn' + ), + 'jsx-a11y/no-noninteractive-element-interactions': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/no-noninteractive-element-interactions'], + 'warn' + ), + 'jsx-a11y/no-static-element-interactions': withSeverity( + jsxA11y.configs.recommended.rules['jsx-a11y/no-static-element-interactions'], + 'warn' + ), + 'react/jsx-no-constructed-context-values': 'error', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/rules-of-hooks': 'error', + }, + settings: { + react: { + version: 'detect', + }, + }, + }, + { + files: ['apps/api/src/**/*.ts'], + ignores: ['apps/api/src/lib/logger.ts'], + rules: { + 'no-console': 'error', + }, + }, + { + // These five workspaces had no lint path before this rollout. Preserve all + // newly surfaced findings as advisory while fatal parser/config failures + // remain blocking; debt cleanup can promote individual rules deliberately. + files: [ + 'apps/www/**/*.{ts,tsx,mts,cts,astro}', + 'apps/tail-worker/**/*.{ts,tsx,mts,cts}', + 'infra/**/*.{ts,tsx,mts,cts}', + 'packages/cloud-init/**/*.{ts,tsx,mts,cts}', + 'tools/og-image/**/*.{ts,tsx,mts,cts}', + ], + rules: onboardingAdvisoryRules, + }, +]); diff --git a/infra/package.json b/infra/package.json index c0a87cc953..e7f2f92ac6 100644 --- a/infra/package.json +++ b/infra/package.json @@ -7,7 +7,8 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "eslint '*.ts' 'resources/**/*.ts' '__tests__/**/*.ts' --rule 'simple-import-sort/imports: off' --rule 'simple-import-sort/exports: off'" }, "dependencies": { "@pulumi/cloudflare": "6.15.0", diff --git a/package.json b/package.json index 1534cff444..c9f52d6170 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,13 @@ "test:coverage": "turbo run test:coverage", "lint": "turbo run lint", "lint:fix": "turbo run lint -- --fix", + "lint:oxlint": "tsx scripts/quality/run-oxlint-shadow.ts", + "lint:oxlint:sam-shadow": "tsx scripts/quality/run-oxlint-sam-shadow.ts", "typecheck": "turbo run typecheck", "typecheck:ui": "pnpm --filter @simple-agent-manager/ui typecheck", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", - "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", + "format:check": "tsx scripts/quality/check-format.ts", + "format:check:strict": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "deploy": "turbo run deploy", "deploy:staging": "turbo run deploy:staging", "db:migrate": "tsx scripts/deploy/run-migrations.ts --env production", @@ -50,9 +53,15 @@ "quality:observability-noise": "tsx scripts/quality/check-observability-noise.ts", "quality:dependency-governance": "npx vitest run --config scripts/quality/vitest.config.ts scripts/quality/dependency-governance.test.ts", "test:infra": "pnpm --filter @simple-agent-manager/infra test", - "prepare": "husky", "quality:migration-ordering": "tsx scripts/quality/check-migration-ordering.ts", - "quality:repo-visibility": "tsx scripts/quality/check-repo-quality-visibility.ts" + "quality:repo-visibility": "tsx scripts/quality/check-repo-quality-visibility.ts", + "quality:type-boundaries": "tsx scripts/quality/check-type-boundaries.ts", + "quality:runtime-boundary-semantics": "tsx scripts/quality/check-runtime-boundary-semantics.ts", + "quality:direct-dependency-evidence": "tsx scripts/quality/check-direct-dependency-evidence.ts", + "quality:govulncheck-diff": "tsx scripts/quality/check-go-vulnerability-diff.ts", + "quality:gitleaks:current": "tsx scripts/quality/run-gitleaks.ts --mode=current-tree", + "quality:gitleaks:pr": "tsx scripts/quality/run-gitleaks.ts --mode=pr-range", + "check:fast": "pnpm format:check && pnpm lint:oxlint && pnpm lint && pnpm quality:type-boundaries" }, "dependencies": { "@iarna/toml": "2.2.5", @@ -61,17 +70,25 @@ "devDependencies": { "@commitlint/cli": "21.2.0", "@commitlint/config-conventional": "21.2.0", + "@eslint/js": "catalog:", "@types/iarna__toml": "2.0.5", "@types/node": "catalog:", + "@typescript-eslint/eslint-plugin": "catalog:", + "@typescript-eslint/parser": "catalog:", + "eslint": "catalog:", + "eslint-plugin-astro": "catalog:", "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-react": "catalog:", + "eslint-plugin-react-hooks": "catalog:", "eslint-plugin-simple-import-sort": "13.0.0", - "husky": "9.1.7", - "lint-staged": "17.2.0", + "globals": "catalog:", + "oxlint": "catalog:", "prettier": "3.8.3", "ts-morph": "28.0.0", "tsx": "catalog:", "turbo": "2.9.9", "typescript": "catalog:", + "typescript-eslint": "catalog:", "valibot": "catalog:", "vitest": "catalog:", "zod": "catalog:" @@ -80,14 +97,5 @@ "engines": { "node": ">=20.0.0", "pnpm": ">=9.5.0" - }, - "lint-staged": { - "*.{ts,tsx,js,jsx}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,md,yaml,yml}": [ - "prettier --write" - ] } } diff --git a/packages/cloud-init/package.json b/packages/cloud-init/package.json index 6357b85301..3dfdd7f869 100644 --- a/packages/cloud-init/package.json +++ b/packages/cloud-init/package.json @@ -15,7 +15,8 @@ "build": "tsup src/index.ts --format esm --dts", "dev": "tsup src/index.ts --format esm --dts --watch", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --rule 'simple-import-sort/imports: off' --rule 'simple-import-sort/exports: off'" }, "devDependencies": { "tsup": "8.5.1", diff --git a/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md b/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md new file mode 100644 index 0000000000..5f86ed6d14 --- /dev/null +++ b/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md @@ -0,0 +1,7 @@ +# sam/no-local-record-guard + +Flags known local `isRecord` / `isObject` guard definitions that duplicate SAM runtime-validation helpers. + +The matcher is intentionally narrow: it targets local definitions whose body is the familiar `typeof value === 'object' && value !== null` shape, with an optional `!Array.isArray(value)` clause and a TypeScript type-predicate return. + +This rule is advisory and suggestion-only. Replacement requires call-site review because local guard semantics may intentionally differ. diff --git a/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md b/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md new file mode 100644 index 0000000000..c3a8a77fa5 --- /dev/null +++ b/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md @@ -0,0 +1,13 @@ +# sam/no-unsafe-json-parse-assertion + +Flags TypeScript assertions that narrow the result of `JSON.parse(...)` directly to application shapes. + +Allowed: + +```ts +const parsed = JSON.parse(raw) as unknown; +``` + +Disallowed examples include `Record`, `Partial`, concrete object shapes, and nested typed assertions such as `JSON.parse(raw) as unknown as Payload`. + +This rule is advisory and provides suggestions only. Runtime parsing/validation must be chosen by the owning code path. diff --git a/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md b/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md new file mode 100644 index 0000000000..4660a09d27 --- /dev/null +++ b/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md @@ -0,0 +1,7 @@ +# sam/no-unvalidated-request-json + +Flags typed Hono-style `*.req.json()` calls. The type argument is compile-time-only and does not validate request bodies at runtime. + +Use route-level `jsonValidator(schema)` or an established parsing helper before consuming the request body. + +This rule is advisory and provides suggestions only. It intentionally does not auto-fix because inserting validation changes route semantics and error behavior. diff --git a/packages/eslint-plugin-sam/package.json b/packages/eslint-plugin-sam/package.json new file mode 100644 index 0000000000..c5a8dfbd84 --- /dev/null +++ b/packages/eslint-plugin-sam/package.json @@ -0,0 +1,24 @@ +{ + "name": "@simple-agent-manager/eslint-plugin-sam", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Unpublished SAM-specific ESLint rules for deterministic quality gates.", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run", + "lint": "eslint 'src/**/*.js' 'tests/**/*.test.js'", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "eslint": "^9.0.0" + }, + "devDependencies": { + "@typescript-eslint/parser": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/eslint-plugin-sam/rules.manifest.json b/packages/eslint-plugin-sam/rules.manifest.json new file mode 100644 index 0000000000..62b2e82cef --- /dev/null +++ b/packages/eslint-plugin-sam/rules.manifest.json @@ -0,0 +1,75 @@ +{ + "schemaVersion": 1, + "plugin": "@simple-agent-manager/eslint-plugin-sam", + "stage": "advisory", + "gateOwner": "deterministic-runtime-boundary-quality", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "rules": [ + { + "name": "sam/no-unvalidated-request-json", + "owner": "runtime-boundary-quality", + "matcherVersion": "2026-08-09.1", + "advisoryStage": "shadow", + "gateOwner": "quality-program", + "evidenceIncident": "Typed Hono request JSON masks unvalidated request bodies at runtime.", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "addedDate": "2026-08-09", + "reviewDate": "2026-09-09", + "falsePositiveSamples": [ + "Untyped c.req.json() calls are intentionally excluded for this syntax rule.", + "Route handlers already using jsonValidator(schema) are handled by integration wiring, not this isolated syntax matcher." + ], + "expiringExemptions": [ + { + "scope": "existing debt", + "expiresOn": "2026-10-09", + "reason": "Advisory rollout while the deterministic ratchet establishes current baseline ownership." + } + ] + }, + { + "name": "sam/no-unsafe-json-parse-assertion", + "owner": "runtime-boundary-quality", + "matcherVersion": "2026-08-09.1", + "advisoryStage": "shadow", + "gateOwner": "quality-program", + "evidenceIncident": "Type assertions over JSON.parse were repeatedly mistaken for runtime validation.", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "addedDate": "2026-08-09", + "reviewDate": "2026-09-09", + "falsePositiveSamples": [ + "JSON.parse(raw) as unknown is allowed as the neutral parse boundary.", + "Non-JSON.parse assertions are excluded even when their target type is structural." + ], + "expiringExemptions": [ + { + "scope": "Record population", + "expiresOn": "2026-10-09", + "reason": "Kept advisory until discriminating validation-oriented matchers and baselines are integrated." + } + ] + }, + { + "name": "sam/no-local-record-guard", + "owner": "runtime-boundary-quality", + "matcherVersion": "2026-08-09.1", + "advisoryStage": "shadow", + "gateOwner": "quality-program", + "evidenceIncident": "Local record/object guards drift from established runtime-validation helpers.", + "baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "addedDate": "2026-08-09", + "reviewDate": "2026-09-09", + "falsePositiveSamples": [ + "Guards with different names are excluded.", + "Guards with extra semantic checks are excluded because automatic replacement is unsafe." + ], + "expiringExemptions": [ + { + "scope": "existing local guard definitions", + "expiresOn": "2026-10-09", + "reason": "Existing call sites need semantics review before shared-helper migration." + } + ] + } + ] +} diff --git a/packages/eslint-plugin-sam/src/index.js b/packages/eslint-plugin-sam/src/index.js new file mode 100644 index 0000000000..01077c7f5c --- /dev/null +++ b/packages/eslint-plugin-sam/src/index.js @@ -0,0 +1,19 @@ +import noLocalRecordGuard from './rules/no-local-record-guard.js'; +import noUnsafeJsonParseAssertion from './rules/no-unsafe-json-parse-assertion.js'; +import noUnvalidatedRequestJson from './rules/no-unvalidated-request-json.js'; + +const rules = { + 'no-local-record-guard': noLocalRecordGuard, + 'no-unsafe-json-parse-assertion': noUnsafeJsonParseAssertion, + 'no-unvalidated-request-json': noUnvalidatedRequestJson, +}; + +export default { + meta: { + name: '@simple-agent-manager/eslint-plugin-sam', + version: '0.0.0', + }, + rules, +}; + +export { rules }; diff --git a/packages/eslint-plugin-sam/src/rules/ast.js b/packages/eslint-plugin-sam/src/rules/ast.js new file mode 100644 index 0000000000..9f488b8021 --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/ast.js @@ -0,0 +1,31 @@ +export function unwrapChainExpression(node) { + return node?.type === 'ChainExpression' ? node.expression : node; +} + +export function getPropertyName(node) { + if (!node) { + return undefined; + } + + if (node.type === 'Identifier') { + return node.name; + } + + if (node.type === 'PrivateIdentifier') { + return node.name; + } + + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + + return undefined; +} + +export function getCallTypeArguments(node) { + return node.typeArguments ?? node.typeParameters; +} + +export function isIdentifierNamed(node, name) { + return node?.type === 'Identifier' && node.name === name; +} diff --git a/packages/eslint-plugin-sam/src/rules/no-local-record-guard.js b/packages/eslint-plugin-sam/src/rules/no-local-record-guard.js new file mode 100644 index 0000000000..de7f3e57de --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/no-local-record-guard.js @@ -0,0 +1,175 @@ +import { isIdentifierNamed } from './ast.js'; + +const docsUrl = + 'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md'; + +function isRecordGuardName(name) { + return name === 'isRecord' || name === 'isObject'; +} + +function getFunctionName(node) { + if (node.type === 'FunctionDeclaration') { + return node.id?.name; + } + + if ( + node.type === 'VariableDeclarator' && + node.id.type === 'Identifier' && + (node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression') + ) { + return node.id.name; + } + + return undefined; +} + +function getFunctionNode(node) { + if (node.type === 'FunctionDeclaration') { + return node; + } + + if (node.type === 'VariableDeclarator') { + return node.init; + } + + return undefined; +} + +function getReturnExpression(functionNode) { + if (!functionNode || functionNode.body.type !== 'BlockStatement') { + return functionNode?.body; + } + + if (functionNode.body.body.length !== 1) { + return undefined; + } + + const statement = functionNode.body.body[0]; + return statement?.type === 'ReturnStatement' ? statement.argument : undefined; +} + +function hasTypePredicateReturn(functionNode, parameterName) { + const returnType = functionNode.returnType?.typeAnnotation; + if (returnType?.type !== 'TSTypePredicate') { + return false; + } + + return isIdentifierNamed(returnType.parameterName, parameterName); +} + +function flattenLogicalAnd(node) { + if (node?.type === 'LogicalExpression' && node.operator === '&&') { + return [...flattenLogicalAnd(node.left), ...flattenLogicalAnd(node.right)]; + } + + return node ? [node] : []; +} + +function isTypeofObjectCheck(node, parameterName) { + return ( + node.type === 'BinaryExpression' && + (node.operator === '===' || node.operator === '==') && + node.left.type === 'UnaryExpression' && + node.left.operator === 'typeof' && + isIdentifierNamed(node.left.argument, parameterName) && + node.right.type === 'Literal' && + node.right.value === 'object' + ); +} + +function isNotNullCheck(node, parameterName) { + return ( + node.type === 'BinaryExpression' && + (node.operator === '!==' || node.operator === '!=') && + isIdentifierNamed(node.left, parameterName) && + node.right.type === 'Literal' && + node.right.value === null + ); +} + +function isNotArrayCheck(node, parameterName) { + return ( + node.type === 'UnaryExpression' && + node.operator === '!' && + node.argument.type === 'CallExpression' && + node.argument.callee.type === 'MemberExpression' && + node.argument.callee.object.type === 'Identifier' && + node.argument.callee.object.name === 'Array' && + node.argument.callee.property.type === 'Identifier' && + node.argument.callee.property.name === 'isArray' && + node.argument.arguments.length === 1 && + isIdentifierNamed(node.argument.arguments[0], parameterName) + ); +} + +function isKnownLocalRecordGuard(functionNode) { + const parameter = functionNode?.params[0]; + if (!parameter || parameter.type !== 'Identifier') { + return false; + } + + if (!hasTypePredicateReturn(functionNode, parameter.name)) { + return false; + } + + const clauses = flattenLogicalAnd(getReturnExpression(functionNode)); + const hasObject = clauses.some((clause) => isTypeofObjectCheck(clause, parameter.name)); + const hasNotNull = clauses.some((clause) => isNotNullCheck(clause, parameter.name)); + const onlyKnownClauses = clauses.every( + (clause) => + isTypeofObjectCheck(clause, parameter.name) || + isNotNullCheck(clause, parameter.name) || + isNotArrayCheck(clause, parameter.name) + ); + + return clauses.length >= 2 && hasObject && hasNotNull && onlyKnownClauses; +} + +function checkNode(context, node) { + const name = getFunctionName(node); + if (!name || !isRecordGuardName(name)) { + return; + } + + const functionNode = getFunctionNode(node); + if (!isKnownLocalRecordGuard(functionNode)) { + return; + } + + context.report({ + node, + messageId: 'localRecordGuard', + data: { name }, + }); +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Discourage local isRecord/isObject guard definitions that duplicate shared runtime validation.', + recommended: false, + url: docsUrl, + }, + hasSuggestions: false, + messages: { + localRecordGuard: + 'Local {{name}} guard definitions drift from shared runtime-validation helpers. Use the established helper instead.', + }, + schema: [], + }, + create(context) { + return { + FunctionDeclaration(node) { + checkNode(context, node); + }, + VariableDeclarator(node) { + checkNode(context, node); + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js b/packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js new file mode 100644 index 0000000000..7d59a19162 --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/no-unsafe-json-parse-assertion.js @@ -0,0 +1,80 @@ +import { getPropertyName, unwrapChainExpression } from './ast.js'; + +const docsUrl = + 'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-unsafe-json-parse-assertion.md'; + +function isJsonParseCall(node) { + const expression = unwrapChainExpression(node); + if (expression?.type !== 'CallExpression') { + return false; + } + + const callee = unwrapChainExpression(expression.callee); + if (callee?.type !== 'MemberExpression' || callee.computed) { + return false; + } + + return ( + callee.object.type === 'Identifier' && + callee.object.name === 'JSON' && + getPropertyName(callee.property) === 'parse' + ); +} + +function unwrapAssertionExpression(node) { + let current = unwrapChainExpression(node); + while (current?.type === 'TSAsExpression' || current?.type === 'TSSatisfiesExpression') { + current = unwrapChainExpression(current.expression); + } + return current; +} + +function isUnknownAssertion(node) { + return node.typeAnnotation?.type === 'TSUnknownKeyword'; +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: 'Disallow typed assertions over JSON.parse except the neutral unknown boundary.', + recommended: false, + url: docsUrl, + }, + hasSuggestions: true, + messages: { + unsafeAssertion: + 'A TypeScript assertion over JSON.parse is not runtime validation. Only `as unknown` is allowed at the parse boundary.', + parseUnknownThenValidate: + 'Parse as unknown, then validate with a schema or established parsing helper before narrowing.', + }, + schema: [], + }, + create(context) { + return { + TSAsExpression(node) { + if (isUnknownAssertion(node)) { + return; + } + + if (!isJsonParseCall(unwrapAssertionExpression(node.expression))) { + return; + } + + context.report({ + node, + messageId: 'unsafeAssertion', + suggest: [ + { + messageId: 'parseUnknownThenValidate', + fix: (fixer) => fixer.replaceText(node.typeAnnotation, 'unknown'), + }, + ], + }); + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js b/packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js new file mode 100644 index 0000000000..54fc38b3bb --- /dev/null +++ b/packages/eslint-plugin-sam/src/rules/no-unvalidated-request-json.js @@ -0,0 +1,65 @@ +import { getCallTypeArguments, getPropertyName, unwrapChainExpression } from './ast.js'; + +const docsUrl = + 'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-unvalidated-request-json.md'; + +function isHonoRequestJsonCall(node) { + const callee = unwrapChainExpression(node.callee); + if (callee?.type !== 'MemberExpression') { + return false; + } + + if (callee.computed || getPropertyName(callee.property) !== 'json') { + return false; + } + + const reqMember = unwrapChainExpression(callee.object); + if (reqMember?.type !== 'MemberExpression') { + return false; + } + + return !reqMember.computed && getPropertyName(reqMember.property) === 'req'; +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: 'Disallow typed Hono request JSON reads that bypass runtime validation.', + recommended: false, + url: docsUrl, + }, + hasSuggestions: true, + messages: { + unvalidatedRequestJson: + 'Typed Hono request JSON is not validation. Validate with jsonValidator(schema) or an established parsing helper before using the body.', + useRuntimeValidator: + 'Use jsonValidator(schema) on the route or parse the unknown body with an established runtime-validation helper.', + }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + const typeArguments = getCallTypeArguments(node); + if (!typeArguments || !isHonoRequestJsonCall(node)) { + return; + } + + context.report({ + node, + messageId: 'unvalidatedRequestJson', + suggest: [ + { + messageId: 'useRuntimeValidator', + fix: (fixer) => fixer.replaceText(typeArguments, ''), + }, + ], + }); + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts b/packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts new file mode 100644 index 0000000000..ba10547f56 --- /dev/null +++ b/packages/eslint-plugin-sam/tests/fixtures/no-local-record-guard.ts @@ -0,0 +1,30 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export const isObject = (value: unknown): value is object => + typeof value === 'object' && value !== null; + +export function isRuntimeRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isRecordWithSemantics(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value).length > 0 + ); +} + +export function isRecordBoolean(value: unknown): boolean { + return typeof value === 'object' && value !== null; +} + +export function isRecordAlias(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +const stringNearMiss = 'function isRecord(value: unknown): value is Record {}'; +// function isObject(value: unknown): value is object {} diff --git a/packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts b/packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts new file mode 100644 index 0000000000..ab39853e65 --- /dev/null +++ b/packages/eslint-plugin-sam/tests/fixtures/no-unsafe-json-parse-assertion.ts @@ -0,0 +1,37 @@ +type Payload = { message: string }; + +export function currentRecordShape(raw: string) { + return JSON.parse(raw) as Record; +} + +export function currentPartialShape(raw: string) { + return JSON.parse(raw) as Partial; +} + +export function currentConcreteShape(raw: string) { + return JSON.parse(raw) as { error?: string; cause?: string }; +} + +export function nestedTypedAssertion(raw: string) { + return JSON.parse(raw) as unknown as Payload; +} + +export function safeUnknown(raw: string) { + return JSON.parse(raw) as unknown; +} + +export function safeValidated(raw: string, parsePayload: (value: unknown) => Payload) { + const parsed = JSON.parse(raw) as unknown; + return parsePayload(parsed); +} + +export function aliasNearMiss(raw: string, parse: (value: string) => unknown) { + return parse(raw) as Payload; +} + +export function computedNearMiss(raw: string) { + return JSON['parse'](raw) as Payload; +} + +const stringNearMiss = 'JSON.parse(raw) as Payload'; +// JSON.parse(raw) as Payload diff --git a/packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts b/packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts new file mode 100644 index 0000000000..8359ee9b1d --- /dev/null +++ b/packages/eslint-plugin-sam/tests/fixtures/no-unvalidated-request-json.ts @@ -0,0 +1,38 @@ +import { jsonValidator } from '../../src/schemas/_validator'; + +type CreatePolicyRequest = { name: string }; + +export async function currentTruePositive(c: { req: { json(): Promise } }) { + const body = await c.req.json(); + return body.name; +} + +export async function multilineTruePositive(context: { req: { json(): Promise } }) { + const body = await context.req.json<{ + defaultModel: string; + }>(); + return body.defaultModel; +} + +export async function safeUntyped(c: { req: { json(): Promise } }) { + return c.req.json(); +} + +export async function safeValidatorRoute(app: { + post(path: string, validator: unknown, handler: unknown): void; +}) { + app.post( + '/ok', + jsonValidator('json', {}), + async (c: { req: { valid(kind: 'json'): CreatePolicyRequest } }) => { + return c.req.valid('json'); + } + ); +} + +export async function aliasNearMiss(c: { request: { json(): Promise } }) { + return c.request.json(); +} + +const stringNearMiss = 'await c.req.json()'; +// await c.req.json(); diff --git a/packages/eslint-plugin-sam/tests/rules.test.js b/packages/eslint-plugin-sam/tests/rules.test.js new file mode 100644 index 0000000000..19c0c2aa11 --- /dev/null +++ b/packages/eslint-plugin-sam/tests/rules.test.js @@ -0,0 +1,133 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RuleTester } from 'eslint'; +import parser from '@typescript-eslint/parser'; +import { describe, it } from 'vitest'; + +import noLocalRecordGuard from '../src/rules/no-local-record-guard.js'; +import noUnsafeJsonParseAssertion from '../src/rules/no-unsafe-json-parse-assertion.js'; +import noUnvalidatedRequestJson from '../src/rules/no-unvalidated-request-json.js'; + +const dirname = fileURLToPath(new URL('.', import.meta.url)); + +function fixture(name) { + return readFileSync(join(dirname, 'fixtures', name), 'utf8'); +} + +const unvalidatedRequestJsonFixture = fixture('no-unvalidated-request-json.ts'); +const unsafeJsonParseAssertionFixture = fixture('no-unsafe-json-parse-assertion.ts'); +const localRecordGuardFixture = fixture('no-local-record-guard.ts'); + +function expectedError(messageId, suggestionMessageId, output) { + if (!suggestionMessageId || !output) return { messageId }; + return { messageId, suggestions: [{ messageId: suggestionMessageId, output }] }; +} + +const languageOptions = { + ecmaVersion: 2022, + sourceType: 'module', + parser, +}; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ languageOptions }); + +ruleTester.run('no-unvalidated-request-json', noUnvalidatedRequestJson, { + valid: [ + 'const body = await c.req.json();', + 'const body = await c.request.json();', + "const body = await c.req['json']();", + "app.post('/ok', jsonValidator('json', schema), (c) => c.req.valid('json'));", + "const text = 'await c.req.json()'; // await c.req.json()", + ], + invalid: [ + { + code: unvalidatedRequestJsonFixture, + errors: [ + expectedError( + 'unvalidatedRequestJson', + 'useRuntimeValidator', + unvalidatedRequestJsonFixture.replace( + 'c.req.json()', + 'c.req.json()' + ) + ), + expectedError( + 'unvalidatedRequestJson', + 'useRuntimeValidator', + unvalidatedRequestJsonFixture.replace( + '.json<{\n defaultModel: string;\n }>()', + '.json()' + ) + ), + ], + }, + ], +}); + +ruleTester.run('no-unsafe-json-parse-assertion', noUnsafeJsonParseAssertion, { + valid: [ + 'const parsed = JSON.parse(raw) as unknown;', + 'const parsed = parse(raw) as Payload;', + "const parsed = JSON['parse'](raw) as Payload;", + "const text = 'JSON.parse(raw) as Payload'; // JSON.parse(raw) as Payload", + ], + invalid: [ + { + code: unsafeJsonParseAssertionFixture, + errors: [ + expectedError( + 'unsafeAssertion', + 'parseUnknownThenValidate', + unsafeJsonParseAssertionFixture.replace( + 'JSON.parse(raw) as Record', + 'JSON.parse(raw) as unknown' + ) + ), + expectedError( + 'unsafeAssertion', + 'parseUnknownThenValidate', + unsafeJsonParseAssertionFixture.replace( + 'JSON.parse(raw) as Partial', + 'JSON.parse(raw) as unknown' + ) + ), + expectedError( + 'unsafeAssertion', + 'parseUnknownThenValidate', + unsafeJsonParseAssertionFixture.replace( + 'JSON.parse(raw) as { error?: string; cause?: string }', + 'JSON.parse(raw) as unknown' + ) + ), + expectedError( + 'unsafeAssertion', + 'parseUnknownThenValidate', + unsafeJsonParseAssertionFixture.replace( + 'JSON.parse(raw) as unknown as Payload', + 'JSON.parse(raw) as unknown as unknown' + ) + ), + ], + }, + ], +}); + +ruleTester.run('no-local-record-guard', noLocalRecordGuard, { + valid: [ + 'function isRuntimeRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; }', + 'function isRecord(value: unknown): boolean { return typeof value === "object" && value !== null; }', + 'function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && Object.keys(value).length > 0; }', + "const text = 'function isRecord(value: unknown): value is Record {}';", + ], + invalid: [ + { + code: localRecordGuardFixture, + errors: [expectedError('localRecordGuard'), expectedError('localRecordGuard')], + }, + ], +}); diff --git a/packages/eslint-plugin-sam/tsconfig.json b/packages/eslint-plugin-sam/tsconfig.json new file mode 100644 index 0000000000..4949a620c4 --- /dev/null +++ b/packages/eslint-plugin-sam/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitAny": false, + "noEmit": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.js", "tests/**/*.test.js"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a70a642ad9..f78a152b3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,15 @@ settings: catalogs: default: + '@astrojs/check': + specifier: 0.9.10 + version: 0.9.10 '@cloudflare/workers-types': specifier: 5.20260707.1 version: 5.20260707.1 + '@eslint/js': + specifier: 9.39.5 + version: 9.39.5 '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 @@ -28,8 +34,8 @@ catalogs: specifier: 19.2.3 version: 19.2.3 '@typescript-eslint/eslint-plugin': - specifier: 7.18.0 - version: 7.18.0 + specifier: 8.65.0 + version: 8.65.0 '@typescript-eslint/parser': specifier: 8.65.0 version: 8.65.0 @@ -49,17 +55,32 @@ catalogs: specifier: 1.6.11 version: 1.6.11 eslint: - specifier: 8.57.1 - version: 8.57.1 + specifier: 9.39.5 + version: 9.39.5 + eslint-plugin-astro: + specifier: 1.7.0 + version: 1.7.0 + eslint-plugin-react: + specifier: 7.37.5 + version: 7.37.5 + eslint-plugin-react-hooks: + specifier: 7.1.1 + version: 7.1.1 execa: specifier: 9.6.1 version: 9.6.1 + globals: + specifier: 17.9.0 + version: 17.9.0 jsdom: specifier: 29.1.1 version: 29.1.1 lucide-react: specifier: 0.460.0 version: 0.460.0 + oxlint: + specifier: 1.77.0 + version: 1.77.0 prism-react-renderer: specifier: 2.4.1 version: 2.4.1 @@ -87,6 +108,9 @@ catalogs: typescript: specifier: 5.9.3 version: 5.9.3 + typescript-eslint: + specifier: 8.65.0 + version: 8.65.0 valibot: specifier: 1.3.1 version: 1.3.1 @@ -120,24 +144,45 @@ importers: '@commitlint/config-conventional': specifier: 21.2.0 version: 21.2.0 + '@eslint/js': + specifier: 'catalog:' + version: 9.39.5 '@types/iarna__toml': specifier: 2.0.5 version: 2.0.5 '@types/node': specifier: 'catalog:' version: 22.19.7 + '@typescript-eslint/eslint-plugin': + specifier: 'catalog:' + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 'catalog:' + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: 'catalog:' + version: 9.39.5(jiti@2.6.1) + eslint-plugin-astro: + specifier: 'catalog:' + version: 1.7.0(eslint@9.39.5(jiti@2.6.1)) eslint-plugin-jsx-a11y: specifier: 6.10.2 - version: 6.10.2(eslint@8.57.1) + version: 6.10.2(eslint@9.39.5(jiti@2.6.1)) + eslint-plugin-react: + specifier: 'catalog:' + version: 7.37.5(eslint@9.39.5(jiti@2.6.1)) + eslint-plugin-react-hooks: + specifier: 'catalog:' + version: 7.1.1(eslint@9.39.5(jiti@2.6.1)) eslint-plugin-simple-import-sort: specifier: 13.0.0 - version: 13.0.0(eslint@8.57.1) - husky: - specifier: 9.1.7 - version: 9.1.7 - lint-staged: - specifier: 17.2.0 - version: 17.2.0 + version: 13.0.0(eslint@9.39.5(jiti@2.6.1)) + globals: + specifier: 'catalog:' + version: 17.9.0 + oxlint: + specifier: 'catalog:' + version: 1.77.0 prettier: specifier: 3.8.3 version: 3.8.3 @@ -153,6 +198,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) valibot: specifier: 'catalog:' version: 1.3.1(typescript@5.9.3) @@ -246,10 +294,10 @@ importers: version: 8.0.0 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -261,7 +309,7 @@ importers: version: 0.26.2 eslint: specifier: 'catalog:' - version: 8.57.1 + version: 9.39.5(jiti@2.6.1) execa: specifier: 'catalog:' version: 9.6.1 @@ -409,10 +457,10 @@ importers: version: 3.0.6 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': specifier: 'catalog:' version: 5.2.0(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -421,13 +469,13 @@ importers: version: 4.1.7(vitest@4.1.5) eslint: specifier: 'catalog:' - version: 8.57.1 + version: 9.39.5(jiti@2.6.1) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@8.57.1) + version: 7.37.5(eslint@9.39.5(jiti@2.6.1)) eslint-plugin-react-hooks: - specifier: 4.6.2 - version: 4.6.2(eslint@8.57.1) + specifier: 'catalog:' + version: 7.1.1(eslint@9.39.5(jiti@2.6.1)) jsdom: specifier: 'catalog:' version: 29.1.1(@noble/hashes@2.0.1) @@ -459,12 +507,18 @@ importers: specifier: 11.14.0 version: 11.14.0 devDependencies: + '@astrojs/check': + specifier: 'catalog:' + version: 0.9.10(prettier@3.8.3)(typescript@5.9.3) '@playwright/test': specifier: 1.62.1 version: 1.62.1 esbuild: specifier: 0.28.1 version: 0.28.1 + tsx: + specifier: 'catalog:' + version: 4.23.1 typescript: specifier: 'catalog:' version: 5.9.3 @@ -532,16 +586,16 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) eslint: specifier: 'catalog:' - version: 8.57.1 + version: 9.39.5(jiti@2.6.1) jsdom: specifier: 'catalog:' version: 29.1.1(@noble/hashes@2.0.1) @@ -573,6 +627,21 @@ importers: specifier: 2.9.0 version: 2.9.0 + packages/eslint-plugin-sam: + devDependencies: + '@typescript-eslint/parser': + specifier: 'catalog:' + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: 'catalog:' + version: 9.39.5(jiti@2.6.1) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/providers: dependencies: '@simple-agent-manager/shared': @@ -584,16 +653,16 @@ importers: devDependencies: '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) eslint: specifier: 'catalog:' - version: 8.57.1 + version: 9.39.5(jiti@2.6.1) typescript: specifier: 'catalog:' version: 5.9.3 @@ -618,16 +687,16 @@ importers: version: 5.6.0(tinybench@2.9.0)(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.5) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) eslint: specifier: 'catalog:' - version: 8.57.1 + version: 9.39.5(jiti@2.6.1) typescript: specifier: 'catalog:' version: 5.9.3 @@ -658,16 +727,16 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@8.57.1)(typescript@5.9.3) + version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) eslint: specifier: 'catalog:' - version: 8.57.1 + version: 9.39.5(jiti@2.6.1) jsdom: specifier: 'catalog:' version: 29.1.1(@noble/hashes@2.0.1) @@ -834,12 +903,36 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/check@0.9.10': + resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/compiler@3.0.1': + resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==} + '@astrojs/compiler@4.0.0': resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==} '@astrojs/internal-helpers@0.10.0': resolution: {integrity: sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==} + '@astrojs/language-server@2.16.13': + resolution: {integrity: sha512-ekOa+CYprEq5n4EJC1qTIAhLk49HZIUQuFwrEuF+3JK/pdMaYnWoREFUI2A0KEPOJiFA2kamBzKzbYljDvUxLg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + '@astrojs/markdown-remark@7.2.0': resolution: {integrity: sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==} @@ -873,6 +966,9 @@ packages: resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + '@aws-sdk/checksums@3.1000.26': resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==} engines: {node: '>=20.0.0'} @@ -1447,6 +1543,27 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -1479,12 +1596,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -1509,12 +1620,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} @@ -1539,12 +1644,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} @@ -1569,12 +1668,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} @@ -1599,12 +1692,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} @@ -1629,12 +1716,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} @@ -1659,12 +1740,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} @@ -1689,12 +1764,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} @@ -1719,12 +1788,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} @@ -1749,12 +1812,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} @@ -1779,12 +1836,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} @@ -1809,12 +1860,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} @@ -1839,12 +1884,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} @@ -1869,12 +1908,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} @@ -1899,12 +1932,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} @@ -1929,12 +1956,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} @@ -1959,12 +1980,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -1977,12 +1992,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} @@ -2007,12 +2016,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -2025,12 +2028,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} @@ -2055,12 +2052,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -2073,12 +2064,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} @@ -2103,12 +2088,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} @@ -2133,12 +2112,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} @@ -2163,12 +2136,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} @@ -2193,12 +2160,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -2215,13 +2176,33 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} @@ -2265,18 +2246,25 @@ packages: hono: '>=3.9.0' valibot: ^1.0.0 || ^1.0.0-beta.4 || ^1.0.0-rc - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} @@ -2824,6 +2812,120 @@ packages: '@oxc-project/types@0.138.0': resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxlint/binding-android-arm-eabi@1.77.0': + resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.77.0': + resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.77.0': + resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.77.0': + resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.77.0': + resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.77.0': + resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.77.0': + resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.77.0': + resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.77.0': + resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.77.0': + resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.77.0': + resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.77.0': + resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.77.0': + resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.77.0': + resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.77.0': + resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pagefind/darwin-arm64@1.5.2': resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} cpu: [arm64] @@ -2862,6 +2964,10 @@ packages: cpu: [x64] os: [win32] + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -3879,16 +3985,13 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/parser@8.65.0': resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} @@ -3903,10 +4006,6 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/scope-manager@8.65.0': resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3917,32 +4016,26 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.65.0': resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.65.0': resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} @@ -3950,15 +4043,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@8.65.0': resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} @@ -4034,6 +4124,32 @@ packages: '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} @@ -4112,6 +4228,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -4120,11 +4244,13 @@ packages: ajv: optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -4190,10 +4316,6 @@ packages: array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -4231,6 +4353,10 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + astro-eslint-parser@1.4.0: + resolution: {integrity: sha512-+QDcgc7e+au6EZ0YjMmRRjNoQo5bDMlaR45aWDoFsuxQTCM9qmCHRoiKJPELgckJ8Wmr7vcfpa9eCDHBFh6G4w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + astro-expressive-code@0.43.1: resolution: {integrity: sha512-xddgwQxFRwpnnAnU7kSfrO82SsOAq7sQrYpXxVcrN9k/0aqNlTH2+mLrOMm1wXm6jdFKepst3hd8/qWojwuunw==} peerDependencies: @@ -4241,6 +4367,12 @@ packages: engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true + astrojs-compiler-sync@1.1.1: + resolution: {integrity: sha512-0mKvB9sDQRIZPsEJadw6OaFbGJ92cJPPR++ICca9XEyiUAZqgVuk25jNmzHPT0KF80rI94trSZrUR5iHFXGGOQ==} + engines: {node: ^18.18.0 || >=20.9.0} + peerDependencies: + '@astrojs/compiler': '>=0.27.0' + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -4410,9 +4542,6 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -5067,10 +5196,6 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - direction@2.0.1: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true @@ -5079,10 +5204,6 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -5219,6 +5340,9 @@ packages: electron-to-chromium@1.5.278: resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + emoji-regex-xs@2.0.1: resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} engines: {node: '>=10.0.0'} @@ -5254,6 +5378,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -5335,11 +5463,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -5360,17 +5483,29 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-compat-utils@0.6.5: + resolution: {integrity: sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-plugin-astro@1.7.0: + resolution: {integrity: sha512-89xpAn528UKCdmyysbg0AHHqi6sqcK89wXnJIpu4F0mFBN03zATEBNK7pRtMfl6gwtMOm5ECXs+Wz5qDHhwTFw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.57.0' + eslint-plugin-jsx-a11y@6.10.2: resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} engines: {node: '>=4.0'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-react-hooks@4.6.2: - resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} - engines: {node: '>=10'} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} @@ -5383,27 +5518,35 @@ packages: peerDependencies: eslint: '>=5.0.0' - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} @@ -5543,9 +5686,6 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -5571,9 +5711,9 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -5605,9 +5745,9 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -5659,9 +5799,6 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5747,26 +5884,26 @@ packages: resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.9.0: + resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} + engines: {node: '>=18'} globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - google-protobuf@3.21.4: resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} @@ -5781,9 +5918,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - graphlib@2.1.8: resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} @@ -5888,6 +6022,12 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hex-rgb@4.3.0: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} @@ -5969,11 +6109,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - i18next@26.3.3: resolution: {integrity: sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==} peerDependencies: @@ -6027,10 +6162,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -6180,10 +6311,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -6301,14 +6428,14 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -6360,6 +6487,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -6504,11 +6634,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@17.2.0: - resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} - engines: {node: '>=22.22.1'} - hasBin: true - load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6871,9 +6996,8 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -6932,6 +7056,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -6940,11 +7067,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.17: resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7126,6 +7248,19 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxlint@1.77.0: + resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -7237,10 +7372,6 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -7265,10 +7396,6 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -7294,10 +7421,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -7673,6 +7796,12 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7734,11 +7863,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -7941,10 +8065,6 @@ packages: engines: {node: '>=20.19.5', npm: '>=10.8.2'} hasBin: true - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -8022,10 +8142,6 @@ packages: stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -8132,6 +8248,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + tailwindcss@4.2.1: resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} @@ -8162,9 +8282,6 @@ packages: engines: {node: '>=10'} hasBin: true - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -8265,12 +8382,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -8329,10 +8440,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -8357,6 +8464,19 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -8704,6 +8824,75 @@ packages: jsdom: optional: true + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -8721,6 +8910,9 @@ packages: resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} hasBin: true + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -8885,6 +9077,15 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -8943,6 +9144,12 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -9077,6 +9284,21 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/check@0.9.10(prettier@3.8.3)(typescript@5.9.3)': + dependencies: + '@astrojs/language-server': 2.16.13(prettier@3.8.3)(typescript@5.9.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 5.9.3 + yargs: 18.0.0 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/compiler@3.0.1': {} + '@astrojs/compiler@4.0.0': {} '@astrojs/internal-helpers@0.10.0': @@ -9090,6 +9312,31 @@ snapshots: smol-toml: 1.6.0 unified: 11.0.5 + '@astrojs/language-server@2.16.13(prettier@3.8.3)(typescript@5.9.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.4 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28)(prettier@3.8.3) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.8.3 + transitivePeerDependencies: + - typescript + '@astrojs/markdown-remark@7.2.0': dependencies: '@astrojs/internal-helpers': 0.10.0 @@ -9185,6 +9432,10 @@ snapshots: is-wsl: 3.1.1 which-pm-runs: 1.1.0 + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.0 + '@aws-sdk/checksums@3.1000.26': dependencies: '@aws-sdk/core': 3.977.6 @@ -9455,7 +9706,6 @@ snapshots: '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 - optional: true '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: @@ -9501,7 +9751,6 @@ snapshots: dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - optional: true '@bcoe/v8-coverage@1.0.2': {} @@ -9867,6 +10116,29 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -9904,9 +10176,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/aix-ppc64@0.28.0': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true @@ -9919,9 +10188,6 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm64@0.28.0': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true @@ -9934,9 +10200,6 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-arm@0.28.0': - optional: true - '@esbuild/android-arm@0.28.1': optional: true @@ -9949,9 +10212,6 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/android-x64@0.28.0': - optional: true - '@esbuild/android-x64@0.28.1': optional: true @@ -9964,9 +10224,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.28.0': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true @@ -9979,9 +10236,6 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/darwin-x64@0.28.0': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true @@ -9994,9 +10248,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.28.0': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true @@ -10009,9 +10260,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.28.0': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true @@ -10024,9 +10272,6 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm64@0.28.0': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true @@ -10039,9 +10284,6 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-arm@0.28.0': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true @@ -10054,9 +10296,6 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-ia32@0.28.0': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true @@ -10069,9 +10308,6 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-loong64@0.28.0': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true @@ -10084,9 +10320,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-mips64el@0.28.0': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true @@ -10099,9 +10332,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-ppc64@0.28.0': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true @@ -10114,9 +10344,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.28.0': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true @@ -10129,9 +10356,6 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-s390x@0.28.0': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true @@ -10144,18 +10368,12 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/linux-x64@0.28.0': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.28.0': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true @@ -10168,18 +10386,12 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.28.0': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.28.0': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true @@ -10192,18 +10404,12 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.28.0': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.28.0': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true @@ -10216,9 +10422,6 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/sunos-x64@0.28.0': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true @@ -10231,9 +10434,6 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-arm64@0.28.0': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true @@ -10246,9 +10446,6 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-ia32@0.28.0': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true @@ -10261,34 +10458,54 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@esbuild/win32-x64@0.28.0': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.6.1))': dependencies: - eslint: 8.57.1 + eslint: 9.39.5(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': dependencies: - ajv: 6.12.6 + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 + espree: 10.4.0 + globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 + js-yaml: 4.3.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': optionalDependencies: @@ -10340,17 +10557,21 @@ snapshots: hono: 4.12.31 valibot: 1.3.1(typescript@5.9.3) - '@humanwhocodes/config-array@0.13.0': + '@humanfs/core@0.19.2': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.4.3': {} '@iarna/toml@2.2.5': {} @@ -10703,8 +10924,8 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.31) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 @@ -10951,6 +11172,63 @@ snapshots: '@oxc-project/types@0.138.0': {} + '@oxlint/binding-android-arm-eabi@1.77.0': + optional: true + + '@oxlint/binding-android-arm64@1.77.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.77.0': + optional: true + + '@oxlint/binding-darwin-x64@1.77.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.77.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.77.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.77.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.77.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.77.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.77.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.77.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.77.0': + optional: true + '@pagefind/darwin-arm64@1.5.2': optional: true @@ -10974,6 +11252,8 @@ snapshots: '@pagefind/windows-x64@1.5.2': optional: true + '@pkgr/core@0.3.6': {} + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -11909,50 +12189,43 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 9.39.5(jiti@2.6.1) + ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 - eslint: 8.57.1 + eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) + '@typescript-eslint/types': 8.66.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/scope-manager@8.65.0': dependencies: '@typescript-eslint/types': 8.65.0 @@ -11962,37 +12235,26 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@7.18.0': {} - - '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.8.3 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + eslint: 9.39.5(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) @@ -12008,21 +12270,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) - eslint: 8.57.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - - '@typescript-eslint/visitor-keys@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - eslint-visitor-keys: 3.4.3 '@typescript-eslint/visitor-keys@8.65.0': dependencies: @@ -12154,6 +12411,56 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@volar/kit@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 5.9.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + '@workflow/serde@4.1.0': {} '@xterm/addon-attach@0.12.0': {} @@ -12207,20 +12514,19 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn@8.16.0: {} acorn@8.17.0: {} - acorn@8.18.0: - optional: true + acorn@8.18.0: {} agent-base@6.0.2: dependencies: @@ -12237,24 +12543,25 @@ snapshots: '@ai-sdk/provider-utils': 5.0.2(zod@4.3.6) zod: 4.3.6 - ajv-formats@3.0.1(ajv@8.17.1): + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 - ajv@6.12.6: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -12317,8 +12624,6 @@ snapshots: array-iterate@2.0.1: {} - array-union@2.1.0: {} - array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -12379,6 +12684,23 @@ snapshots: astring@1.9.0: {} + astro-eslint-parser@1.4.0: + dependencies: + '@astrojs/compiler': 3.0.1 + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + astrojs-compiler-sync: 1.1.1(@astrojs/compiler@3.0.1) + debug: 4.4.3 + entities: 7.0.1 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + fast-glob: 3.3.3 + is-glob: 4.0.3 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + astro-expressive-code@0.43.1(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) @@ -12477,6 +12799,11 @@ snapshots: - uploadthing - yaml + astrojs-compiler-sync@1.1.1(@astrojs/compiler@3.0.1): + dependencies: + '@astrojs/compiler': 3.0.1 + synckit: 0.11.13 + async-function@1.0.0: {} async-lock@1.4.1: {} @@ -12698,10 +13025,6 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -13344,20 +13667,12 @@ snapshots: diff@9.0.0: {} - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - direction@2.0.1: {} doctrine@2.1.0: dependencies: esutils: 2.0.3 - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -13415,6 +13730,11 @@ snapshots: electron-to-chromium@1.5.278: {} + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + emoji-regex-xs@2.0.1: {} emoji-regex@10.6.0: {} @@ -13443,6 +13763,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + entities@8.0.0: {} env-paths@2.2.1: {} @@ -13570,7 +13892,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.17.0 + acorn: 8.18.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -13661,35 +13983,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -13727,7 +14020,26 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): + eslint-compat-utils@0.6.5(eslint@9.39.5(jiti@2.6.1)): + dependencies: + eslint: 9.39.5(jiti@2.6.1) + semver: 7.8.5 + + eslint-plugin-astro@1.7.0(eslint@9.39.5(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.6.1)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.65.0 + astro-eslint-parser: 1.4.0 + eslint: 9.39.5(jiti@2.6.1) + eslint-compat-utils: 0.6.5(eslint@9.39.5(jiti@2.6.1)) + globals: 16.5.0 + postcss: 8.5.16 + postcss-selector-parser: 7.1.1 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -13737,7 +14049,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 8.57.1 + eslint: 9.39.5(jiti@2.6.1) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -13746,11 +14058,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.6.1)): dependencies: - eslint: 8.57.1 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.8 + eslint: 9.39.5(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color - eslint-plugin-react@7.37.5(eslint@8.57.1): + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -13758,7 +14077,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 8.57.1 + eslint: 9.39.5(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -13772,67 +14091,67 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-simple-import-sort@13.0.0(eslint@8.57.1): + eslint-plugin-simple-import-sort@13.0.0(eslint@9.39.5(jiti@2.6.1)): dependencies: - eslint: 8.57.1 + eslint: 9.39.5(jiti@2.6.1) - eslint-scope@7.2.2: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} - eslint@8.57.1: + eslint@9.39.5(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 - ajv: 6.12.6 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 - doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 3.4.3 + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -14043,8 +14362,6 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} - fast-uri@3.1.2: {} fast-wrap-ansi@0.2.2: @@ -14069,9 +14386,9 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 file-uri-to-path@1.0.0: {} @@ -14120,11 +14437,10 @@ snapshots: mlly: 1.8.2 rollup: 4.56.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 - rimraf: 3.0.2 flatted@3.3.3: {} @@ -14164,8 +14480,6 @@ snapshots: dependencies: minipass: 7.1.2 - fs.realpath@1.0.0: {} - fsevents@2.3.2: optional: true @@ -14254,37 +14568,21 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - global-directory@5.0.0: dependencies: ini: 6.0.0 - globals@13.24.0: - dependencies: - type-fest: 0.20.2 + globals@14.0.0: {} + + globals@16.5.0: {} + + globals@17.9.0: {} globalthis@1.0.4: dependencies: define-properties: 1.2.1 gopd: 1.2.0 - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - google-protobuf@3.21.4: {} gopd@1.2.0: {} @@ -14305,8 +14603,6 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - graphlib@2.1.8: dependencies: lodash: 4.17.23 @@ -14547,6 +14843,12 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + hex-rgb@4.3.0: {} hono-openapi@1.3.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.12.31)(openapi-types@12.1.3): @@ -14624,8 +14926,6 @@ snapshots: human-signals@8.0.1: {} - husky@9.1.7: {} - i18next@26.3.3(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -14670,11 +14970,6 @@ snapshots: indent-string@4.0.0: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ini@1.3.8: {} @@ -14799,8 +15094,6 @@ snapshots: is-number@7.0.0: {} - is-path-inside@3.0.3: {} - is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -14917,11 +15210,11 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 - js-yaml@4.2.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -14975,6 +15268,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@2.3.1: {} + jsonc-parser@3.3.1: {} jsonparse@1.3.1: {} @@ -15090,14 +15385,6 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@17.2.0: - dependencies: - picomatch: 4.0.5 - string-argv: 0.3.2 - tinyexec: 1.2.4 - optionalDependencies: - yaml: 2.9.0 - load-tsconfig@0.2.5: {} locate-path@6.0.0: @@ -15545,8 +15832,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -15755,9 +16042,9 @@ snapshots: dependencies: brace-expansion: 1.1.12 - minimatch@9.0.5: + minimatch@3.1.5: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 1.1.12 minimist@1.2.8: {} @@ -15816,6 +16103,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -15824,8 +16113,6 @@ snapshots: nanoid@3.3.15: {} - nanoid@3.3.16: {} - nanoid@3.3.17: {} nanostores@1.3.0: {} @@ -16028,6 +16315,28 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxlint@1.77.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.77.0 + '@oxlint/binding-android-arm64': 1.77.0 + '@oxlint/binding-darwin-arm64': 1.77.0 + '@oxlint/binding-darwin-x64': 1.77.0 + '@oxlint/binding-freebsd-x64': 1.77.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 + '@oxlint/binding-linux-arm-musleabihf': 1.77.0 + '@oxlint/binding-linux-arm64-gnu': 1.77.0 + '@oxlint/binding-linux-arm64-musl': 1.77.0 + '@oxlint/binding-linux-ppc64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-musl': 1.77.0 + '@oxlint/binding-linux-s390x-gnu': 1.77.0 + '@oxlint/binding-linux-x64-gnu': 1.77.0 + '@oxlint/binding-linux-x64-musl': 1.77.0 + '@oxlint/binding-openharmony-arm64': 1.77.0 + '@oxlint/binding-win32-arm64-msvc': 1.77.0 + '@oxlint/binding-win32-ia32-msvc': 1.77.0 + '@oxlint/binding-win32-x64-msvc': 1.77.0 + p-cancelable@2.1.1: {} p-limit@3.1.0: @@ -16166,8 +16475,6 @@ snapshots: path-exists@5.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -16185,8 +16492,6 @@ snapshots: path-to-regexp@8.3.0: {} - path-type@4.0.0: {} - pathe@2.0.3: {} piccolore@0.1.3: {} @@ -16201,8 +16506,6 @@ snapshots: picomatch@4.0.4: {} - picomatch@4.0.5: {} - pify@4.0.1: {} pirates@4.0.7: {} @@ -16266,7 +16569,7 @@ snapshots: postcss@8.5.6: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -16674,6 +16977,10 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + request-light@0.5.8: {} + + request-light@0.7.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -16741,10 +17048,6 @@ snapshots: reusify@1.1.0: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - robust-predicates@3.0.3: {} rolldown@1.1.4: @@ -17116,8 +17419,6 @@ snapshots: arg: 5.0.2 sax: 1.6.0 - slash@3.0.0: {} - smart-buffer@4.2.0: {} smol-toml@1.6.0: {} @@ -17186,8 +17487,6 @@ snapshots: stream-replace-string@2.0.0: {} - string-argv@0.3.2: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -17323,6 +17622,10 @@ snapshots: symbol-tree@3.2.4: {} + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + tailwindcss@4.2.1: {} tailwindcss@4.2.3: {} @@ -17362,8 +17665,6 @@ snapshots: source-map-support: 0.5.21 optional: true - text-table@0.2.0: {} - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -17446,10 +17747,6 @@ snapshots: trough@2.2.0: {} - ts-api-utils@1.4.3(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -17495,7 +17792,7 @@ snapshots: tsx@4.23.1: dependencies: - esbuild: 0.28.0 + esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 @@ -17524,8 +17821,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.20.2: {} - type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -17570,6 +17865,23 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ufo@1.6.3: {} @@ -17763,7 +18075,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.6 + postcss: 8.5.16 rollup: 4.56.0 tinyglobby: 0.2.16 optionalDependencies: @@ -17993,6 +18305,84 @@ snapshots: transitivePeerDependencies: - msw + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28)(prettier@3.8.3): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.8.3 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -18008,6 +18398,8 @@ snapshots: dependencies: vscode-languageserver-protocol: 3.17.5 + vscode-nls@5.2.0: {} + vscode-uri@3.1.0: {} w3c-xmlserializer@5.0.0: @@ -18180,6 +18572,23 @@ snapshots: yallist@5.0.0: {} + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.8.3 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {} @@ -18242,6 +18651,10 @@ snapshots: dependencies: zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d3854fdae3..7cf89d6983 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,7 +10,12 @@ catalog: vite: 8.1.3 vitest: 4.1.5 tsx: 4.23.1 - eslint: 8.57.1 + '@eslint/js': 9.39.5 + '@astrojs/check': 0.9.10 + eslint: 9.39.5 + eslint-plugin-astro: 1.7.0 + globals: 17.9.0 + oxlint: 1.77.0 jsdom: 29.1.1 # === Vitest Ecosystem === @@ -18,8 +23,9 @@ catalog: '@vitejs/plugin-react': 5.2.0 # === TypeScript ESLint === - '@typescript-eslint/eslint-plugin': 7.18.0 + '@typescript-eslint/eslint-plugin': 8.65.0 '@typescript-eslint/parser': 8.65.0 + typescript-eslint: 8.65.0 # === Cloudflare === '@cloudflare/workers-types': 5.20260707.1 @@ -28,6 +34,8 @@ catalog: # === React Ecosystem === react: 19.2.7 react-dom: 19.2.7 + eslint-plugin-react: 7.37.5 + eslint-plugin-react-hooks: 7.1.1 '@types/react': 19.2.17 '@types/react-dom': 19.2.3 react-router: 7.14.2 diff --git a/scripts/quality/README.md b/scripts/quality/README.md new file mode 100644 index 0000000000..0ba3d7d769 --- /dev/null +++ b/scripts/quality/README.md @@ -0,0 +1,70 @@ +# Repository quality program + +The quality program preserves the existing application runtime while progressively adding +deterministic repository checks. The root developer entry point is: + +```bash +pnpm check:fast +``` + +It runs the formatting ratchet, the Oxlint shadow, authoritative workspace ESLint checks, and +the blocking type-boundary ratchet. CI invokes those same leaf commands rather than maintaining +different matcher logic in workflow YAML; see `.github/workflows/ci.yml` and +`scripts/quality/ci-quality-program.test.ts`. + +## Authoritative and advisory layers + +- ESLint 9 flat config remains authoritative. `eslint.config.mjs` preserves the captured legacy + finding set, hosts `@simple-agent-manager/eslint-plugin-sam`, and retains `simple-import-sort`. +- The three `sam/*` rules provide advisory editor diagnostics. Their ownership, stages, + baselines, and expiring exemptions live in + `packages/eslint-plugin-sam/rules.manifest.json`; fixtures run with ESLint 9 `RuleTester`. +- `pnpm quality:type-boundaries` is the blocking net-count ratchet. Existing debt in + `scripts/quality/type-boundary-baseline.json` passes, while net-new debt fails with + deterministic `file:line` output. `JSON.parse(...) as unknown` is allowed. Broad + `Record` and `as unknown as` populations are report-only. +- `pnpm quality:runtime-boundary-semantics` reports only the two bounded ts-morph checks for + unvalidated DO/D1 row narrowing and blind external-payload narrowing. It is not a whole-repo + type-aware gate. +- `pnpm lint:oxlint` is report-only. Promotion is forbidden until + `scripts/quality/lint-adoption-evidence.json` records finding, fix-diff, scope, template, + suppression, and cold-performance parity. Type-aware Oxlint is disabled. + +Boundary guidance and sanctioned Valibot patterns are in +`.claude/rules/51-runtime-boundary-validation.md`. Current helpers live in +`apps/api/src/lib/runtime-validation.ts` and `apps/api/src/schemas/_validator.ts`. + +## Workspace and template coverage + +`scripts/quality/workspace-quality-coverage.test.ts` proves that every pnpm workspace has the +intended lint and type/template-validation scripts. Astro templates use `astro check`; they are +not described as TypeScript compiler coverage. `tools/og-image` uses its scoped TypeScript +configuration. + +## Supply-chain checks + +- `pnpm quality:direct-dependency-evidence` requires authoritative evidence for direct npm and Go + manifest changes. Its checked-in snapshot makes staged, unstaged, and untracked manifest + changes visible to the same policy. +- `pnpm quality:gitleaks:current` and `pnpm quality:gitleaks:pr` run Gitleaks against the current + tree and PR range. Public logs expose counts and disposition only; secret-like findings and + full-history evidence remain private. +- `pnpm quality:govulncheck-diff` blocks locally changed Go modules and uses the locked tool module + in `scripts/quality/govulncheck-tool/`. + +Scanner jobs install the frozen lockfile without lifecycle scripts, and CI passes explicit scanner +binary paths to the wrappers. Do not publish scanner reports, hashes, advisories, or candidate +secret material in logs, PR text, or public issues. + +## Rollback switches + +Each layer is independently reversible: + +- keep or restore ESLint as the complete authoritative layer and leave Oxlint report-only; +- disable the advisory `sam/*` rules without changing the independent boundary ratchet; +- remove a leaf invocation from CI or `check:fast` without changing application runtime; +- disable an individual supply-chain job without publishing or accepting its findings as a new + baseline. + +Never remove the current authoritative path until its replacement has passed the documented +parity and rollout gates. diff --git a/scripts/quality/astro-check-baseline.json b/scripts/quality/astro-check-baseline.json new file mode 100644 index 0000000000..eaac6270b7 --- /dev/null +++ b/scripts/quality/astro-check-baseline.json @@ -0,0 +1,8 @@ +{ + "metadata": { + "owner": "SAM quality program", + "backlog": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "review": "Lower this count after each owned apps/www template cleanup." + }, + "errors": 6 +} diff --git a/scripts/quality/check-astro-templates.test.ts b/scripts/quality/check-astro-templates.test.ts new file mode 100644 index 0000000000..9a2eb936fc --- /dev/null +++ b/scripts/quality/check-astro-templates.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + exceedsAstroErrorBaseline, + isCompleteAstroCheckResult, + parseAstroCheckBaseline, + parseAstroCheckSummary, +} from './check-astro-templates'; + +describe('Astro template validation ratchet', () => { + it('runtime-validates the checked-in JSON baseline', () => { + expect( + parseAstroCheckBaseline( + JSON.stringify({ + errors: 6, + metadata: { owner: 'quality', backlog: 'task', review: 'date' }, + }) + ) + ).toMatchObject({ errors: 6 }); + expect(() => parseAstroCheckBaseline('{"errors":"6","metadata":{}}')).toThrow(); + }); + + it('parses deterministic error, warning, and hint totals', () => { + expect( + parseAstroCheckSummary(`Result (45 files):\n- 6 errors\n- 2 warnings\n- 18 hints\n`) + ).toEqual({ errors: 6, warnings: 2, hints: 18 }); + }); + + it('passes equal/decreased debt and fails a net increase', () => { + expect(exceedsAstroErrorBaseline({ errors: 6, warnings: 0, hints: 0 }, 6)).toBe(false); + expect(exceedsAstroErrorBaseline({ errors: 5, warnings: 0, hints: 0 }, 6)).toBe(false); + expect(exceedsAstroErrorBaseline({ errors: 7, warnings: 0, hints: 0 }, 6)).toBe(true); + }); + + it('fails closed when Astro does not emit a complete result', () => { + expect(parseAstroCheckSummary('Astro crashed before diagnostics.')).toBeUndefined(); + expect(parseAstroCheckSummary('Result (1 files):\n- 0 errors')).toBeUndefined(); + const complete = { errors: 0, warnings: 0, hints: 0 }; + expect(isCompleteAstroCheckResult(0, complete, undefined)).toBe(true); + expect(isCompleteAstroCheckResult(1, complete, undefined)).toBe(true); + expect(isCompleteAstroCheckResult(2, complete, undefined)).toBe(false); + expect(isCompleteAstroCheckResult(0, complete, new Error('spawn failed'))).toBe(false); + }); +}); diff --git a/scripts/quality/check-astro-templates.ts b/scripts/quality/check-astro-templates.ts new file mode 100644 index 0000000000..72487748c6 --- /dev/null +++ b/scripts/quality/check-astro-templates.ts @@ -0,0 +1,98 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import * as v from 'valibot'; + +interface AstroCheckBaseline { + errors: number; + metadata: { + owner: string; + backlog: string; + review: string; + }; +} + +const AstroCheckBaselineSchema = v.object({ + errors: v.number(), + metadata: v.object({ + owner: v.string(), + backlog: v.string(), + review: v.string(), + }), +}); + +export interface AstroCheckSummary { + errors: number; + hints: number; + warnings: number; +} + +const ANSI_PATTERN = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, 'g'); + +export function parseAstroCheckSummary(output: string): AstroCheckSummary | undefined { + const plain = output.replace(ANSI_PATTERN, ''); + const result = /Result \(\d+ files\):([\s\S]*)/.exec(plain)?.[1]; + if (!result) return undefined; + const count = (label: string): number | undefined => { + const value = new RegExp(String.raw`-\s+(\d+)\s+${label}`).exec(result)?.[1]; + return value === undefined ? undefined : Number(value); + }; + const errors = count('errors'); + const warnings = count('warnings'); + const hints = count('hints'); + if (errors === undefined || warnings === undefined || hints === undefined) return undefined; + return { errors, warnings, hints }; +} + +export function exceedsAstroErrorBaseline(summary: AstroCheckSummary, allowed: number): boolean { + return summary.errors > allowed; +} + +export function isCompleteAstroCheckResult( + status: number | null, + summary: AstroCheckSummary | undefined, + executionError: unknown +): summary is AstroCheckSummary { + return !executionError && (status === 0 || status === 1) && summary !== undefined; +} + +export function parseAstroCheckBaseline(input: string): AstroCheckBaseline { + return v.parse(AstroCheckBaselineSchema, JSON.parse(input) as unknown); +} + +function run(): void { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + const baseline = parseAstroCheckBaseline( + readFileSync(resolve(repoRoot, 'scripts/quality/astro-check-baseline.json'), 'utf8') + ); + const result = spawnSync( + process.execPath, + [resolve(repoRoot, 'apps/www/node_modules/astro/bin/astro.mjs'), 'check'], + { + cwd: resolve(repoRoot, 'apps/www'), + encoding: 'utf8', + env: process.env, + maxBuffer: 32 * 1024 * 1024, + } + ); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + const summary = parseAstroCheckSummary(output); + if (!isCompleteAstroCheckResult(result.status, summary, result.error)) { + console.error('Astro template validation did not produce a complete diagnostic summary.'); + process.exit(1); + } + if (exceedsAstroErrorBaseline(summary, baseline.errors)) { + console.error(output.replace(ANSI_PATTERN, '')); + console.error( + `Astro template errors increased from baseline ${baseline.errors} to ${summary.errors}.` + ); + process.exit(1); + } + console.log( + `Astro template validation: ${summary.errors} baseline error(s), ${summary.warnings} warning(s), ${summary.hints} hint(s).` + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) run(); diff --git a/scripts/quality/check-direct-dependency-evidence.test.ts b/scripts/quality/check-direct-dependency-evidence.test.ts new file mode 100644 index 0000000000..9c4be8c22b --- /dev/null +++ b/scripts/quality/check-direct-dependency-evidence.test.ts @@ -0,0 +1,179 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + checkDirectDependencyEvidence, + compareManifestSnapshots, + directDependencyAdditionsAgainstBase, + extractDirectDependencyAdditions, + loadEvidence, +} from './check-direct-dependency-evidence'; + +const fixtureRoot = join(import.meta.dirname, 'fixtures/supply-chain'); +const readFixture = (name: string) => readFileSync(join(fixtureRoot, name), 'utf8'); + +describe('direct dependency evidence checker', () => { + it('requires authoritative evidence and necessity for production npm additions', () => { + const result = checkDirectDependencyEvidence(readFixture('direct-dependency-add.diff'), { + npm: {}, + }); + + expect(result.ok).toBe(false); + expect(result.errors).toContain('Missing direct dependency evidence for npm:left-pad'); + }); + + it('accepts registry/homepage evidence with a one-line necessity', () => { + const result = checkDirectDependencyEvidence(readFixture('direct-dependency-add.diff'), { + npm: { + 'left-pad': { + registryUrl: 'https://www.npmjs.com/package/left-pad', + necessity: 'Pads deterministic fixture values.', + }, + }, + }); + + expect(result.ok).toBe(true); + }); + + it('flags malformed evidence without printing package contents', () => { + const result = checkDirectDependencyEvidence(readFixture('go-dependency-add.diff'), { + go: { + 'golang.org/x/crypto': { + registryUrl: 'http://pkg.go.dev/golang.org/x/crypto', + necessity: 'crypto', + }, + }, + }); + + expect(result.ok).toBe(false); + expect(result.errors).toEqual([ + 'go:golang.org/x/crypto registryUrl must be an https URL', + 'go:golang.org/x/crypto needs a one-line necessity with at least three words', + ]); + }); + + it('does not require evidence for removals, version updates, or workspace/internal dependencies', () => { + const diffs = ['dependency-update-remove.diff', 'direct-dependency-workspace.diff'].map( + readFixture + ); + + for (const diff of diffs) { + const result = checkDirectDependencyEvidence(diff, {}); + expect(result.ok).toBe(true); + } + }); + + it('requires evidence for direct dev dependency additions too', () => { + const missing = checkDirectDependencyEvidence(readFixture('direct-dependency-dev.diff'), {}); + expect(missing.errors).toContain('Missing direct dependency evidence for npm:vitest'); + + const present = checkDirectDependencyEvidence(readFixture('direct-dependency-dev.diff'), { + npm: { + vitest: { + registryUrl: 'https://www.npmjs.com/package/vitest', + necessity: 'Runs deterministic unit test fixtures.', + }, + }, + }); + expect(present.ok).toBe(true); + }); + + it('detects additions in the repository-root package manifest', () => { + const additions = extractDirectDependencyAdditions( + `diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -3,0 +4,3 @@\n+ "dependencies": {\n+ "root-package": "1.0.0"\n+ }\n` + ); + expect(additions).toMatchObject([ + { ecosystem: 'npm', manifestPath: 'package.json', name: 'root-package' }, + ]); + }); + + it('compares complete npm snapshots when a zero-context patch omits the section name', () => { + expect( + compareManifestSnapshots( + 'package.json', + JSON.stringify({ scripts: { test: 'vitest' }, dependencies: {} }), + JSON.stringify({ scripts: { test: 'vitest' }, dependencies: { valibot: '1.2.0' } }) + ) + ).toEqual([ + { + ecosystem: 'npm', + manifestPath: 'package.json', + name: 'valibot', + production: true, + internal: false, + }, + ]); + }); + + it('wires the repository check to manifest snapshots instead of diff context', () => { + const root = mkdtempSync(join(tmpdir(), 'sam-dependency-evidence-')); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ scripts: { test: 'vitest' }, dependencies: {} }, null, 2) + ); + execFileSync('git', ['add', 'package.json'], { cwd: root }); + execFileSync('git', ['commit', '-m', 'base', '--quiet'], { cwd: root }); + const base = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ scripts: { test: 'vitest' }, dependencies: { valibot: '1.2.0' } }, null, 2) + ); + + expect(directDependencyAdditionsAgainstBase(root, base)).toMatchObject([ + { ecosystem: 'npm', manifestPath: 'package.json', name: 'valibot' }, + ]); + }); + + it('fails closed when the durable evidence file is missing', () => { + expect(() => loadEvidence(join(tmpdir(), 'missing-sam-dependency-evidence.json'))).toThrow( + 'evidence file is missing' + ); + }); + + it('does not treat transitive Go requirements as direct additions', () => { + const result = checkDirectDependencyEvidence( + `diff --git a/packages/vm-agent/go.mod b/packages/vm-agent/go.mod\n--- a/packages/vm-agent/go.mod\n+++ b/packages/vm-agent/go.mod\n@@ -3,0 +4 @@\n+require golang.org/x/text v0.3.0 // indirect\n`, + {} + ); + expect(result).toMatchObject({ ok: true, additions: [] }); + }); + + it('extracts Go direct additions from module manifests only', () => { + const additions = extractDirectDependencyAdditions(readFixture('go-dependency-add.diff')); + + expect(additions).toEqual([ + { + ecosystem: 'go', + manifestPath: 'packages/vm-agent/go.mod', + name: 'golang.org/x/crypto', + production: true, + internal: false, + }, + ]); + }); + + it('treats a Go tool directive as direct dependency evidence', () => { + expect( + compareManifestSnapshots( + 'scripts/quality/govulncheck-tool/go.mod', + 'module example.com/tools\n\ngo 1.25.0\n', + 'module example.com/tools\n\ngo 1.25.0\n\ntool golang.org/x/vuln/cmd/govulncheck\n' + ) + ).toEqual([ + { + ecosystem: 'go', + manifestPath: 'scripts/quality/govulncheck-tool/go.mod', + name: 'golang.org/x/vuln/cmd/govulncheck', + production: true, + internal: false, + }, + ]); + }); +}); diff --git a/scripts/quality/check-direct-dependency-evidence.ts b/scripts/quality/check-direct-dependency-evidence.ts new file mode 100644 index 0000000000..d1c8d5d179 --- /dev/null +++ b/scripts/quality/check-direct-dependency-evidence.ts @@ -0,0 +1,425 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { basename, dirname, join, posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import * as v from 'valibot'; + +export type DependencyEcosystem = 'npm' | 'go'; + +export interface DependencyAddition { + ecosystem: DependencyEcosystem; + manifestPath: string; + name: string; + production: boolean; + internal: boolean; +} + +export interface DependencyEvidence { + registryUrl?: string; + homepageUrl?: string; + necessity?: string; +} + +export interface EvidenceFile { + npm?: Record; + go?: Record; +} + +export interface CheckResult { + ok: boolean; + additions: DependencyAddition[]; + errors: string[]; +} + +const repoRoot = posix.normalize(join(dirname(fileURLToPath(import.meta.url)), '../..')); +const evidencePath = join(repoRoot, 'scripts/quality/direct-dependency-evidence.json'); +const urlPattern = /^https:\/\/[^\s"<>]+$/; +const goRequirePattern = + /^\+\s*(?:require\s+)?([A-Za-z0-9_.~/-]+\.[A-Za-z0-9_.~/-]+)\s+v[^\s]+(?:\s*\/\/.*)?$/; +const DependencyEvidenceSchema = v.object({ + registryUrl: v.optional(v.string()), + homepageUrl: v.optional(v.string()), + necessity: v.optional(v.string()), +}); +const EvidenceFileSchema = v.looseObject({ + npm: v.optional(v.record(v.string(), DependencyEvidenceSchema)), + go: v.optional(v.record(v.string(), DependencyEvidenceSchema)), +}); + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')) as unknown; +} + +function normalizeRepoPath(path: string): string { + return posix.normalize(path.replaceAll('\\', '/')); +} + +function changedFileFromDiffLine(line: string): string | undefined { + const match = /^\+\+\+ b\/(.+)$/.exec(line); + return match?.[1] ? normalizeRepoPath(match[1]) : undefined; +} + +function packageDependencySection(line: string): 'dependencies' | 'devDependencies' | undefined { + const match = /^\s*[+-]?\s*"([^"]+)":\s*\{?\s*$/.exec(line); + const key = match?.[1]; + if (key === 'dependencies' || key === 'devDependencies') return key; + return undefined; +} + +function npmDependencyFromAddedLine(line: string): { name: string; version: string } | undefined { + const match = /^\+\s*"([^"]+)":\s*"([^"]+)"\s*,?\s*$/.exec(line); + if (!match?.[1] || !match[2]) return undefined; + return { name: match[1], version: match[2] }; +} + +function npmDependencyFromRemovedLine(line: string): { name: string; version: string } | undefined { + const match = /^-\s*"([^"]+)":\s*"([^"]+)"\s*,?\s*$/.exec(line); + if (!match?.[1] || !match[2]) return undefined; + return { name: match[1], version: match[2] }; +} + +function isInternalNpmDependency( + name: string, + version: string, + workspacePackageName?: string +): boolean { + if (version.startsWith('workspace:')) return true; + if (name.startsWith('@simple-agent-manager/')) return true; + return workspacePackageName === name; +} + +function isInternalGoDependency(name: string): boolean { + return name.startsWith('github.com/raphaeltm/simple-agent-manager/'); +} + +export function extractDirectDependencyAdditions(diff: string): DependencyAddition[] { + const additions: DependencyAddition[] = []; + const removedDependencies = new Set(); + let currentFile: string | undefined; + let npmSection: 'dependencies' | 'devDependencies' | undefined; + let inGoRequireBlock = false; + + for (const line of diff.split('\n')) { + const nextFile = changedFileFromDiffLine(line); + if (nextFile) { + currentFile = nextFile; + npmSection = undefined; + inGoRequireBlock = false; + continue; + } + + if (!currentFile) continue; + + if (basename(currentFile) === 'package.json') { + const section = packageDependencySection(line); + if (section) npmSection = section; + if (/^\s*[+-]?\s*}\s*,?\s*$/.test(line)) npmSection = undefined; + + const removedDependency = npmDependencyFromRemovedLine(line); + if (removedDependency && npmSection) { + removedDependencies.add(`${currentFile}\0${npmSection}\0${removedDependency.name}`); + } + + const dependency = npmDependencyFromAddedLine(line); + if (dependency && npmSection) { + if (removedDependencies.has(`${currentFile}\0${npmSection}\0${dependency.name}`)) continue; + additions.push({ + ecosystem: 'npm', + manifestPath: currentFile, + name: dependency.name, + production: npmSection === 'dependencies', + internal: isInternalNpmDependency(dependency.name, dependency.version), + }); + } + continue; + } + + if (basename(currentFile) === 'go.mod') { + if (/^[ +]?require\s*\(\s*$/.test(line)) { + inGoRequireBlock = true; + continue; + } + if (inGoRequireBlock && /^[ +]?\)\s*$/.test(line)) { + inGoRequireBlock = false; + continue; + } + if (!line.startsWith('+') || line.startsWith('+++')) continue; + const match = goRequirePattern.exec(line); + if (match?.[1]) { + if (/\/\/\s*indirect\s*$/.test(line)) continue; + const removedSameModulePattern = new RegExp( + `^-\\s*(?:require\\s+)?${match[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+v\\S+` + ); + if (diff.split('\n').some((diffLine) => removedSameModulePattern.test(diffLine))) continue; + additions.push({ + ecosystem: 'go', + manifestPath: currentFile, + name: match[1], + production: true, + internal: isInternalGoDependency(match[1]), + }); + } + } + } + + return additions; +} + +interface NpmManifestSnapshot { + name?: string; + dependencies: Record; + devDependencies: Record; +} + +function stringRecord(value: unknown, label: string): Record { + if (value === undefined) return {}; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid ${label} in package manifest.`); + } + const result: Record = {}; + for (const [name, version] of Object.entries(value)) { + if (typeof version !== 'string') throw new Error(`Invalid ${label} in package manifest.`); + result[name] = version; + } + return result; +} + +function parseNpmManifestSnapshot(raw: string | undefined): NpmManifestSnapshot { + if (raw === undefined) return { dependencies: {}, devDependencies: {} }; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Invalid package manifest.'); + } + const name = 'name' in parsed ? parsed.name : undefined; + const dependencies = 'dependencies' in parsed ? parsed.dependencies : undefined; + const devDependencies = 'devDependencies' in parsed ? parsed.devDependencies : undefined; + if (name !== undefined && typeof name !== 'string') { + throw new Error('Invalid package name in package manifest.'); + } + return { + name, + dependencies: stringRecord(dependencies, 'dependencies'), + devDependencies: stringRecord(devDependencies, 'devDependencies'), + }; +} + +function parseDirectGoRequirements(raw: string | undefined): Map { + const requirements = new Map(); + if (raw === undefined) return requirements; + let inRequireBlock = false; + for (const sourceLine of raw.split('\n')) { + const line = sourceLine.trim(); + if (line === 'require (') { + inRequireBlock = true; + continue; + } + if (inRequireBlock && line === ')') { + inRequireBlock = false; + continue; + } + const candidate = inRequireBlock ? line : line.startsWith('require ') ? line.slice(8) : ''; + if (!candidate || /\/\/\s*indirect\s*$/.test(candidate)) continue; + const match = /^([^\s]+)\s+(v[^\s]+)(?:\s*\/\/.*)?$/.exec(candidate); + if (match?.[1] && match[2]) requirements.set(match[1], match[2]); + } + return requirements; +} + +function parseGoTools(raw: string | undefined): Set { + const tools = new Set(); + if (raw === undefined) return tools; + let inToolBlock = false; + for (const sourceLine of raw.split('\n')) { + const line = sourceLine.trim(); + if (line === 'tool (') { + inToolBlock = true; + continue; + } + if (inToolBlock && line === ')') { + inToolBlock = false; + continue; + } + const tool = inToolBlock ? line : line.startsWith('tool ') ? line.slice(5).trim() : ''; + if (tool && !tool.startsWith('//')) tools.add(tool); + } + return tools; +} + +/** Compare complete manifest snapshots so diff context cannot hide additions. */ +export function compareManifestSnapshots( + manifestPath: string, + beforeRaw: string | undefined, + afterRaw: string | undefined +): DependencyAddition[] { + if (basename(manifestPath) === 'package.json') { + const before = parseNpmManifestSnapshot(beforeRaw); + const after = parseNpmManifestSnapshot(afterRaw); + const existingNames = new Set([ + ...Object.keys(before.dependencies), + ...Object.keys(before.devDependencies), + ]); + const additions: DependencyAddition[] = []; + for (const [section, production] of [ + [after.dependencies, true], + [after.devDependencies, false], + ] as const) { + for (const [name, version] of Object.entries(section).sort(([left], [right]) => + left.localeCompare(right) + )) { + if (existingNames.has(name)) continue; + additions.push({ + ecosystem: 'npm', + manifestPath, + name, + production, + internal: isInternalNpmDependency(name, version, after.name), + }); + } + } + return additions; + } + + if (basename(manifestPath) === 'go.mod') { + const before = parseDirectGoRequirements(beforeRaw); + const requirementAdditions = [...parseDirectGoRequirements(afterRaw)] + .filter(([name]) => !before.has(name)) + .map(([name]) => ({ + ecosystem: 'go' as const, + manifestPath, + name, + production: true, + internal: isInternalGoDependency(name), + })); + const beforeTools = parseGoTools(beforeRaw); + const toolAdditions = [...parseGoTools(afterRaw)] + .filter((name) => !beforeTools.has(name)) + .map((name) => ({ + ecosystem: 'go' as const, + manifestPath, + name, + production: true, + internal: isInternalGoDependency(name), + })); + return [...requirementAdditions, ...toolAdditions].sort((left, right) => + left.name.localeCompare(right.name) + ); + } + + return []; +} + +function gitOutput( + repositoryRoot: string, + args: string[], + allowMissing = false +): string | undefined { + const result = spawnSync('/usr/bin/git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (allowMissing && result.status === 128) return undefined; + if (result.error || result.status !== 0) { + throw new Error('Could not compare direct dependency manifests against the base revision.'); + } + return result.stdout; +} + +function changedManifestPaths(repositoryRoot: string, base: string): string[] { + const tracked = + gitOutput(repositoryRoot, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', base, '--']) + ?.split('\0') + .filter(Boolean) ?? []; + const untracked = + gitOutput(repositoryRoot, ['ls-files', '--others', '--exclude-standard', '-z']) + ?.split('\0') + .filter(Boolean) ?? []; + return [...new Set([...tracked, ...untracked])] + .map(normalizeRepoPath) + .filter((path) => basename(path) === 'package.json' || basename(path) === 'go.mod') + .sort((left, right) => left.localeCompare(right)); +} + +export function directDependencyAdditionsAgainstBase( + repositoryRoot: string, + base: string +): DependencyAddition[] { + return changedManifestPaths(repositoryRoot, base).flatMap((manifestPath) => { + const before = gitOutput(repositoryRoot, ['show', `${base}:${manifestPath}`], true); + const currentPath = join(repositoryRoot, manifestPath); + if (!existsSync(currentPath)) return []; + if (!lstatSync(currentPath).isFile()) { + throw new Error('Direct dependency manifests must be regular files.'); + } + return compareManifestSnapshots(manifestPath, before, readFileSync(currentPath, 'utf8')); + }); +} + +function validateEvidenceEntry( + addition: DependencyAddition, + evidence: DependencyEvidence | undefined +): string[] { + const prefix = `${addition.ecosystem}:${addition.name}`; + const errors: string[] = []; + if (!evidence) return [`Missing direct dependency evidence for ${prefix}`]; + + if (!evidence.registryUrl && !evidence.homepageUrl) { + errors.push(`${prefix} needs registryUrl or homepageUrl`); + } + if (evidence.registryUrl && !urlPattern.test(evidence.registryUrl)) { + errors.push(`${prefix} registryUrl must be an https URL`); + } + if (evidence.homepageUrl && !urlPattern.test(evidence.homepageUrl)) { + errors.push(`${prefix} homepageUrl must be an https URL`); + } + if (!evidence.necessity || evidence.necessity.trim().split(/\s+/).length < 3) { + errors.push(`${prefix} needs a one-line necessity with at least three words`); + } + if (evidence.necessity?.includes('\n')) { + errors.push(`${prefix} necessity must be one line`); + } + return errors; +} + +export function checkDirectDependencyEvidence(diff: string, evidence: EvidenceFile): CheckResult { + const additions = extractDirectDependencyAdditions(diff); + const relevantAdditions = additions.filter((addition) => !addition.internal); + const errors = relevantAdditions.flatMap((addition) => + validateEvidenceEntry(addition, evidence[addition.ecosystem]?.[addition.name]) + ); + return { ok: errors.length === 0, additions, errors }; +} + +export function loadEvidence(path = evidencePath): EvidenceFile { + if (!existsSync(path)) throw new Error('Direct dependency evidence file is missing.'); + const parsed = readJson(path); + return v.parse(EvidenceFileSchema, parsed); +} + +function baseRevision(): string { + const base = process.env.GITHUB_BASE_REF + ? `origin/${process.env.GITHUB_BASE_REF}` + : 'origin/main'; + return base; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const additions = directDependencyAdditionsAgainstBase(repoRoot, baseRevision()); + const relevantAdditions = additions.filter((addition) => !addition.internal); + const evidence = loadEvidence(); + const errors = relevantAdditions.flatMap((addition) => + validateEvidenceEntry(addition, evidence[addition.ecosystem]?.[addition.name]) + ); + const result = { ok: errors.length === 0, additions, errors }; + if (!result.ok) { + console.error( + [ + 'Direct dependency evidence check failed:', + ...result.errors.map((error) => `- ${error}`), + ].join('\n') + ); + process.exit(1); + } + console.log('Direct dependency evidence check passed.'); +} diff --git a/scripts/quality/check-format.test.ts b/scripts/quality/check-format.test.ts new file mode 100644 index 0000000000..b7a2c81ba9 --- /dev/null +++ b/scripts/quality/check-format.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { compareFormatCounts } from './check-format'; + +describe('format debt ratchet', () => { + it('allows an unchanged baseline', () => { + expect(compareFormatCounts(2390, 2390)).toEqual({ + ok: true, + currentCount: 2390, + baselineCount: 2390, + increase: 0, + }); + }); + + it('allows debt reduction', () => { + expect(compareFormatCounts(2300, 2390).ok).toBe(true); + }); + + it('blocks net-new formatting debt', () => { + expect(compareFormatCounts(2391, 2390)).toEqual({ + ok: false, + currentCount: 2391, + baselineCount: 2390, + increase: 1, + }); + }); +}); diff --git a/scripts/quality/check-format.ts b/scripts/quality/check-format.ts new file mode 100644 index 0000000000..55a9f8db0b --- /dev/null +++ b/scripts/quality/check-format.ts @@ -0,0 +1,157 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { extname, join } from 'node:path'; + +import { check, getFileInfo, resolveConfig } from 'prettier'; + +interface FormatBaseline { + version: 1; + formatter: string; + baseCommit: string; + extensions: string[]; + unformattedFileCount: number; + reviewedAt: string; + reviewBy: string; +} + +export interface FormatCountComparison { + ok: boolean; + currentCount: number; + baselineCount: number; + increase: number; +} + +const BASELINE_PATH = 'scripts/quality/format-baseline.json'; + +export function compareFormatCounts( + currentCount: number, + baselineCount: number +): FormatCountComparison { + return { + ok: currentCount <= baselineCount, + currentCount, + baselineCount, + increase: Math.max(0, currentCount - baselineCount), + }; +} + +function readBaseline(repositoryRoot: string): FormatBaseline { + const parsed = JSON.parse(readFileSync(join(repositoryRoot, BASELINE_PATH), 'utf8')) as unknown; + const baseline = parsed as Partial; + if ( + typeof baseline !== 'object' || + baseline === null || + baseline.version !== 1 || + typeof baseline.formatter !== 'string' || + typeof baseline.baseCommit !== 'string' || + !/^[a-f0-9]{40}$/.test(baseline.baseCommit) || + !Array.isArray(baseline.extensions) || + baseline.extensions.some((extension) => typeof extension !== 'string') || + !Number.isInteger(baseline.unformattedFileCount) || + baseline.unformattedFileCount! < 0 || + typeof baseline.reviewedAt !== 'string' || + Number.isNaN(new Date(baseline.reviewedAt).valueOf()) || + typeof baseline.reviewBy !== 'string' || + Number.isNaN(new Date(baseline.reviewBy).valueOf()) + ) { + throw new Error('The Prettier debt baseline is invalid.'); + } + if (new Date(baseline.reviewBy) <= new Date()) { + throw new Error('The Prettier debt baseline review has expired.'); + } + return baseline as FormatBaseline; +} + +function gitPaths(repositoryRoot: string, args: string[]): string[] { + const result = spawnSync('/usr/bin/git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + throw new Error('Could not enumerate files changed from the Prettier baseline.'); + } + return result.stdout.split('\0').filter(Boolean); +} + +function changedPaths(repositoryRoot: string, baseCommit: string): string[] { + const tracked = gitPaths(repositoryRoot, [ + 'diff', + '--name-only', + '--no-renames', + '-z', + baseCommit, + '--', + ]); + const untracked = gitPaths(repositoryRoot, ['ls-files', '--others', '--exclude-standard', '-z']); + return [...new Set([...tracked, ...untracked])].sort((left, right) => left.localeCompare(right)); +} + +function fileAtRevision( + repositoryRoot: string, + revision: string, + path: string +): string | undefined { + const result = spawnSync('/usr/bin/git', ['show', `${revision}:${path}`], { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status === 128) return undefined; + if (result.error || result.status !== 0) { + throw new Error('Could not read a file from the Prettier baseline commit.'); + } + return result.stdout; +} + +async function participates(path: string, extensions: Set): Promise { + if (!extensions.has(extname(path))) return false; + const information = await getFileInfo(path, { ignorePath: '.prettierignore' }); + return !information.ignored && information.inferredParser !== null; +} + +async function isFormatted(content: string, path: string): Promise { + const config = await resolveConfig(path); + return check(content, { ...config, filepath: path }); +} + +async function run(): Promise { + const repositoryRoot = process.cwd(); + const baseline = readBaseline(repositoryRoot); + const extensions = new Set(baseline.extensions); + let removedDebtCount = 0; + let currentChangedDebtCount = 0; + + for (const path of changedPaths(repositoryRoot, baseline.baseCommit)) { + if (!(await participates(path, extensions))) continue; + const baselineContent = fileAtRevision(repositoryRoot, baseline.baseCommit, path); + if (baselineContent !== undefined && !(await isFormatted(baselineContent, path))) { + removedDebtCount += 1; + } + const currentPath = join(repositoryRoot, path); + if (existsSync(currentPath)) { + const currentContent = readFileSync(currentPath, 'utf8'); + if (!(await isFormatted(currentContent, path))) currentChangedDebtCount += 1; + } + } + + const currentCount = baseline.unformattedFileCount - removedDebtCount + currentChangedDebtCount; + const comparison = compareFormatCounts(currentCount, baseline.unformattedFileCount); + if (!comparison.ok) { + console.error( + `Prettier debt increased by ${comparison.increase} file(s): ${comparison.currentCount}/${comparison.baselineCount}.` + ); + console.error( + 'Run pnpm format:check:strict to list the files, then format the changed source.' + ); + process.exitCode = 1; + return; + } + + console.log( + `Prettier format ratchet passed: ${comparison.currentCount}/${comparison.baselineCount} unformatted file(s).` + ); +} + +const isEntrypoint = process.argv[1]?.endsWith('check-format.ts'); +if (isEntrypoint) await run(); diff --git a/scripts/quality/check-go-vulnerability-diff.test.ts b/scripts/quality/check-go-vulnerability-diff.test.ts new file mode 100644 index 0000000000..a96a1a66ae --- /dev/null +++ b/scripts/quality/check-go-vulnerability-diff.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; +import { + changedFilesForRepository, + changedGoModuleDirectories, + runGoVulnerabilityDiffPolicy, + shouldRunGovulncheck, +} from './check-go-vulnerability-diff'; + +describe('govulncheck diff policy', () => { + it('is applicable only when Go module files changed', () => { + expect(shouldRunGovulncheck(['apps/api/src/index.ts', 'README.md'])).toBe(false); + expect(shouldRunGovulncheck(['packages/vm-agent/go.mod'])).toBe(true); + expect(shouldRunGovulncheck(['packages/vm-agent/go.sum'])).toBe(true); + expect(shouldRunGovulncheck(['docs/go.mod-notes.md'])).toBe(false); + }); + + it('runs once from each changed module directory in deterministic order', () => { + expect( + changedGoModuleDirectories([ + 'packages/vm-agent/go.sum', + 'packages/cli/go.mod', + 'packages/vm-agent/go.mod', + ]) + ).toEqual(['packages/cli', 'packages/vm-agent']); + + const runner = vi.fn(() => ({ status: 0, stdout: '', stderr: '' })); + const result = runGoVulnerabilityDiffPolicy( + ['packages/vm-agent/go.sum', 'packages/cli/go.mod'], + runner, + '/repo' + ); + expect(result).toMatchObject({ + ok: true, + moduleDirectories: ['packages/cli', 'packages/vm-agent'], + }); + expect(runner).toHaveBeenNthCalledWith(1, 'govulncheck', ['./...'], { + cwd: '/repo/packages/cli', + }); + expect(runner).toHaveBeenNthCalledWith(2, 'govulncheck', ['./...'], { + cwd: '/repo/packages/vm-agent', + }); + }); + + it('does not invoke govulncheck for unrelated diffs', () => { + const runner = vi.fn(); + const result = runGoVulnerabilityDiffPolicy(['scripts/quality/check.test.ts'], runner); + + expect(result).toMatchObject({ applicable: false, ok: true }); + expect(runner).not.toHaveBeenCalled(); + }); + + it('includes committed, staged, unstaged, and untracked local changes', () => { + const root = mkdtempSync(join(tmpdir(), 'sam-govuln-diff-')); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root }); + writeFileSync(join(root, 'README.md'), 'base\n'); + execFileSync('git', ['add', 'README.md'], { cwd: root }); + execFileSync('git', ['commit', '-m', 'base', '--quiet'], { cwd: root }); + const base = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(); + + mkdirSync(join(root, 'staged'), { recursive: true }); + writeFileSync(join(root, 'staged/go.mod'), 'module example.com/staged\n'); + execFileSync('git', ['add', 'staged/go.mod'], { cwd: root }); + writeFileSync(join(root, 'README.md'), 'unstaged\n'); + mkdirSync(join(root, 'untracked'), { recursive: true }); + writeFileSync(join(root, 'untracked/go.sum'), 'untracked\n'); + + expect(changedFilesForRepository(root, base)).toEqual([ + 'README.md', + 'staged/go.mod', + 'untracked/go.sum', + ]); + }); + + it('fails closed when govulncheck reports vulnerabilities or execution failure', () => { + const runner = vi.fn(() => ({ status: 1, stdout: 'vulnerability found', stderr: '' })); + const result = runGoVulnerabilityDiffPolicy(['packages/vm-agent/go.mod'], runner); + + expect(result.ok).toBe(false); + expect(result.applicable).toBe(true); + expect(runner).toHaveBeenCalledWith( + 'govulncheck', + ['./...'], + expect.objectContaining({ cwd: expect.stringContaining('packages/vm-agent') }) + ); + }); +}); diff --git a/scripts/quality/check-go-vulnerability-diff.ts b/scripts/quality/check-go-vulnerability-diff.ts new file mode 100644 index 0000000000..81cbb22707 --- /dev/null +++ b/scripts/quality/check-go-vulnerability-diff.ts @@ -0,0 +1,119 @@ +import { execFileSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface CommandRunner { + ( + command: string, + args: string[], + options: { cwd: string } + ): { status: number; stdout: string; stderr: string }; +} + +export interface GoVulnerabilityPolicyResult { + applicable: boolean; + ok: boolean; + moduleDirectories?: string[]; + reason?: string; + stdout?: string; + stderr?: string; +} + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const goModulePattern = /(^|\/)go\.(mod|sum)$/; + +export function shouldRunGovulncheck(changedFiles: string[]): boolean { + return changedFiles.some((file) => goModulePattern.test(file.replaceAll('\\', '/'))); +} + +export function changedGoModuleDirectories(changedFiles: string[]): string[] { + return [ + ...new Set( + changedFiles + .map((file) => file.replaceAll('\\', '/')) + .filter((file) => goModulePattern.test(file)) + .map((file) => file.slice(0, file.lastIndexOf('/')) || '.') + ), + ].sort((left, right) => left.localeCompare(right)); +} + +export function runGoVulnerabilityDiffPolicy( + changedFiles: string[], + runner: CommandRunner, + cwd = repoRoot +): GoVulnerabilityPolicyResult { + if (!shouldRunGovulncheck(changedFiles)) { + return { applicable: false, ok: true, reason: 'No Go module files changed.' }; + } + + const moduleDirectories = changedGoModuleDirectories(changedFiles); + const outputs = moduleDirectories.map((directory) => + runner('govulncheck', ['./...'], { cwd: join(cwd, directory) }) + ); + return { + applicable: true, + ok: outputs.every((output) => output.status === 0), + moduleDirectories, + stdout: outputs.map((output) => output.stdout).join('\n'), + stderr: outputs.map((output) => output.stderr).join('\n'), + }; +} + +export function changedFilesForRepository(repositoryRoot: string, base: string): string[] { + const gitLines = (args: string[]): string[] => + execFileSync('/usr/bin/git', args, { cwd: repositoryRoot, encoding: 'utf8' }) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return [ + ...new Set([ + ...gitLines(['diff', '--name-only', `${base}...HEAD`]), + ...gitLines(['diff', '--cached', '--name-only']), + ...gitLines(['diff', '--name-only']), + ...gitLines(['ls-files', '--others', '--exclude-standard']), + ]), + ].sort((left, right) => left.localeCompare(right)); +} + +function changedFilesAgainstBase(): string[] { + const base = process.env.GITHUB_BASE_REF + ? `origin/${process.env.GITHUB_BASE_REF}` + : 'origin/main'; + return changedFilesForRepository(repoRoot, base); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const result = runGoVulnerabilityDiffPolicy( + changedFilesAgainstBase(), + (_command, args, options) => { + try { + return { + status: 0, + stdout: execFileSync( + process.env.SAM_GOVULNCHECK_BIN ?? '/usr/local/bin/govulncheck', + args, + { cwd: options.cwd, encoding: 'utf8' } + ), + stderr: '', + }; + } catch (error) { + const failure = error as { + status?: number; + stdout?: Buffer | string; + stderr?: Buffer | string; + }; + return { + status: failure.status ?? 1, + stdout: String(failure.stdout ?? ''), + stderr: String(failure.stderr ?? ''), + }; + } + } + ); + + if (!result.ok) { + console.error('govulncheck failed for Go module changes.'); + process.exit(1); + } + console.log(result.reason ?? 'govulncheck passed for Go module changes.'); +} diff --git a/scripts/quality/check-runtime-boundary-semantics.test.ts b/scripts/quality/check-runtime-boundary-semantics.test.ts new file mode 100644 index 0000000000..f8b719ffeb --- /dev/null +++ b/scripts/quality/check-runtime-boundary-semantics.test.ts @@ -0,0 +1,107 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { auditRuntimeBoundarySemantics } from './check-runtime-boundary-semantics'; + +function fixtureRepo(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'sam-runtime-semantics-')); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root }); + for (const [path, content] of Object.entries(files)) { + const full = join(root, path); + execFileSync('mkdir', ['-p', full.split('/').slice(0, -1).join('/')], { cwd: root }); + writeFileSync(full, content); + } + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['commit', '-m', 'fixtures'], { cwd: root }); + return root; +} + +describe('runtime-boundary semantic checks', () => { + it('flags only unvalidated DO/D1 row narrowing and blind external payload narrowing', () => { + const root = fixtureRepo({ + 'apps/api/src/bad.ts': ` + type Row = { id: string }; + async function badRows(storage: { sql: { exec(sql: string): { toArray(): unknown[] } } }) { + const rows = storage.sql.exec('select id from tasks').toArray() as Row[]; + return rows; + } + async function badPayload(request: Request) { + const body = await request.json() as { name: string }; + const broad = await request.json() as Record; + return { body, broad }; + } + `, + }); + expect(auditRuntimeBoundarySemantics(root)).toMatchObject([ + { rule: 'unvalidated-row-narrowing', file: 'apps/api/src/bad.ts', line: 4 }, + { rule: 'blind-external-payload-narrowing', file: 'apps/api/src/bad.ts', line: 8 }, + { rule: 'blind-external-payload-narrowing', file: 'apps/api/src/bad.ts', line: 9 }, + ]); + }); + + it('tracks one-hop boundary variables and accepts an explicit named runtime guard', () => { + const root = fixtureRepo({ + 'apps/api/src/one-hop.ts': ` + type Row = { id: string }; + declare function isRow(value: unknown): value is Row; + async function check(storage: { sql: { exec(sql: string): { toArray(): unknown[] } } }, request: Request) { + const unsafeRow = storage.sql.exec('select id').toArray()[0]; + const finding = unsafeRow as Row; + const payload = await request.json(); + if (!isRow(payload)) throw new Error('invalid'); + const safe = payload as Row; + return { finding, safe }; + } + `, + }); + expect(auditRuntimeBoundarySemantics(root)).toMatchObject([ + { rule: 'unvalidated-row-narrowing', file: 'apps/api/src/one-hop.ts', line: 6 }, + ]); + }); + + it('includes untracked source in the advisory repository audit', () => { + const root = fixtureRepo({ + 'apps/api/src/tracked.ts': 'export const tracked = true;', + }); + writeFileSync( + join(root, 'apps/api/src/untracked.ts'), + `type Body = { name: string };\nexport async function read(request: Request) { return await request.json() as Body; }` + ); + + expect(auditRuntimeBoundarySemantics(root)).toMatchObject([ + { + rule: 'blind-external-payload-narrowing', + file: 'apps/api/src/untracked.ts', + }, + ]); + }); + + it('treats schemas, guards, sanctioned helpers, env casts, DO stubs, and RPC boundary casts as safe/low-noise', () => { + const root = fixtureRepo({ + 'apps/api/src/good.ts': ` + import { parseWithSchema, expectJsonRecord, readResponseJson } from './lib/runtime-validation'; + type Row = { id: string }; + type Env = { DATABASE: unknown }; + type Rpc = { run(): Promise }; + declare const schema: unknown; + async function good(storage: { sql: { exec(sql: string): { toArray(): unknown[] } } }, request: Request, response: Response, env: unknown, stub: unknown) { + const rawRows = storage.sql.exec('select id from tasks').toArray(); + const rows = rawRows.map((row) => parseWithSchema(schema as never, row, 'row')) as Row[]; + const raw = await request.json(); + const body = expectJsonRecord(raw, 'request'); + const parsed = await readResponseJson(response, schema as never, 'response'); + const workerEnv = env as unknown as Env; + const rpc = stub as unknown as Rpc; + return { rows, body, parsed, workerEnv, rpc }; + } + `, + }); + expect(auditRuntimeBoundarySemantics(root)).toEqual([]); + }); +}); diff --git a/scripts/quality/check-runtime-boundary-semantics.ts b/scripts/quality/check-runtime-boundary-semantics.ts new file mode 100644 index 0000000000..42e2e0ed7a --- /dev/null +++ b/scripts/quality/check-runtime-boundary-semantics.ts @@ -0,0 +1,277 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + type AsExpression, + type CallExpression, + Node, + Project, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; + +type SemanticRule = 'unvalidated-row-narrowing' | 'blind-external-payload-narrowing'; + +interface Finding { + rule: SemanticRule; + file: string; + line: number; + message: string; + code: string; +} + +const __filename = fileURLToPath(import.meta.url); +const ROOT = resolve(__filename, '../..', '..'); +const DEFAULT_SCOPE = 'apps/api/src'; + +const sanctionedValidationNames = new Set([ + 'jsonValidator', + 'parseWithSchema', + 'expectJsonRecord', + 'optionalJsonRecord', + 'maybeJsonRecord', + 'parseJsonRecord', + 'readRequestJsonRecord', + 'readResponseJson', + 'safeParse', + 'parse', + 'vValidator', +]); + +const rowSourcePatterns = [ + /\.first\s*\(/, + /\.all\s*\(/, + /\.raw\s*\(/, + /\.toArray\s*\(/, + /\.results\b/, + /\bsql\.(exec|prepare)\s*\(/, +]; + +const externalPayloadPatterns = [ + /\b(await\s+)?request\.json\s*\(/, + /\b(await\s+)?req\.json\s*\(/, + /\b(await\s+)?response\.json\s*\(/, + /\bJSON\.parse\s*\(/, +]; + +function trackedFiles(root: string, scope: string): string[] { + return execFileSync( + '/usr/bin/git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', scope], + { cwd: root, encoding: 'utf8' } + ) + .split('\0') + .filter(Boolean) + .filter((file) => file.endsWith('.ts')) + .filter((file) => !file.endsWith('.test.ts') && !file.includes('/fixtures/')) + .filter((file) => { + const sourcePath = resolve(root, file); + if (!existsSync(sourcePath)) return false; + if (!lstatSync(sourcePath).isFile()) { + throw new Error(`Runtime-boundary source is not a regular file: ${file}`); + } + return true; + }) + .sort((left, right) => left.localeCompare(right)); +} + +function createProject(root: string, files: string[]): Project { + const project = new Project({ skipAddingFilesFromTsConfig: true }); + for (const file of files) project.addSourceFileAtPath(resolve(root, file)); + return project; +} + +function typeText(assertion: AsExpression): string { + return assertion.getTypeNode()?.getText().replace(/\s+/g, ' ').trim() ?? ''; +} + +function nearestStatementText(node: Node): string { + return node.getFirstAncestorByKind(SyntaxKind.VariableStatement)?.getText() ?? node.getText(); +} + +function expressionText(assertion: AsExpression): string { + return assertion.getExpression().getText().replace(/\s+/g, ' '); +} + +function isSanctionedHelperCall(call: CallExpression): boolean { + const expression = call.getExpression(); + if (Node.isIdentifier(expression)) return sanctionedValidationNames.has(expression.getText()); + if (Node.isPropertyAccessExpression(expression)) { + const name = expression.getName(); + const receiver = expression.getExpression().getText(); + return ( + sanctionedValidationNames.has(name) || + (receiver === 'v' && (name === 'parse' || name === 'safeParse')) + ); + } + return false; +} + +function assertedIdentifier(assertion: AsExpression): string | undefined { + let expression = assertion.getExpression(); + while (Node.isParenthesizedExpression(expression)) expression = expression.getExpression(); + return Node.isIdentifier(expression) ? expression.getText() : undefined; +} + +export function hasValidationGuardBefore(assertion: AsExpression): boolean { + const identifier = assertedIdentifier(assertion); + if (!identifier) return false; + const block = assertion.getFirstAncestorByKind(SyntaxKind.Block); + if (!block) return false; + const assertionStart = assertion.getStart(); + const priorText = block + .getStatements() + .filter((statement) => statement.getStart() < assertionStart) + .slice(-8) + .map((statement) => statement.getText()) + .join('\n'); + const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const namedGuard = new RegExp(`\\bis[A-Z]\\w*\\(\\s*${escaped}\\s*\\)`).test(priorText); + const schemaGuard = new RegExp( + `\\b(parseWithSchema|expectJsonRecord|maybeJsonRecord|safeParse|v\\.safeParse)\\([^;]*\\b${escaped}\\b` + ).test(priorText); + const structuralGuard = + new RegExp(`typeof\\s+${escaped}\\s*={2,3}\\s*['"]object['"]`).test(priorText) && + new RegExp(`typeof\\s+${escaped}\\.[A-Za-z_$][\\w$]*\\s*={2,3}`).test(priorText); + return namedGuard || schemaGuard || structuralGuard; +} + +export function sourceExpressionText(assertion: AsExpression): string { + const identifier = assertedIdentifier(assertion); + if (!identifier) return expressionText(assertion); + const functionScopeStart = (node: Node): number | undefined => + node + .getAncestors() + .find( + (ancestor) => + Node.isFunctionDeclaration(ancestor) || + Node.isFunctionExpression(ancestor) || + Node.isArrowFunction(ancestor) || + Node.isMethodDeclaration(ancestor) + ) + ?.getStart(); + const assertionScope = functionScopeStart(assertion); + const declaration = assertion + .getSourceFile() + .getDescendantsOfKind(SyntaxKind.VariableDeclaration) + .filter( + (candidate) => + candidate.getName() === identifier && + candidate.getStart() < assertion.getStart() && + functionScopeStart(candidate) === assertionScope + ) + .at(-1); + return declaration?.getInitializer()?.getText().replace(/\s+/g, ' ') ?? identifier; +} + +export function isRowNarrowing(assertion: AsExpression): boolean { + const source = sourceExpressionText(assertion); + if ( + Node.isAsExpression(assertion.getExpression()) && + typeText(assertion.getExpression()) === 'unknown' + ) + return false; + return rowSourcePatterns.some((pattern) => pattern.test(source)); +} + +function isExternalPayloadNarrowing(assertion: AsExpression): boolean { + const source = sourceExpressionText(assertion); + return externalPayloadPatterns.some((pattern) => pattern.test(source)); +} + +function isSafe(assertion: AsExpression): boolean { + if (typeText(assertion) === 'unknown') return true; + const expression = assertion.getExpression(); + if (Node.isAsExpression(expression) && typeText(expression) === 'unknown') return true; + if (hasValidationGuardBefore(assertion)) return true; + const parentCall = assertion.getFirstAncestorByKind(SyntaxKind.CallExpression); + return parentCall ? isSanctionedHelperCall(parentCall) : false; +} + +function add( + findings: Finding[], + rule: SemanticRule, + sf: SourceFile, + root: string, + node: Node, + message: string +) { + findings.push({ + rule, + file: relative(root, sf.getFilePath()).replaceAll('\\', '/'), + line: node.getStartLineNumber(), + message, + code: nearestStatementText(node).replace(/\s+/g, ' ').slice(0, 160), + }); +} + +export function auditRuntimeBoundarySemantics(root = ROOT, scope = DEFAULT_SCOPE): Finding[] { + const project = createProject(root, trackedFiles(root, scope)); + const findings: Finding[] = []; + + for (const sf of project + .getSourceFiles() + .sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()))) { + for (const assertion of sf.getDescendantsOfKind(SyntaxKind.AsExpression)) { + if (isSafe(assertion)) continue; + if (isRowNarrowing(assertion)) { + add( + findings, + 'unvalidated-row-narrowing', + sf, + root, + assertion, + 'D1/Durable Object row result is narrowed without a guard, schema parse, or sanctioned row mapper.' + ); + } else if (isExternalPayloadNarrowing(assertion)) { + add( + findings, + 'blind-external-payload-narrowing', + sf, + root, + assertion, + 'External JSON/payload value is narrowed without a guard, schema parse, or sanctioned helper.' + ); + } + } + } + + return findings.sort( + (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule) + ); +} + +function main() { + const scopeArg = process.argv.slice(2).find((arg) => arg.startsWith('--scope=')); + const shouldFail = process.argv.includes('--fail-on-findings'); + const showDetails = shouldFail || process.argv.includes('--details'); + const scope = scopeArg?.slice('--scope='.length) ?? DEFAULT_SCOPE; + const findings = auditRuntimeBoundarySemantics(ROOT, scope); + if (findings.length === 0) { + console.log(`Runtime-boundary semantic checks passed for ${scope}.`); + return; + } + if (showDetails) { + for (const finding of findings) { + console.error(`${finding.file}:${finding.line} ${finding.rule}: ${finding.message}`); + console.error(` ${finding.code}`); + } + } + const counts = Object.fromEntries( + [...new Set(findings.map((finding) => finding.rule))] + .sort((left, right) => left.localeCompare(right)) + .map((rule) => [rule, findings.filter((finding) => finding.rule === rule).length]) + ); + console.log( + `Runtime-boundary semantic shadow: ${findings.length} advisory diagnostic(s) in ${scope} ` + + `(${Object.entries(counts) + .map(([rule, count]) => `${rule}=${count}`) + .join(', ')}).` + ); + if (!showDetails) console.log('Run with --details for deterministic file:line diagnostics.'); + if (shouldFail) process.exit(1); +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); diff --git a/scripts/quality/check-secret-scan-policy.test.ts b/scripts/quality/check-secret-scan-policy.test.ts new file mode 100644 index 0000000000..647dbf5295 --- /dev/null +++ b/scripts/quality/check-secret-scan-policy.test.ts @@ -0,0 +1,145 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + evaluateGitleaksFindings, + gitleaksArgsForMode, + redactSecretScanOutput, + type SecretFindingBaseline, + secretFindingDigest, +} from './check-secret-scan-policy'; +import { createCurrentTreeSnapshot } from './run-gitleaks'; + +const syntheticFinding = { + RuleID: 'synthetic-rule', + File: 'fixtures/example.txt', + StartLine: 1, + Secret: 'synthetic-not-a-real-secret-value', + Match: 'TOKEN=synthetic-not-a-real-secret-value', +}; + +function baseline(expiresAt = '2099-01-01T00:00:00.000Z'): SecretFindingBaseline { + const digest = secretFindingDigest(syntheticFinding); + if (!digest) throw new Error('Synthetic finding must produce a digest.'); + return { + version: 1, + matcherVersion: 'gitleaks-finding-v2-unredacted', + baseCommit: 'fixture', + groups: [ + { + classification: 'synthetic-test-fixture', + reason: 'Fixture exercises secret-redaction behavior without a live credential.', + owner: 'security', + reviewedAt: '2026-08-09T00:00:00.000Z', + expiresAt, + digests: [digest], + }, + ], + }; +} + +describe('secret scan policy', () => { + it('fails closed on new findings without returning secret metadata', () => { + const result = evaluateGitleaksFindings(JSON.stringify([syntheticFinding])); + + expect(result).toMatchObject({ + ok: false, + totalFindingCount: 1, + reviewedFindingCount: 0, + newFindingCount: 1, + }); + expect(JSON.stringify(result)).not.toContain('synthetic-not-a-real-secret-value'); + expect(JSON.stringify(result)).not.toContain('fixtures/example.txt'); + expect(JSON.stringify(result)).not.toContain('synthetic-rule'); + }); + + it('permits only the exact reviewed finding digest', () => { + const reviewed = evaluateGitleaksFindings( + JSON.stringify([syntheticFinding]), + JSON.stringify(baseline()), + new Date('2026-08-09T12:00:00.000Z') + ); + const changed = evaluateGitleaksFindings( + JSON.stringify([{ ...syntheticFinding, Secret: 'changed-fixture-value' }]), + JSON.stringify(baseline()), + new Date('2026-08-09T12:00:00.000Z') + ); + + expect(reviewed).toMatchObject({ ok: true, reviewedFindingCount: 1, newFindingCount: 0 }); + expect(changed).toMatchObject({ ok: false, reviewedFindingCount: 0, newFindingCount: 1 }); + }); + + it('rejects redacted findings because location-only hashes are forgeable', () => { + const result = evaluateGitleaksFindings( + JSON.stringify([{ ...syntheticFinding, Secret: 'REDACTED', Match: 'TOKEN=REDACTED' }]), + JSON.stringify(baseline()), + new Date('2026-08-09T12:00:00.000Z') + ); + + expect(result).toMatchObject({ ok: false, reviewedFindingCount: 0, newFindingCount: 1 }); + expect(result.errors).toContain('Gitleaks output contained an incomplete finding.'); + }); + + it('fails closed when a reviewed exemption expires', () => { + const result = evaluateGitleaksFindings( + JSON.stringify([syntheticFinding]), + JSON.stringify(baseline('2026-08-09T11:59:59.000Z')), + new Date('2026-08-09T12:00:00.000Z') + ); + + expect(result.ok).toBe(false); + expect(result.errors).toEqual(['A reviewed Gitleaks baseline entry has expired.']); + }); + + it('redacts raw command output before logs or artifacts can expose values', () => { + const redacted = redactSecretScanOutput( + '{"Secret":"synthetic-not-a-real-secret-value","Match":"TOKEN=synthetic-not-a-real-secret-value"}' + ); + + expect(redacted).not.toContain('synthetic-not-a-real-secret-value'); + expect(redacted).toContain('[REDACTED]'); + }); + + it('uses current-tree and explicit PR-range modes without full-history scan arguments', () => { + expect(gitleaksArgsForMode('current-tree')).toEqual([ + 'dir', + '.', + '--no-banner', + '--no-color', + '--report-format=json', + ]); + expect(gitleaksArgsForMode('pr-range', 'origin/base..HEAD')).toEqual([ + 'git', + '.', + '--log-opts', + 'origin/base..HEAD', + '--no-banner', + '--no-color', + '--report-format=json', + ]); + }); + + it.runIf(process.platform !== 'win32')( + 'snapshots symlink blobs without following targets outside the repository', + () => { + const root = mkdtempSync(join(tmpdir(), 'sam-gitleaks-source-')); + const snapshot = mkdtempSync(join(tmpdir(), 'sam-gitleaks-snapshot-')); + const outside = join(tmpdir(), 'sam-gitleaks-outside.txt'); + writeFileSync(outside, 'outside file contents must not be copied'); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + symlinkSync(outside, join(root, 'tracked-link')); + execFileSync('git', ['add', 'tracked-link'], { cwd: root }); + + createCurrentTreeSnapshot(root, snapshot); + + expect(readFileSync(join(snapshot, 'tracked-link'), 'utf8')).toBe(outside); + expect(readFileSync(join(snapshot, 'tracked-link'), 'utf8')).not.toContain( + 'outside file contents' + ); + } + ); +}); diff --git a/scripts/quality/check-secret-scan-policy.ts b/scripts/quality/check-secret-scan-policy.ts new file mode 100644 index 0000000000..13ef17dfe2 --- /dev/null +++ b/scripts/quality/check-secret-scan-policy.ts @@ -0,0 +1,222 @@ +import { createHash } from 'node:crypto'; + +export interface SecretFinding { + RuleID?: string; + Description?: string; + File?: string; + StartLine?: number; + EndLine?: number; + Secret?: string; + Match?: string; +} + +export interface ReviewedSecretFindingGroup { + classification: 'code-identifier' | 'documented-example' | 'synthetic-test-fixture'; + reason: string; + owner: string; + reviewedAt: string; + expiresAt: string; + digests: string[]; +} + +export interface SecretFindingBaseline { + version: 1; + matcherVersion: 'gitleaks-finding-v2-unredacted'; + baseCommit: string; + groups: ReviewedSecretFindingGroup[]; +} + +export interface SecretScanResult { + ok: boolean; + totalFindingCount: number; + reviewedFindingCount: number; + newFindingCount: number; + errors: string[]; +} + +export function redactSecretScanOutput(output: string): string { + return output + .replace(/("Secret"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"') + .replace(/("Match"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"') + .replace(/([A-Za-z0-9_]*SECRET[A-Za-z0-9_]*=)[^\s]+/gi, '$1[REDACTED]') + .replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/gi, '$1[REDACTED]'); +} + +function normalizeFindingPath(file: string): string { + return file.replaceAll('\\', '/').replace(/^\.\//, ''); +} + +export function secretFindingDigest(finding: SecretFinding): string | undefined { + if ( + typeof finding.RuleID !== 'string' || + typeof finding.File !== 'string' || + !Number.isInteger(finding.StartLine) || + typeof finding.Secret !== 'string' || + typeof finding.Match !== 'string' + ) { + return undefined; + } + if (/\[?REDACTED\]?/i.test(finding.Secret) || /\[?REDACTED\]?/i.test(finding.Match)) { + return undefined; + } + + return createHash('sha256') + .update( + JSON.stringify([ + 'gitleaks-finding-v2-unredacted', + finding.RuleID, + normalizeFindingPath(finding.File), + finding.StartLine, + finding.Secret, + finding.Match, + ]) + ) + .digest('hex'); +} + +function parseBaseline(rawJson: string, now: Date): { digests: Set; errors: string[] } { + let parsed: unknown; + try { + parsed = JSON.parse(rawJson) as unknown; + } catch { + return { digests: new Set(), errors: ['The reviewed Gitleaks baseline was not valid JSON.'] }; + } + + const baseline = parsed as Partial; + if ( + typeof baseline !== 'object' || + baseline === null || + baseline.version !== 1 || + baseline.matcherVersion !== 'gitleaks-finding-v2-unredacted' || + typeof baseline.baseCommit !== 'string' || + !Array.isArray(baseline.groups) + ) { + return { digests: new Set(), errors: ['The reviewed Gitleaks baseline schema was invalid.'] }; + } + + const digests = new Set(); + for (const group of baseline.groups) { + const reviewed = group as Partial; + const expiry = + typeof reviewed.expiresAt === 'string' ? new Date(reviewed.expiresAt) : undefined; + if ( + (reviewed.classification !== 'code-identifier' && + reviewed.classification !== 'documented-example' && + reviewed.classification !== 'synthetic-test-fixture') || + typeof reviewed.reason !== 'string' || + reviewed.reason.length === 0 || + typeof reviewed.owner !== 'string' || + reviewed.owner.length === 0 || + typeof reviewed.reviewedAt !== 'string' || + Number.isNaN(new Date(reviewed.reviewedAt).valueOf()) || + !expiry || + Number.isNaN(expiry.valueOf()) || + !Array.isArray(reviewed.digests) || + reviewed.digests.length === 0 || + reviewed.digests.some( + (digest) => typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) + ) + ) { + return { digests: new Set(), errors: ['A reviewed Gitleaks baseline entry was invalid.'] }; + } + if (expiry <= now) { + return { digests: new Set(), errors: ['A reviewed Gitleaks baseline entry has expired.'] }; + } + for (const digest of reviewed.digests) { + if (digests.has(digest)) { + return { + digests: new Set(), + errors: ['The reviewed Gitleaks baseline has a duplicate entry.'], + }; + } + digests.add(digest); + } + } + + return { digests, errors: [] }; +} + +export function evaluateGitleaksFindings( + rawJson: string, + baselineJson = '{"version":1,"matcherVersion":"gitleaks-finding-v2-unredacted","baseCommit":"none","groups":[]}', + now = new Date() +): SecretScanResult { + let parsed: unknown; + try { + parsed = rawJson.trim() ? (JSON.parse(rawJson) as unknown) : []; + } catch { + return { + ok: false, + totalFindingCount: 0, + reviewedFindingCount: 0, + newFindingCount: 0, + errors: ['Gitleaks output was not valid JSON.'], + }; + } + + if (!Array.isArray(parsed)) { + return { + ok: false, + totalFindingCount: 0, + reviewedFindingCount: 0, + newFindingCount: 0, + errors: ['Gitleaks output was not a JSON array.'], + }; + } + if (parsed.some((finding) => typeof finding !== 'object' || finding === null)) { + return { + ok: false, + totalFindingCount: parsed.length, + reviewedFindingCount: 0, + newFindingCount: parsed.length, + errors: ['Gitleaks output contained an invalid finding.'], + }; + } + + const baseline = parseBaseline(baselineJson, now); + if (baseline.errors.length > 0) { + return { + ok: false, + totalFindingCount: parsed.length, + reviewedFindingCount: 0, + newFindingCount: parsed.length, + errors: baseline.errors, + }; + } + + let reviewedFindingCount = 0; + let invalidFindingCount = 0; + for (const finding of parsed as SecretFinding[]) { + const digest = secretFindingDigest(finding); + if (!digest) invalidFindingCount += 1; + else if (baseline.digests.has(digest)) reviewedFindingCount += 1; + } + const newFindingCount = parsed.length - reviewedFindingCount; + const errors: string[] = []; + if (invalidFindingCount > 0) { + errors.push('Gitleaks output contained an incomplete finding.'); + } + if (newFindingCount > 0) { + errors.push( + `Gitleaks reported ${newFindingCount} new finding(s). Details are withheld by policy.` + ); + } + + return { + ok: errors.length === 0, + totalFindingCount: parsed.length, + reviewedFindingCount, + newFindingCount, + errors, + }; +} + +export function gitleaksArgsForMode(mode: 'current-tree' | 'pr-range', range?: string): string[] { + // Exact baselines must include the actual match bytes. The caller captures + // the report in a private temporary directory and never forwards scanner + // stdout/stderr or finding metadata to logs. + const privateReport = ['--no-banner', '--no-color', '--report-format=json']; + if (mode === 'current-tree') return ['dir', '.', ...privateReport]; + if (!range) throw new Error('PR range is required for pr-range scans'); + return ['git', '.', '--log-opts', range, ...privateReport]; +} diff --git a/scripts/quality/check-type-boundaries.test.ts b/scripts/quality/check-type-boundaries.test.ts new file mode 100644 index 0000000000..f00d933a58 --- /dev/null +++ b/scripts/quality/check-type-boundaries.test.ts @@ -0,0 +1,162 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + auditTypeBoundaries, + compareBlockingCounts, + loadBaseline, + parseBoundaryBaseline, +} from './check-type-boundaries'; + +function fixtureRepo(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'sam-type-boundary-')); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root }); + for (const [path, content] of Object.entries(files)) { + const full = join(root, path); + execFileSync('mkdir', ['-p', full.split('/').slice(0, -1).join('/')], { cwd: root }); + writeFileSync(full, content); + } + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['commit', '-m', 'fixtures'], { cwd: root }); + return root; +} + +describe('type-boundary ratchet audit', () => { + it('counts blocking and report-only classes while excluding JSON.parse as unknown', () => { + const root = fixtureRepo({ + 'src/a.ts': ` + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + async function route(c: { req: { json(): Promise } }) { + const body = await c.req.json<{ name: string }>(); + const parsed = JSON.parse('{}') as { ok: boolean }; + const unknownOnly = JSON.parse('{}') as unknown; + const broad = body as Record; + const double = broad as unknown as { x: string }; + const unsafe = navigator as any; + return { parsed, unknownOnly, double, unsafe }; + } + `, + }); + const result = auditTypeBoundaries(root); + expect(result.blockingCounts).toEqual({ + 'as-any': 1, + 'hono-req-json-generic': 1, + 'typed-json-parse': 1, + 'local-record-guard': 1, + }); + expect(result.reportOnlyCounts).toEqual({ + 'record-string-unknown': 1, + 'unknown-double-assertion': 1, + }); + }); + + it('proves N to N+1 fails by comparing current count to a lower baseline', () => { + const root = fixtureRepo({ + 'src/a.ts': `const unsafe = value as any;`, + }); + const result = auditTypeBoundaries(root); + expect( + compareBlockingCounts(result.blockingCounts, { + 'as-any': 0, + 'hono-req-json-generic': 0, + 'typed-json-parse': 0, + 'local-record-guard': 0, + }) + ).toEqual([{ class: 'as-any', allowed: 0, current: 1 }]); + }); + + it('moves and splits pass because only net counts are compared', () => { + const moved = fixtureRepo({ + 'src/a.ts': `const unsafe = value as any;`, + 'src/b.ts': `const parsed = JSON.parse('{}') as { ok: boolean };`, + }); + const split = fixtureRepo({ + 'src/moved/one.ts': `const unsafe = value as any;`, + 'src/moved/two.ts': `const parsed = JSON.parse('{}') as { ok: boolean };`, + }); + expect(auditTypeBoundaries(split).blockingCounts).toEqual( + auditTypeBoundaries(moved).blockingCounts + ); + expect( + compareBlockingCounts(auditTypeBoundaries(split).blockingCounts, { + 'as-any': 1, + 'hono-req-json-generic': 0, + 'typed-json-parse': 1, + 'local-record-guard': 0, + }) + ).toEqual([]); + }); + + it('decreases pass and repeated output is identical', () => { + const root = fixtureRepo({ + 'src/a.ts': `const parsed = JSON.parse('{}') as unknown;`, + }); + const first = auditTypeBoundaries(root); + const second = auditTypeBoundaries(root); + expect(first.blockingCounts).toEqual({ + 'as-any': 0, + 'hono-req-json-generic': 0, + 'typed-json-parse': 0, + 'local-record-guard': 0, + }); + expect(second).toEqual(first); + expect( + compareBlockingCounts(first.blockingCounts, { + 'as-any': 1, + 'hono-req-json-generic': 1, + 'typed-json-parse': 1, + 'local-record-guard': 1, + }) + ).toEqual([]); + }); + + it('includes untracked source so local checks cannot miss newly generated debt', () => { + const root = fixtureRepo({ 'src/tracked.ts': 'export const tracked = true;' }); + writeFileSync(join(root, 'src/untracked.ts'), 'export const unsafe = value as any;'); + + expect(auditTypeBoundaries(root).blockingCounts['as-any']).toBe(1); + }); + + it('fails closed when the baseline is missing or structurally incomplete', () => { + expect(() => loadBaseline(join(tmpdir(), 'missing-sam-type-boundary-baseline.json'))).toThrow(); + expect(() => + parseBoundaryBaseline( + JSON.stringify({ + metadata: { + owner: 'quality', + backlog: 'task', + review: 'review', + blockingClasses: [], + reportOnlyClasses: [], + }, + counts: {}, + reportOnlyCounts: {}, + }) + ) + ).toThrow('Invalid type-boundary baseline'); + }); + + it('matches known function and arrow record guards, including array-accepting variants', () => { + const root = fixtureRepo({ + 'src/guards.ts': ` + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } + const isObject = (candidate: unknown): candidate is object => + typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate); + function isRecordWithSemantics(value: unknown): value is Record { + return typeof value === 'object' && value !== null && Object.keys(value).length > 0; + } + `, + }); + expect(auditTypeBoundaries(root).blockingCounts['local-record-guard']).toBe(2); + }); +}); diff --git a/scripts/quality/check-type-boundaries.ts b/scripts/quality/check-type-boundaries.ts new file mode 100644 index 0000000000..34543a25ca --- /dev/null +++ b/scripts/quality/check-type-boundaries.ts @@ -0,0 +1,422 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + type ArrowFunction, + type AsExpression, + type CallExpression, + type FunctionDeclaration, + type FunctionExpression, + Node, + Project, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; + +type BlockingClass = 'as-any' | 'hono-req-json-generic' | 'typed-json-parse' | 'local-record-guard'; +type ReportOnlyClass = 'record-string-unknown' | 'unknown-double-assertion'; +type BoundaryClass = BlockingClass | ReportOnlyClass; + +export interface Finding { + class: BoundaryClass; + file: string; + line: number; + text: string; +} + +export interface Baseline { + metadata: { + owner: string; + backlog: string; + review: string; + blockingClasses: BlockingClass[]; + reportOnlyClasses: ReportOnlyClass[]; + }; + counts: Record; + reportOnlyCounts: Record; +} + +export interface AuditResult { + findings: Finding[]; + blockingCounts: Record; + reportOnlyCounts: Record; +} + +const __filename = fileURLToPath(import.meta.url); +const ROOT = resolve(__filename, '../..', '..'); +const DEFAULT_BASELINE = resolve(ROOT, 'scripts/quality/type-boundary-baseline.json'); + +const blockingClasses: BlockingClass[] = [ + 'as-any', + 'hono-req-json-generic', + 'typed-json-parse', + 'local-record-guard', +]; +const reportOnlyClasses: ReportOnlyClass[] = ['record-string-unknown', 'unknown-double-assertion']; + +function zeroBlocking(): Record { + return { + 'as-any': 0, + 'hono-req-json-generic': 0, + 'typed-json-parse': 0, + 'local-record-guard': 0, + }; +} + +function zeroReportOnly(): Record { + return { + 'record-string-unknown': 0, + 'unknown-double-assertion': 0, + }; +} + +function trackedFiles(root: string, scope?: string): string[] { + const extensionPatterns = ['*.ts', '*.tsx', '*.mts', '*.cts']; + const normalizedScope = scope?.replaceAll('\\', '/').replace(/\/$/, ''); + const pathspecs = normalizedScope + ? extensionPatterns.map((pattern) => `${normalizedScope}/**/${pattern}`) + : extensionPatterns; + const args = ['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', ...pathspecs]; + const output = execFileSync('/usr/bin/git', args, { cwd: root, encoding: 'utf8' }); + return output + .split('\0') + .filter(Boolean) + .filter((file) => !isExcluded(file)) + .filter((file) => { + const sourcePath = resolve(root, file); + if (!existsSync(sourcePath)) return false; + const stat = lstatSync(sourcePath); + if (!stat.isFile()) throw new Error(`Boundary source is not a regular file: ${file}`); + return true; + }) + .sort((left, right) => left.localeCompare(right)); +} + +function isExcluded(file: string): boolean { + return ( + file.includes('/node_modules/') || + file.includes('/fixtures/') || + file.includes('/tests/') || + file.endsWith('.test.ts') || + file.endsWith('.test.tsx') || + file.endsWith('.spec.ts') || + file.endsWith('.spec.tsx') || + file.endsWith('.d.ts') || + file.includes('/dist/') + ); +} + +function createProject(root: string, files: string[]): Project { + const project = new Project({ + skipAddingFilesFromTsConfig: true, + compilerOptions: { allowJs: false, jsx: 4 }, + }); + for (const file of files) project.addSourceFileAtPath(resolve(root, file)); + return project; +} + +function add( + finding: Omit, + findings: Finding[], + sf: SourceFile, + root: string, + node: Node +) { + findings.push({ + ...finding, + file: relative(root, sf.getFilePath()).replaceAll('\\', '/'), + line: node.getStartLineNumber(), + }); +} + +function typeText(node: AsExpression): string { + return node.getTypeNode()?.getText().replace(/\s+/g, ' ').trim() ?? ''; +} + +function isJsonParseCall(node: Node): boolean { + while (Node.isParenthesizedExpression(node)) node = node.getExpression(); + if (!Node.isCallExpression(node)) return false; + const expression = node.getExpression(); + return Node.isPropertyAccessExpression(expression) && expression.getText() === 'JSON.parse'; +} + +function isHonoReqJsonCall(call: CallExpression): boolean { + if (call.getTypeArguments().length === 0) return false; + const expression = call.getExpression(); + if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== 'json') return false; + const receiver = expression.getExpression(); + return Node.isPropertyAccessExpression(receiver) && receiver.getName() === 'req'; +} + +type RecordGuardFunction = FunctionDeclaration | FunctionExpression | ArrowFunction; + +function returnExpressionText(fn: RecordGuardFunction): string | undefined { + const body = fn.getBody(); + if (!Node.isBlock(body)) return body.getText(); + const statements = body.getStatements(); + if (statements.length !== 1 || !Node.isReturnStatement(statements[0])) return undefined; + return statements[0].getExpression()?.getText(); +} + +function stripOuterParentheses(value: string): string { + let result = value; + while (result.startsWith('(') && result.endsWith(')')) result = result.slice(1, -1); + return result; +} + +function isLocalRecordGuard(fn: RecordGuardFunction, name: string | undefined): boolean { + if (name !== 'isRecord' && name !== 'isObject') return false; + const parameter = fn.getParameters()[0]?.getNameNode(); + if (!parameter || !Node.isIdentifier(parameter)) return false; + const parameterName = parameter.getText(); + const returnType = fn.getReturnTypeNode()?.getText().replace(/\s+/g, ' ').trim() ?? ''; + if (!returnType.startsWith(`${parameterName} is `)) return false; + + const expression = returnExpressionText(fn); + if (!expression) return false; + const clauses = expression.replace(/\s+/g, '').split('&&').map(stripOuterParentheses); + const objectChecks = new Set([ + `typeof${parameterName}==='object'`, + `typeof${parameterName}==="object"`, + ]); + const nullChecks = new Set([`${parameterName}!==null`, `${parameterName}!=null`]); + const arrayCheck = `!Array.isArray(${parameterName})`; + return ( + clauses.length >= 2 && + clauses.every( + (clause) => objectChecks.has(clause) || nullChecks.has(clause) || clause === arrayCheck + ) && + clauses.some((clause) => objectChecks.has(clause)) && + clauses.some((clause) => nullChecks.has(clause)) + ); +} + +function countInPlace( + counts: Record, + klass: T, + finding: Omit, + findings: Finding[], + sf: SourceFile, + root: string, + node: Node +) { + counts[klass] += 1; + add(finding, findings, sf, root, node); +} + +export function auditTypeBoundaries(root = ROOT, scope?: string): AuditResult { + const files = trackedFiles(root, scope); + const project = createProject(root, files); + const findings: Finding[] = []; + const blockingCounts = zeroBlocking(); + const reportOnlyCounts = zeroReportOnly(); + + for (const sf of project + .getSourceFiles() + .sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()))) { + for (const assertion of sf.getDescendantsOfKind(SyntaxKind.AsExpression)) { + const asserted = typeText(assertion); + if (asserted === 'any') { + countInPlace( + blockingCounts, + 'as-any', + { class: 'as-any', text: assertion.getText() }, + findings, + sf, + root, + assertion + ); + } + if (isJsonParseCall(assertion.getExpression()) && asserted !== 'unknown') { + countInPlace( + blockingCounts, + 'typed-json-parse', + { class: 'typed-json-parse', text: assertion.getText() }, + findings, + sf, + root, + assertion + ); + } + if (/^Record\s*<\s*string\s*,\s*unknown\s*>$/.test(asserted)) { + countInPlace( + reportOnlyCounts, + 'record-string-unknown', + { class: 'record-string-unknown', text: assertion.getText() }, + findings, + sf, + root, + assertion + ); + } + const assertionExpression = assertion.getExpression(); + if (Node.isAsExpression(assertionExpression) && typeText(assertionExpression) === 'unknown') { + countInPlace( + reportOnlyCounts, + 'unknown-double-assertion', + { class: 'unknown-double-assertion', text: assertion.getText() }, + findings, + sf, + root, + assertion + ); + } + } + + for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) { + if (!isHonoReqJsonCall(call)) continue; + countInPlace( + blockingCounts, + 'hono-req-json-generic', + { class: 'hono-req-json-generic', text: call.getText() }, + findings, + sf, + root, + call + ); + } + + const guardCandidates: Array<{ fn: RecordGuardFunction; name: string | undefined }> = sf + .getFunctions() + .map((fn) => ({ fn, name: fn.getName() })); + for (const declaration of sf.getVariableDeclarations()) { + const initializer = declaration.getInitializer(); + if ( + initializer && + (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) + ) { + guardCandidates.push({ fn: initializer, name: declaration.getName() }); + } + } + for (const { fn, name } of guardCandidates) { + if (!isLocalRecordGuard(fn, name)) continue; + countInPlace( + blockingCounts, + 'local-record-guard', + { class: 'local-record-guard', text: fn.getText().split('\n')[0] }, + findings, + sf, + root, + fn + ); + } + } + + findings.sort( + (a, b) => a.class.localeCompare(b.class) || a.file.localeCompare(b.file) || a.line - b.line + ); + return { findings, blockingCounts, reportOnlyCounts }; +} + +export interface RatchetFailure { + class: BlockingClass; + allowed: number; + current: number; +} + +export function compareBlockingCounts( + current: Record, + allowed: Record +): RatchetFailure[] { + return blockingClasses + .filter((klass) => current[klass] > allowed[klass]) + .map((klass) => ({ class: klass, allowed: allowed[klass], current: current[klass] })); +} + +export function parseBoundaryBaseline(raw: string, path = ''): Baseline { + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new Error(`Invalid type-boundary baseline at ${path}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid type-boundary baseline at ${path}`); + } + const baseline = parsed as Partial; + const metadata = baseline.metadata; + const hasClasses = (value: unknown, expected: T[]): value is T[] => + Array.isArray(value) && + value.length === expected.length && + expected.every((klass) => value.includes(klass)); + const validCounts = ( + value: unknown, + classes: T[] + ): value is Record => + typeof value === 'object' && + value !== null && + classes.every((klass) => { + const count = Reflect.get(value, klass) as unknown; + return Number.isInteger(count) && (count as number) >= 0; + }); + if ( + typeof metadata !== 'object' || + metadata === null || + typeof metadata.owner !== 'string' || + !metadata.owner.trim() || + typeof metadata.backlog !== 'string' || + !metadata.backlog.trim() || + typeof metadata.review !== 'string' || + !metadata.review.trim() || + !hasClasses(metadata.blockingClasses, blockingClasses) || + !hasClasses(metadata.reportOnlyClasses, reportOnlyClasses) || + !validCounts(baseline.counts, blockingClasses) || + !validCounts(baseline.reportOnlyCounts, reportOnlyClasses) + ) { + throw new Error(`Invalid type-boundary baseline at ${path}`); + } + return baseline as Baseline; +} + +export function loadBaseline(path: string): Baseline { + return parseBoundaryBaseline(readFileSync(path, 'utf8'), path); +} + +function printSummary(result: AuditResult) { + console.log('Type-boundary audit counts:'); + for (const klass of blockingClasses) + console.log(` ${klass}: ${result.blockingCounts[klass]} blocking`); + for (const klass of reportOnlyClasses) + console.log(` ${klass}: ${result.reportOnlyCounts[klass]} report-only`); +} + +function main() { + const args = process.argv.slice(2); + const baselineArg = args.find((arg) => arg.startsWith('--baseline=')); + const scopeArg = args.find((arg) => arg.startsWith('--scope=')); + const baselinePath = baselineArg + ? resolve(ROOT, baselineArg.slice('--baseline='.length)) + : DEFAULT_BASELINE; + const scope = scopeArg?.slice('--scope='.length); + + const result = auditTypeBoundaries(ROOT, scope); + printSummary(result); + + const baseline = loadBaseline(baselinePath); + const failures = compareBlockingCounts(result.blockingCounts, baseline.counts).map( + ({ class: klass, current, allowed }) => { + const over = current - allowed; + const examples = result.findings + .filter((finding) => finding.class === klass) + .slice(0, Math.max(5, over)) + .map( + (finding) => + ` ${finding.file}:${finding.line} ${finding.text.replace(/\s+/g, ' ').slice(0, 140)}` + ) + .join('\n'); + return ( + `${klass} increased from baseline ${allowed} to ${current}.\n${examples}\n` + + ' Keep the value unknown until jsonValidator, parseWithSchema, readResponseJson, a row mapper, or another sanctioned runtime helper validates it.' + ); + } + ); + + if (failures.length > 0) { + console.error('\nType-boundary ratchet failed:\n' + failures.join('\n\n')); + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); diff --git a/scripts/quality/ci-quality-program.test.ts b/scripts/quality/ci-quality-program.test.ts new file mode 100644 index 0000000000..5bb097fa74 --- /dev/null +++ b/scripts/quality/ci-quality-program.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const ci = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8'); +const parsedRootManifest: unknown = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8') +); +if ( + typeof parsedRootManifest !== 'object' || + parsedRootManifest === null || + !('scripts' in parsedRootManifest) || + typeof parsedRootManifest.scripts !== 'object' || + parsedRootManifest.scripts === null +) { + throw new Error('Root package manifest scripts are invalid.'); +} +const rootScripts = Object.fromEntries( + Object.entries(parsedRootManifest.scripts).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) +); + +function jobBlock(workflow: string, jobName: string): string { + const match = workflow.match( + new RegExp(String.raw`\n ${jobName}:\n[\s\S]*?(?=\n [a-zA-Z0-9_-]+:\n|\n*$)`) + ); + expect(match?.[0], `missing ${jobName} job`).toBeDefined(); + if (!match) throw new Error(`Missing ${jobName} job.`); + return match[0]; +} + +describe('deterministic quality-program CI wiring', () => { + it('uses the same check:fast leaf commands in the blocking lint job', () => { + expect(rootScripts['check:fast']).toBe( + 'pnpm format:check && pnpm lint:oxlint && pnpm lint && pnpm quality:type-boundaries' + ); + const lint = jobBlock(ci, 'lint'); + const commands = [ + 'pnpm format:check', + 'pnpm lint:oxlint', + 'pnpm lint', + 'pnpm quality:type-boundaries', + ]; + let previous = -1; + for (const command of commands) { + const current = lint.indexOf(`run: ${command}\n`); + expect(current, `${command} must be an executable lint-job leaf`).toBeGreaterThan(previous); + previous = current; + } + expect(lint).toContain('run: pnpm --filter @simple-agent-manager/eslint-plugin-sam test'); + expect(lint).toContain('pnpm lint:oxlint:sam-shadow'); + expect(lint).toContain('continue-on-error: true'); + }); + + it('blocks current-tree/PR-range secrets and changed Go modules through privacy-safe wrappers', () => { + const secretScan = jobBlock(ci, 'secret-scan'); + expect(secretScan).toContain('fetch-depth: 0'); + expect(secretScan).toContain('pnpm quality:gitleaks:current'); + expect(secretScan).toContain('pnpm quality:gitleaks:pr'); + expect(secretScan).not.toContain('upload-artifact'); + + const govulncheck = jobBlock(ci, 'go-vulnerability-diff'); + expect(govulncheck).toContain('needs: [changes]'); + expect(govulncheck).toContain("needs.changes.outputs.go-modules == 'true'"); + expect(govulncheck).toContain('pnpm quality:govulncheck-diff'); + }); + + it('enforces dependency evidence and keeps semantic findings in shadow mode', () => { + const quality = jobBlock(ci, 'code-quality'); + expect(quality).toContain('fetch-depth: 0'); + expect(quality).toContain('pnpm quality:direct-dependency-evidence'); + expect(quality).toContain('pnpm quality:runtime-boundary-semantics'); + }); +}); diff --git a/scripts/quality/deploy-reusable-workflow.test.ts b/scripts/quality/deploy-reusable-workflow.test.ts index af84d64a12..808ddba6d8 100644 --- a/scripts/quality/deploy-reusable-workflow.test.ts +++ b/scripts/quality/deploy-reusable-workflow.test.ts @@ -109,6 +109,15 @@ function runWorkersDevSubdomainStep(httpCode: number): { output: string; status: } describe('deploy reusable workflow', () => { + it('uses the lockfile-pinned Wrangler binary for the Pulumi state bucket preflight', () => { + const block = stepBlock('Create Pulumi State Bucket \\(if not exists\\)'); + + expect(block).toContain( + 'pnpm --filter @simple-agent-manager/api exec wrangler r2 bucket create "$BUCKET_NAME"' + ); + expect(block).not.toContain('npx wrangler'); + }); + it('behaviorally verifies the checked-out SHA and skip-agent output', () => { const tmp = mkdtempSync(join(tmpdir(), 'sam-deploy-sha-')); const script = stepRunScript('Resolve and Verify Deployment SHA'); diff --git a/scripts/quality/direct-dependency-evidence.json b/scripts/quality/direct-dependency-evidence.json new file mode 100644 index 0000000000..447dd2a96a --- /dev/null +++ b/scripts/quality/direct-dependency-evidence.json @@ -0,0 +1,67 @@ +{ + "$schema": "./fixtures/supply-chain/direct-dependency-evidence.schema.json", + "npm": { + "@astrojs/check": { + "registryUrl": "https://www.npmjs.com/package/@astrojs/check", + "necessity": "Validates Astro templates and embedded TypeScript." + }, + "@eslint/js": { + "registryUrl": "https://www.npmjs.com/package/@eslint/js", + "necessity": "Provides supported ESLint flat recommended configuration." + }, + "@typescript-eslint/eslint-plugin": { + "registryUrl": "https://www.npmjs.com/package/@typescript-eslint/eslint-plugin", + "necessity": "Hosts supported TypeScript lint rules." + }, + "@typescript-eslint/parser": { + "registryUrl": "https://www.npmjs.com/package/@typescript-eslint/parser", + "necessity": "Parses TypeScript for ESLint rules and fixtures." + }, + "eslint": { + "registryUrl": "https://www.npmjs.com/package/eslint", + "necessity": "Runs authoritative lint and local rule fixtures." + }, + "eslint-plugin-astro": { + "registryUrl": "https://www.npmjs.com/package/eslint-plugin-astro", + "necessity": "Parses and lints tracked Astro templates." + }, + "eslint-plugin-react": { + "registryUrl": "https://www.npmjs.com/package/eslint-plugin-react", + "necessity": "Preserves existing React lint findings." + }, + "eslint-plugin-react-hooks": { + "registryUrl": "https://www.npmjs.com/package/eslint-plugin-react-hooks", + "necessity": "Preserves existing React Hooks lint findings." + }, + "globals": { + "registryUrl": "https://www.npmjs.com/package/globals", + "necessity": "Defines explicit flat-config runtime globals." + }, + "oxlint": { + "registryUrl": "https://www.npmjs.com/package/oxlint", + "necessity": "Measures native lint findings in shadow mode." + }, + "typescript": { + "registryUrl": "https://www.npmjs.com/package/typescript", + "necessity": "Checks local plugin JavaScript declarations." + }, + "tsx": { + "registryUrl": "https://www.npmjs.com/package/tsx", + "necessity": "Runs the deterministic Astro validation wrapper." + }, + "typescript-eslint": { + "registryUrl": "https://www.npmjs.com/package/typescript-eslint", + "necessity": "Provides supported ESLint flat TypeScript configuration." + }, + "vitest": { + "registryUrl": "https://www.npmjs.com/package/vitest", + "necessity": "Runs local plugin and quality checker fixtures." + } + }, + "go": { + "golang.org/x/vuln/cmd/govulncheck": { + "registryUrl": "https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck", + "necessity": "Pins the official govulncheck CLI used by the diff-local Go vulnerability gate." + } + } +} diff --git a/scripts/quality/eslint-config-parity.test.ts b/scripts/quality/eslint-config-parity.test.ts new file mode 100644 index 0000000000..6b6537d015 --- /dev/null +++ b/scripts/quality/eslint-config-parity.test.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ESLint } from 'eslint'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +const lintText = async (code: string, filePath: string) => { + const eslint = new ESLint({ cwd: repoRoot }); + const [result] = await eslint.lintText(code, { + filePath: path.join(repoRoot, filePath), + }); + return result.messages; +}; + +describe('ESLint 8 to ESLint 9 flat-config parity', () => { + it('uses the supported ESLint 9 host', () => { + expect(ESLint.version).toMatch(/^9\./); + }); + + it('preserves the audited v7 type-only-use behavior', async () => { + const messages = await lintText( + ` + function makeFixture() { + return { id: 'fixture' }; + } + export type Fixture = ReturnType; + `, + 'apps/web/src/eslint-parity.ts' + ); + + expect( + messages.filter((message) => message.ruleId === '@typescript-eslint/no-unused-vars') + ).toEqual([]); + }); + + it('continues to reject genuinely unused runtime values', async () => { + const messages = await lintText( + 'const unusedRuntimeValue = 1; export {};', + 'apps/web/src/eslint-parity.ts' + ); + + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ruleId: '@typescript-eslint/no-unused-vars', + severity: 2, + }), + ]) + ); + }); + + it('retains legacy jsx-a11y options when lowering severity', async () => { + const messages = await lintText( + ` + export function ParityFixture() { + return ( + <> +
undefined}>blur is not a legacy handler
+
undefined}>click is a legacy handler
+ + ); + } + `, + 'apps/web/src/eslint-parity.tsx' + ); + const staticInteractionMessages = messages.filter( + (message) => message.ruleId === 'jsx-a11y/no-static-element-interactions' + ); + + expect(staticInteractionMessages).toHaveLength(1); + expect(staticInteractionMessages[0]).toMatchObject({ + line: 6, + severity: 1, + }); + }); +}); diff --git a/scripts/quality/fixtures/supply-chain/dependency-update-remove.diff b/scripts/quality/fixtures/supply-chain/dependency-update-remove.diff new file mode 100644 index 0000000000..f731aa645a --- /dev/null +++ b/scripts/quality/fixtures/supply-chain/dependency-update-remove.diff @@ -0,0 +1,9 @@ +diff --git a/apps/api/package.json b/apps/api/package.json +--- a/apps/api/package.json ++++ b/apps/api/package.json +@@ -8,2 +8,2 @@ + "dependencies": { +- "changed-lib": "1.0.0", +- "old-lib": "1.0.0" ++ "changed-lib": "1.1.0" + } diff --git a/scripts/quality/fixtures/supply-chain/direct-dependency-add.diff b/scripts/quality/fixtures/supply-chain/direct-dependency-add.diff new file mode 100644 index 0000000000..d8336231c5 --- /dev/null +++ b/scripts/quality/fixtures/supply-chain/direct-dependency-add.diff @@ -0,0 +1,7 @@ +diff --git a/apps/api/package.json b/apps/api/package.json +--- a/apps/api/package.json ++++ b/apps/api/package.json +@@ -4,0 +5,3 @@ ++ "dependencies": { ++ "left-pad": "1.3.0" ++ } diff --git a/scripts/quality/fixtures/supply-chain/direct-dependency-dev.diff b/scripts/quality/fixtures/supply-chain/direct-dependency-dev.diff new file mode 100644 index 0000000000..4fd5515c33 --- /dev/null +++ b/scripts/quality/fixtures/supply-chain/direct-dependency-dev.diff @@ -0,0 +1,7 @@ +diff --git a/apps/api/package.json b/apps/api/package.json +--- a/apps/api/package.json ++++ b/apps/api/package.json +@@ -4,0 +5,3 @@ ++ "devDependencies": { ++ "vitest": "4.0.0" ++ } diff --git a/scripts/quality/fixtures/supply-chain/direct-dependency-evidence.schema.json b/scripts/quality/fixtures/supply-chain/direct-dependency-evidence.schema.json new file mode 100644 index 0000000000..d1823e3cb4 --- /dev/null +++ b/scripts/quality/fixtures/supply-chain/direct-dependency-evidence.schema.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "npm": { "type": "object" }, + "go": { "type": "object" } + }, + "additionalProperties": false +} diff --git a/scripts/quality/fixtures/supply-chain/direct-dependency-workspace.diff b/scripts/quality/fixtures/supply-chain/direct-dependency-workspace.diff new file mode 100644 index 0000000000..7febade0da --- /dev/null +++ b/scripts/quality/fixtures/supply-chain/direct-dependency-workspace.diff @@ -0,0 +1,7 @@ +diff --git a/apps/api/package.json b/apps/api/package.json +--- a/apps/api/package.json ++++ b/apps/api/package.json +@@ -4,0 +5,3 @@ ++ "dependencies": { ++ "@simple-agent-manager/shared": "workspace:*" ++ } diff --git a/scripts/quality/fixtures/supply-chain/go-dependency-add.diff b/scripts/quality/fixtures/supply-chain/go-dependency-add.diff new file mode 100644 index 0000000000..ed3abdb184 --- /dev/null +++ b/scripts/quality/fixtures/supply-chain/go-dependency-add.diff @@ -0,0 +1,5 @@ +diff --git a/packages/vm-agent/go.mod b/packages/vm-agent/go.mod +--- a/packages/vm-agent/go.mod ++++ b/packages/vm-agent/go.mod +@@ -3,0 +4 @@ ++require golang.org/x/crypto v0.40.0 diff --git a/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/apps/www/package.json b/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/apps/www/package.json new file mode 100644 index 0000000000..eac7a64791 --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/apps/www/package.json @@ -0,0 +1,8 @@ +{ + "name": "astro-tsc-only", + "private": true, + "scripts": { + "lint": "eslint 'src/**/*.ts'", + "typecheck": "tsc --noEmit" + } +} diff --git a/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/apps/www/src/pages/index.astro b/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/apps/www/src/pages/index.astro new file mode 100644 index 0000000000..929c5eb9a5 --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/apps/www/src/pages/index.astro @@ -0,0 +1,5 @@ +--- +const title = 'Fixture'; +--- + +

{title}

diff --git a/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/pnpm-workspace.yaml b/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/pnpm-workspace.yaml new file mode 100644 index 0000000000..8ab3e17a0d --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/astro-tsc-only/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'apps/*' diff --git a/scripts/quality/fixtures/workspace-quality-coverage/covered/apps/covered/package.json b/scripts/quality/fixtures/workspace-quality-coverage/covered/apps/covered/package.json new file mode 100644 index 0000000000..7c8114357e --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/covered/apps/covered/package.json @@ -0,0 +1,8 @@ +{ + "name": "covered", + "private": true, + "scripts": { + "lint": "eslint 'src/**/*.ts'", + "typecheck": "tsc --noEmit" + } +} diff --git a/scripts/quality/fixtures/workspace-quality-coverage/covered/apps/covered/src/index.ts b/scripts/quality/fixtures/workspace-quality-coverage/covered/apps/covered/src/index.ts new file mode 100644 index 0000000000..efeee5db16 --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/covered/apps/covered/src/index.ts @@ -0,0 +1 @@ +export const value = 1; diff --git a/scripts/quality/fixtures/workspace-quality-coverage/covered/pnpm-workspace.yaml b/scripts/quality/fixtures/workspace-quality-coverage/covered/pnpm-workspace.yaml new file mode 100644 index 0000000000..8ab3e17a0d --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/covered/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'apps/*' diff --git a/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/apps/uncovered/package.json b/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/apps/uncovered/package.json new file mode 100644 index 0000000000..5c594c6b9c --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/apps/uncovered/package.json @@ -0,0 +1,7 @@ +{ + "name": "uncovered", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/apps/uncovered/src/index.ts b/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/apps/uncovered/src/index.ts new file mode 100644 index 0000000000..efeee5db16 --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/apps/uncovered/src/index.ts @@ -0,0 +1 @@ +export const value = 1; diff --git a/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/pnpm-workspace.yaml b/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/pnpm-workspace.yaml new file mode 100644 index 0000000000..8ab3e17a0d --- /dev/null +++ b/scripts/quality/fixtures/workspace-quality-coverage/missing-lint/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'apps/*' diff --git a/scripts/quality/format-baseline.json b/scripts/quality/format-baseline.json new file mode 100644 index 0000000000..38fa91d1f1 --- /dev/null +++ b/scripts/quality/format-baseline.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "formatter": "prettier@3.8.3", + "baseCommit": "8c689a6a7923f76a33d96d6272797090598d6c2d", + "extensions": [".ts", ".tsx", ".js", ".jsx", ".json", ".md"], + "unformattedFileCount": 2390, + "reviewedAt": "2026-08-09T00:00:00.000Z", + "reviewBy": "2026-11-07T00:00:00.000Z" +} diff --git a/scripts/quality/gitleaks-reviewed-baseline.json b/scripts/quality/gitleaks-reviewed-baseline.json new file mode 100644 index 0000000000..bdc75d6f84 --- /dev/null +++ b/scripts/quality/gitleaks-reviewed-baseline.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "matcherVersion": "gitleaks-finding-v2-unredacted", + "baseCommit": "8c689a6a7923f76a33d96d6272797090598d6c2d", + "groups": [ + { + "classification": "code-identifier", + "reason": "Reviewed non-secret identifiers and protocol parser markers already present on audited main; exact findings only.", + "owner": "security", + "reviewedAt": "2026-08-09T00:00:00.000Z", + "expiresAt": "2026-11-07T00:00:00.000Z", + "digests": [ + "01e0f4da644effe0c9a77ab961699ebdbcfd756e098b8b40fd947ad03d05f338", + "1331d6c0435d788a6be558ecc57678d47746a20de0faffc4d19d384d4081acf7", + "1494325ed0dbdb95224065d7a3823b594c5703e29f7d71114c42430118211ce3", + "43311ab05444a3deed84ddf803f7d4a523e4ab4a97d044200e84fedc73661f5f", + "59a04ed4110887b9251aace7ec061d099c85febe627099ab51f6da9eff130fa8", + "5df62a0d499038d1b76cc34e61c556cb7a0e8710c8845445576cb4e79b89be4b", + "6e71e0fbc4dc054f19b21e28442c6a4067d1451c5ea9fe53cb9c1db6f7f1f319", + "8044309cbc66656e48be6c8002fbcd590d94402efc1cbe8e7a190fc37dbe6fbb", + "b2d8273e0993f51d66739f7c3aed3a7773f19deacaddf93cd1af2d6422d55048" + ] + }, + { + "classification": "documented-example", + "reason": "Reviewed non-live documentation examples already present on audited main; exact findings only.", + "owner": "security", + "reviewedAt": "2026-08-09T00:00:00.000Z", + "expiresAt": "2026-11-07T00:00:00.000Z", + "digests": [ + "21b2baa363c78a8d2fbbf78ddd5eecca2b70b37963a71df4a550215d58b53259", + "8dd3a631fc752a17596f955300a74f538d73d9deea3588b16b5bfdd08ca562d8" + ] + }, + { + "classification": "synthetic-test-fixture", + "reason": "Reviewed synthetic fixtures already present on audited main; exact findings only.", + "owner": "security", + "reviewedAt": "2026-08-09T00:00:00.000Z", + "expiresAt": "2026-11-07T00:00:00.000Z", + "digests": [ + "0446ae0d1d29d010939f1a51da7e9bdbdd985c7c9240e8593ec797bf6251a266", + "08b6973beb0309ed23f7288fa1a81b5e4d8186dd2259960aa8d2fff7710d105a", + "09c40905b818f288f2eb9c90edbe10cd8bf5061aadbb162a5ec0821d195338e8", + "110a101b7443fd2b7bdaeade081aeffd784ee639475a2f770ab53c3befb8ed17", + "1726f89f4c1c6f6115133fd63064839a0f6d2bb0463a2b0b926cf5f85a206d51", + "1890267ac0262af98886e14e4f70d5d8d5c9294deccb78d5d6ad686b8d9f3b68", + "20f6fd5918d54ffab3b9e77c5efb81ced69ee3bc06471e9cba29d008a4a1c137", + "28a56effde34ab107713ed618f3c9a27a53df8899e2490ebe3bc77aea41fafc8", + "2dab10b6f6404c0663b4b5a9d928738e7e435bcba4369b194522154456faf02b", + "2e6676622a4a6e94ba7bf6de164bfe502d67dca554c389a167ee9692ea19f6d0", + "3162e219d33177d8f3ab0498ebb68c5d68e5b485b92b6f63f0e99a06af4b5133", + "31c2cd3de478ba9be1d6f82b4c20fdfe086ab12e9ad1d99358edd6eb3102ab87", + "368a9bf4a2991fecfd51146398eb4f41fe2d23cccdfbb75f631c60de8f90f0cb", + "40a6b11e4101f404515b2fb8bfcdfa24ad5dea060dc792e59b34132771f2466c", + "4c9d69a7f1d0c2d6daa664a73d4afb0ca5172a57f60a0b7c22adabfc4ca18125", + "56829340ae1505987a940275bcce15f0af85b3d9cc75012c8afdf5961b0f5b9e", + "62856b14639eb5d80d6316e127f70a71fa6050e0ead1dc601ff8f4a179d11778", + "65065c32e9ba182668d746c2a1bbdd2af0a907fb63d7c7bc43c3ab1ca821da83", + "6c8f1b4eaf79f0bd732207f6ea5421b8e6a936b650ecc5e933b14a57619c6639", + "79903f8b1d301733295e3f1dc78c89eecb8e47444d8e4096c62a6ac05e4fccf4", + "7dbc5937804a972332e230a6d6d50eac993eb834d6fe61c5b403ee860c415a63", + "8009176958a550f96020124cae059ef1d34dee7131f3603cee39787a4ae38146", + "88698eceb54eb6fd1933a663b1842bd3e52e0766bafceffa5d2769df72cb45ae", + "8aac78085d75ef3930f5a144559771548aeda1b931a533b69bcc8e1655d77ca2", + "92cf3fb1b7d5fb6f86147210e39d520dc0f842071b40677e0adcafaf07194fa1", + "979f104248115742f8866eb8a3d35206073505d05311b729897506fe5744c396", + "9dacd69ffe422a84e76cd3958ccddc2b5fffa17a457bfb1abdc23b2efa07f7d1", + "9e7fb998333cc94b17aedf775be2e8d203d2e27a348feddc79427c19b0fe9ea2", + "a023ede2e719bc0fc7bfd82991494ded0d40ec34dcd511111476b0680000a90b", + "a3bfb2168fbf3c197263b1921502632fc3b1a6415a4c740efabe5bedd8eecc6d", + "b50f9b586a3498492c93826306367d25795cfba4d3852b2ec12a2650c19d56b4", + "b5d9392c52b396cc75af636a2cbefe9826ec585d1740d67ddd1ea6b06a7657e5", + "be5036a3b2760ebe664cec63bc56f255a3f5a6b82e8e53c55ee3e144a127437f", + "c4d8a64d82cf39d956a885a828d716ce960fd13c0cec8e359556b427200255b8", + "c8af0b81fba99345ba67dbbdf7b4415e4010e7b8206bdad7415eb1a6b371ea36", + "cc9d43d29f502f0570580b3ffacae0e262faeb9b2229fe5342a701078b9f06b2", + "cd372c5591f22da97528170ccaafdcaa070378522bfb8757a108d7935d335a68", + "df9e7feac78c7301cb8f872843632c12236ff1ca2bcad6637cb57f9c219a5916", + "eb874737e51d373527bec3648c9b802c5f2d5d00b108588fc62ba19dee2eedfb", + "ecdcad9fface944533ef84fe1d770a589df45d073b939a28cded19369ee82302", + "ee1e620aa06730c6e08f309ced2a3eb5a9a02e6c60bb4c19a86bec3c2afefaea", + "f2e7c7af178ea8c025dd158f595741b249a114e7052bf8df9237d927ee97db0d", + "fd14cfaa56cef04e0bad7a3d077367da7acd08675466315eb9731532e9c2f2b6", + "fff53b305e93dcecdf6104004ec42b985bcb7e1da8434b01afb39a2c25b335cf" + ] + } + ] +} diff --git a/scripts/quality/govulncheck-tool/doc.go b/scripts/quality/govulncheck-tool/doc.go new file mode 100644 index 0000000000..c0620413e8 --- /dev/null +++ b/scripts/quality/govulncheck-tool/doc.go @@ -0,0 +1,2 @@ +// Package govulnchecktool anchors the lockfile for the CI govulncheck binary. +package govulnchecktool diff --git a/scripts/quality/govulncheck-tool/go.mod b/scripts/quality/govulncheck-tool/go.mod new file mode 100644 index 0000000000..11ccc6952d --- /dev/null +++ b/scripts/quality/govulncheck-tool/go.mod @@ -0,0 +1,14 @@ +module github.com/raphaeltm/simple-agent-manager/scripts/quality/govulncheck-tool + +go 1.25.0 + +tool golang.org/x/vuln/cmd/govulncheck + +require ( + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/tools v0.48.0 // indirect + golang.org/x/vuln v1.6.0 // indirect +) diff --git a/scripts/quality/govulncheck-tool/go.sum b/scripts/quality/govulncheck-tool/go.sum new file mode 100644 index 0000000000..2780d4407d --- /dev/null +++ b/scripts/quality/govulncheck-tool/go.sum @@ -0,0 +1,22 @@ +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.6.0 h1:FeMO9Rm/HwyduOztbvKcOw+zvDEPr4I4aQNSfevFcKY= +golang.org/x/vuln v1.6.0/go.mod h1:bWlG2493/sjR7ksvicBgMrznH3eYQEyK8ifUYBrqUbg= diff --git a/scripts/quality/lint-adoption-evidence.json b/scripts/quality/lint-adoption-evidence.json new file mode 100644 index 0000000000..2cea448e77 --- /dev/null +++ b/scripts/quality/lint-adoption-evidence.json @@ -0,0 +1,99 @@ +{ + "version": 1, + "auditedAt": "2026-08-09T00:00:00.000Z", + "baseCommit": "8c689a6a7923f76a33d96d6272797090598d6c2d", + "toolchain": { + "typescript": "5.9.3", + "eslint": "9.39.5", + "typescriptEslint": "8.65.0", + "eslintPluginAstro": "1.7.0", + "oxlint": "1.77.0" + }, + "eslintFoundationParity": { + "legacyFiles": 2229, + "legacyDiagnostics": 2372, + "flatDiagnostics": 2372, + "missingNormalizedFindings": 0, + "addedNormalizedFindings": 0 + }, + "oxlintShadow": { + "mode": "report-only", + "typeAware": false, + "typeCheck": false, + "coldWallMilliseconds": 3195, + "engineSeconds": 0.201, + "files": 2448, + "diagnostics": 2774, + "rules": 127, + "trackedAstroFiles": 33, + "observedAstroFiles": 33, + "samFixtureExpectedFindings": 8, + "samFixtureObservedFindings": 8, + "samFixtureConformancePercent": 100 + }, + "alignedRuleSamples": [ + { + "rule": "no-non-null-assertion", + "eslint": 1804, + "oxlint": 1804, + "parity": true + }, + { + "rule": "no-explicit-any", + "eslint": 655, + "oxlint": 650, + "parity": false + }, + { + "rule": "consistent-type-imports", + "eslint": 22, + "oxlint": 1, + "parity": false + }, + { + "rule": "react-hooks/exhaustive-deps", + "eslint": 6, + "oxlint": 16, + "parity": false + }, + { + "rule": "jsx-a11y/aria-role", + "eslint": 51, + "oxlint": 51, + "parity": true + }, + { + "rule": "jsx-a11y/no-static-element-interactions", + "eslint": 14, + "oxlint": 17, + "parity": false + } + ], + "fixEvidence": { + "fixture": "inline-type-import", + "eslintProducedOutput": true, + "exactSafeFixOutputMatch": false, + "fixesAppliedToRepository": false + }, + "newInlineSuppressions": 0, + "promotion": { + "decision": "stay-shadow", + "gates": { + "findingParity": false, + "safeFixParity": false, + "zeroNewInlineSuppressions": true, + "directoryAndTemplateCoverage": false, + "samAlphaFixtureParity": true, + "coldRuntimeFaster": true + }, + "reasons": [ + "Aligned TypeScript, hooks, accessibility, and type-import finding counts are not all preserved.", + "The controlled inline-type-import safe-fix output differs from ESLint.", + "The aligned repository file populations differ even though all tracked Astro templates are enumerated." + ] + }, + "rollback": { + "switch": "Remove lint:oxlint from check:fast and the CI lint job.", + "authoritativeLayer": "ESLint 9 flat config remains unchanged and blocking." + } +} diff --git a/scripts/quality/lint-adoption-evidence.test.ts b/scripts/quality/lint-adoption-evidence.test.ts new file mode 100644 index 0000000000..de051af41d --- /dev/null +++ b/scripts/quality/lint-adoption-evidence.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +interface LintEvidence { + eslintFoundationParity: { + legacyDiagnostics: number; + flatDiagnostics: number; + missingNormalizedFindings: number; + addedNormalizedFindings: number; + }; + oxlintShadow: { + mode: string; + typeAware: boolean; + typeCheck: boolean; + trackedAstroFiles: number; + observedAstroFiles: number; + samFixtureExpectedFindings: number; + samFixtureObservedFindings: number; + samFixtureConformancePercent: number; + }; + fixEvidence: { exactSafeFixOutputMatch: boolean; fixesAppliedToRepository: boolean }; + newInlineSuppressions: number; + promotion: { decision: string; gates: Record; reasons: string[] }; +} + +const evidence = JSON.parse( + readFileSync(join(process.cwd(), 'scripts/quality/lint-adoption-evidence.json'), 'utf8') +) as LintEvidence; + +describe('lint adoption evidence', () => { + it('proves the ESLint host migration preserved the audited finding set', () => { + expect(evidence.eslintFoundationParity).toMatchObject({ + legacyDiagnostics: 2372, + flatDiagnostics: 2372, + missingNormalizedFindings: 0, + addedNormalizedFindings: 0, + }); + }); + + it('keeps non-type-aware Oxlint and the alpha SAM host advisory', () => { + expect(evidence.oxlintShadow).toMatchObject({ + mode: 'report-only', + typeAware: false, + typeCheck: false, + samFixtureConformancePercent: 100, + }); + expect(evidence.oxlintShadow.observedAstroFiles).toBe(evidence.oxlintShadow.trackedAstroFiles); + expect(evidence.oxlintShadow.samFixtureObservedFindings).toBe( + evidence.oxlintShadow.samFixtureExpectedFindings + ); + }); + + it('refuses promotion while any required gate is unmet', () => { + expect(evidence.promotion.decision).toBe('stay-shadow'); + expect(Object.values(evidence.promotion.gates)).toContain(false); + expect(evidence.promotion.reasons.length).toBeGreaterThan(0); + expect(evidence.fixEvidence).toMatchObject({ + exactSafeFixOutputMatch: false, + fixesAppliedToRepository: false, + }); + expect(evidence.newInlineSuppressions).toBe(0); + }); +}); diff --git a/scripts/quality/run-gitleaks.ts b/scripts/quality/run-gitleaks.ts new file mode 100644 index 0000000000..bf63638311 --- /dev/null +++ b/scripts/quality/run-gitleaks.ts @@ -0,0 +1,123 @@ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { evaluateGitleaksFindings, gitleaksArgsForMode } from './check-secret-scan-policy'; + +type ScanMode = 'current-tree' | 'pr-range'; + +const BASELINE_PATH = 'scripts/quality/gitleaks-reviewed-baseline.json'; + +function argument(name: string): string | undefined { + const prefix = `--${name}=`; + return process.argv + .slice(2) + .find((value) => value.startsWith(prefix)) + ?.slice(prefix.length); +} + +export function createCurrentTreeSnapshot(repositoryRoot: string, treeDirectory: string): void { + const listed = spawnSync( + '/usr/bin/git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { cwd: repositoryRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 } + ); + if (listed.error || listed.status !== 0) { + throw new Error('Could not enumerate the Git current tree for secret scanning.'); + } + + mkdirSync(treeDirectory, { recursive: true }); + for (const relativePath of listed.stdout.split('\0').filter(Boolean)) { + const sourcePath = resolve(repositoryRoot, relativePath); + const expectedPrefix = `${resolve(repositoryRoot)}${sep}`; + if (!sourcePath.startsWith(expectedPrefix)) { + throw new Error('Git returned a path outside the repository.'); + } + if (!existsSync(sourcePath)) continue; + const stat = lstatSync(sourcePath); + const destinationPath = join(treeDirectory, relativePath); + mkdirSync(dirname(destinationPath), { recursive: true }); + if (stat.isFile()) { + copyFileSync(sourcePath, destinationPath); + } else if (stat.isSymbolicLink()) { + // Scan the Git symlink blob (its target text) without following it. + writeFileSync(destinationPath, readlinkSync(sourcePath), { mode: 0o600 }); + } + } +} + +function run(): void { + const mode = argument('mode') as ScanMode | undefined; + if (mode !== 'current-tree' && mode !== 'pr-range') { + throw new Error( + 'Use --mode=current-tree or --mode=pr-range. Full-history scans are private operations.' + ); + } + const repositoryRoot = process.cwd(); + const defaultRange = process.env.GITHUB_BASE_REF + ? `origin/${process.env.GITHUB_BASE_REF}..HEAD` + : 'origin/main..HEAD'; + const range = mode === 'pr-range' ? (argument('range') ?? defaultRange) : undefined; + const temporaryDirectory = mkdtempSync(join(tmpdir(), 'sam-gitleaks-')); + const reportPath = join(temporaryDirectory, 'report.json'); + const treeDirectory = join(temporaryDirectory, 'tree'); + + try { + const scanDirectory = mode === 'current-tree' ? treeDirectory : repositoryRoot; + if (mode === 'current-tree') createCurrentTreeSnapshot(repositoryRoot, treeDirectory); + const result = spawnSync( + process.env.SAM_GITLEAKS_BIN ?? '/usr/local/bin/gitleaks', + [...gitleaksArgsForMode(mode, range), '--report-path', reportPath], + { cwd: scanDirectory, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 } + ); + if (result.error || (result.status !== 0 && result.status !== 1)) { + console.error('Gitleaks could not complete. Scanner output is withheld by policy.'); + process.exitCode = 1; + return; + } + + let report: string; + try { + report = readFileSync(reportPath, 'utf8'); + } catch { + report = result.status === 0 ? '[]' : ''; + } + // The report is intentionally unredacted so reviewed hashes cannot be + // forged by replacing a secret at the same rule/file/line. It remains only + // in the private temporary directory and no scanner output is forwarded. + const baseline = + mode === 'current-tree' + ? readFileSync(join(repositoryRoot, BASELINE_PATH), 'utf8') + : '{"version":1,"matcherVersion":"gitleaks-finding-v2-unredacted","baseCommit":"none","groups":[]}'; + const evaluated = evaluateGitleaksFindings(report, baseline); + if (!evaluated.ok) { + console.error( + evaluated.errors[0] ?? + 'Gitleaks reported a finding. Finding details are withheld by policy.' + ); + process.exitCode = 1; + return; + } + console.log( + `Gitleaks ${mode} scan passed (${evaluated.reviewedFindingCount} reviewed finding(s), 0 new).` + ); + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +} + +const isDirectExecution = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectExecution) run(); diff --git a/scripts/quality/run-oxlint-sam-shadow.test.ts b/scripts/quality/run-oxlint-sam-shadow.test.ts new file mode 100644 index 0000000000..ac589c1aa9 --- /dev/null +++ b/scripts/quality/run-oxlint-sam-shadow.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { findingsConform, normalizeOxlintSamFindings } from './run-oxlint-sam-shadow'; + +describe('Oxlint SAM alpha shadow comparison', () => { + const finding = { + file: 'packages/eslint-plugin-sam/tests/fixtures/example.ts', + line: 4, + message: 'runtime validation required', + rule: 'sam/no-unvalidated-request-json', + }; + + it('normalizes only SAM alpha-host diagnostics', () => { + expect( + normalizeOxlintSamFindings([ + { + code: 'eslint(no-unused-vars)', + filename: finding.file, + labels: [{ span: { line: 1 } }], + message: 'extra native finding', + }, + { + code: 'sam(no-unvalidated-request-json)', + filename: finding.file, + labels: [{ span: { line: 4 } }], + message: finding.message, + }, + ]) + ).toEqual([finding]); + }); + + it('requires exact normalized finding parity', () => { + expect(findingsConform([finding], [finding])).toBe(true); + expect(findingsConform([finding], [{ ...finding, line: 5 }])).toBe(false); + }); +}); diff --git a/scripts/quality/run-oxlint-sam-shadow.ts b/scripts/quality/run-oxlint-sam-shadow.ts new file mode 100644 index 0000000000..7a30ec604d --- /dev/null +++ b/scripts/quality/run-oxlint-sam-shadow.ts @@ -0,0 +1,105 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ESLint } from 'eslint'; + +export interface SamShadowFinding { + file: string; + line: number; + message: string; + rule: string; +} + +interface OxlintLabel { + span?: { line?: number }; +} + +interface OxlintDiagnostic { + code?: string; + filename?: string; + labels?: OxlintLabel[]; + message?: string; +} + +function parseOxlintDiagnostics(raw: string): OxlintDiagnostic[] { + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed !== 'object' || + parsed === null || + !('diagnostics' in parsed) || + !Array.isArray(parsed.diagnostics) + ) { + throw new Error('Oxlint SAM shadow returned an unexpected JSON report.'); + } + return parsed.diagnostics as OxlintDiagnostic[]; +} + +export function normalizeOxlintSamFindings(diagnostics: OxlintDiagnostic[]): SamShadowFinding[] { + return diagnostics + .filter((diagnostic) => diagnostic.code?.startsWith('sam(')) + .map((diagnostic) => ({ + file: diagnostic.filename ?? '', + line: diagnostic.labels?.[0]?.span?.line ?? 0, + message: diagnostic.message ?? '', + rule: diagnostic.code?.replace(/^sam\((.+)\)$/, 'sam/$1') ?? '', + })) + .sort(compareFinding); +} + +function compareFinding(left: SamShadowFinding, right: SamShadowFinding): number { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.rule.localeCompare(right.rule) || + left.message.localeCompare(right.message) + ); +} + +export function findingsConform( + eslintFindings: SamShadowFinding[], + oxlintFindings: SamShadowFinding[] +): boolean { + return ( + JSON.stringify([...eslintFindings].sort(compareFinding)) === JSON.stringify(oxlintFindings) + ); +} + +async function run(): Promise { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + const fixtureGlob = 'packages/eslint-plugin-sam/tests/fixtures/*.ts'; + const fixtureDirectory = 'packages/eslint-plugin-sam/tests/fixtures'; + const eslint = new ESLint({ cwd: repoRoot }); + const eslintResults = await eslint.lintFiles([fixtureGlob]); + const eslintFindings = eslintResults + .flatMap((result) => + result.messages + .filter((message) => message.ruleId?.startsWith('sam/')) + .map((message) => ({ + file: relative(repoRoot, result.filePath).replaceAll('\\', '/'), + line: message.line, + message: message.message, + rule: message.ruleId ?? '', + })) + ) + .sort(compareFinding); + + const oxlint = spawnSync( + resolve(repoRoot, 'node_modules/.bin/oxlint'), + ['--config', '.oxlintrc.sam-shadow.json', '--format', 'json', fixtureDirectory], + { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 } + ); + if (oxlint.error || oxlint.status !== 0) { + throw new Error('Oxlint alpha JS-plugin shadow could not complete.'); + } + const oxlintFindings = normalizeOxlintSamFindings(parseOxlintDiagnostics(oxlint.stdout)); + const conformant = findingsConform(eslintFindings, oxlintFindings); + console.log( + `Oxlint SAM alpha shadow: ${oxlintFindings.length}/${eslintFindings.length} fixture findings; ` + + `finding conformance=${conformant ? '100%' : 'incomplete'}; non-authoritative.` + ); +} + +const isDirectExecution = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectExecution) await run(); diff --git a/scripts/quality/run-oxlint-shadow.test.ts b/scripts/quality/run-oxlint-shadow.test.ts new file mode 100644 index 0000000000..5a6316d792 --- /dev/null +++ b/scripts/quality/run-oxlint-shadow.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { type OxlintReport, summarizeOxlintReport } from './run-oxlint-shadow'; + +describe('Oxlint shadow summary', () => { + it('produces deterministic, bounded report-only output', () => { + const report: OxlintReport = { + diagnostics: [ + { code: 'typescript/no-any', severity: 'warning' }, + { code: 'eslint/no-console', severity: 'warning' }, + { code: 'typescript/no-any', severity: 'warning' }, + ], + number_of_files: 12, + number_of_rules: 3, + start_time: 0.25, + threads_count: 2, + }; + + expect(summarizeOxlintReport(report)).toEqual({ + diagnostics: 3, + files: 12, + mode: 'report-only', + rules: 3, + seconds: 0.25, + topRules: [ + { count: 2, rule: 'typescript/no-any' }, + { count: 1, rule: 'eslint/no-console' }, + ], + }); + }); + + it('uses rule name as the stable tie-breaker', () => { + const report: OxlintReport = { + diagnostics: [ + { code: 'z/rule', severity: 'warning' }, + { code: 'a/rule', severity: 'warning' }, + ], + number_of_files: 1, + number_of_rules: 2, + start_time: 0.1, + threads_count: 1, + }; + + expect(summarizeOxlintReport(report).topRules).toEqual([ + { count: 1, rule: 'a/rule' }, + { count: 1, rule: 'z/rule' }, + ]); + }); +}); diff --git a/scripts/quality/run-oxlint-shadow.ts b/scripts/quality/run-oxlint-shadow.ts new file mode 100644 index 0000000000..e5c1c2dcef --- /dev/null +++ b/scripts/quality/run-oxlint-shadow.ts @@ -0,0 +1,136 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +export interface OxlintDiagnostic { + code: string; + severity: string; +} + +export interface OxlintReport { + diagnostics: OxlintDiagnostic[]; + number_of_files: number; + number_of_rules: number; + start_time: number; + threads_count: number; +} + +export interface OxlintSummary { + diagnostics: number; + files: number; + mode: 'report-only'; + rules: number; + seconds: number; + topRules: Array<{ count: number; rule: string }>; +} + +function parseOxlintReport(raw: string): OxlintReport { + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed !== 'object' || + parsed === null || + !('diagnostics' in parsed) || + !Array.isArray(parsed.diagnostics) || + !('number_of_files' in parsed) || + typeof parsed.number_of_files !== 'number' || + !('number_of_rules' in parsed) || + typeof parsed.number_of_rules !== 'number' || + !('start_time' in parsed) || + typeof parsed.start_time !== 'number' || + !('threads_count' in parsed) || + typeof parsed.threads_count !== 'number' || + !parsed.diagnostics.every( + (diagnostic) => + typeof diagnostic === 'object' && + diagnostic !== null && + 'code' in diagnostic && + typeof diagnostic.code === 'string' && + 'severity' in diagnostic && + typeof diagnostic.severity === 'string' + ) + ) { + throw new Error('Oxlint returned an unexpected JSON report shape.'); + } + return parsed as OxlintReport; +} + +export function summarizeOxlintReport(report: OxlintReport): OxlintSummary { + const counts = new Map(); + for (const diagnostic of report.diagnostics) { + counts.set(diagnostic.code, (counts.get(diagnostic.code) ?? 0) + 1); + } + + return { + diagnostics: report.diagnostics.length, + files: report.number_of_files, + mode: 'report-only', + rules: report.number_of_rules, + seconds: report.start_time, + topRules: [...counts.entries()] + .sort( + ([leftRule, leftCount], [rightRule, rightCount]) => + rightCount - leftCount || leftRule.localeCompare(rightRule) + ) + .slice(0, 10) + .map(([rule, count]) => ({ count, rule })), + }; +} + +function runOxlintShadow(): void { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const binary = path.join( + repoRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'oxlint.cmd' : 'oxlint' + ); + const result = spawnSync( + binary, + [ + '--config', + '.oxlintrc.json', + '--format', + 'json', + 'apps', + 'packages', + 'infra', + 'tools', + 'scripts', + ], + { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + } + ); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const diagnostic = result.stderr.trim() || 'Oxlint exited without diagnostics.'; + throw new Error(`Oxlint shadow execution failed: ${diagnostic}`); + } + + const report = parseOxlintReport(result.stdout); + const summary = summarizeOxlintReport(report); + if (process.argv.includes('--json')) { + console.log(JSON.stringify(summary)); + return; + } + + console.log( + `Oxlint shadow: ${summary.diagnostics} advisory diagnostics across ${summary.files} files ` + + `(${summary.rules} rules, ${summary.seconds.toFixed(3)}s). ESLint remains authoritative.` + ); + for (const entry of summary.topRules) { + console.log(` ${entry.rule}: ${entry.count}`); + } +} + +const isDirectExecution = + process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectExecution) { + runOxlintShadow(); +} diff --git a/scripts/quality/runtime-boundary-semantic-evidence.json b/scripts/quality/runtime-boundary-semantic-evidence.json new file mode 100644 index 0000000000..1f0c878a77 --- /dev/null +++ b/scripts/quality/runtime-boundary-semantic-evidence.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "auditedAt": "2026-08-09T00:00:00.000Z", + "scope": "apps/api/src", + "stage": "advisory", + "blockingEnabled": false, + "rules": { + "blind-external-payload-narrowing": 39, + "unvalidated-row-narrowing": 6 + }, + "totalDiagnostics": 45, + "fixtureTests": 3, + "sampleReview": { + "method": "first ten deterministic file-and-line ordered diagnostics", + "sampleSize": 10, + "potentialFalsePositives": 2, + "potentialFalsePositivePercent": 20 + }, + "decision": { + "promote": false, + "reason": "The bounded checker finds useful candidates but the sampled potential false-positive rate exceeds the 5% promotion threshold. Keep it advisory until sanctioned row coercions are discriminated without losing external-payload findings." + } +} diff --git a/scripts/quality/runtime-boundary-semantic-evidence.test.ts b/scripts/quality/runtime-boundary-semantic-evidence.test.ts new file mode 100644 index 0000000000..a33fe38617 --- /dev/null +++ b/scripts/quality/runtime-boundary-semantic-evidence.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const evidence = JSON.parse( + readFileSync( + join(process.cwd(), 'scripts/quality/runtime-boundary-semantic-evidence.json'), + 'utf8' + ) +) as { + scope: string; + stage: string; + blockingEnabled: boolean; + rules: Record; + totalDiagnostics: number; + sampleReview: { potentialFalsePositivePercent: number }; + decision: { promote: boolean; reason: string }; +}; + +describe('runtime-boundary semantic adoption evidence', () => { + it('contains only the two approved bounded rules', () => { + expect(evidence.scope).toBe('apps/api/src'); + expect(Object.keys(evidence.rules).sort()).toEqual([ + 'blind-external-payload-narrowing', + 'unvalidated-row-narrowing', + ]); + expect(Object.values(evidence.rules).reduce((total, count) => total + count, 0)).toBe( + evidence.totalDiagnostics + ); + }); + + it('stays advisory while the sampled noise gate is unmet', () => { + expect(evidence.stage).toBe('advisory'); + expect(evidence.blockingEnabled).toBe(false); + expect(evidence.sampleReview.potentialFalsePositivePercent).toBeGreaterThan(5); + expect(evidence.decision.promote).toBe(false); + expect(evidence.decision.reason).not.toHaveLength(0); + }); +}); diff --git a/scripts/quality/type-boundary-baseline.json b/scripts/quality/type-boundary-baseline.json new file mode 100644 index 0000000000..d405e70b94 --- /dev/null +++ b/scripts/quality/type-boundary-baseline.json @@ -0,0 +1,25 @@ +{ + "metadata": { + "owner": "SAM quality program", + "backlog": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md", + "review": "Local task-completion/test/constitution review before promotion to CI wiring", + "blockingClasses": [ + "as-any", + "hono-req-json-generic", + "typed-json-parse", + "local-record-guard" + ], + "reportOnlyClasses": ["record-string-unknown", "unknown-double-assertion"], + "notes": "Blocking classes fail only on repository-wide net increases. Report-only classes are counted for visibility and never fail this ratchet." + }, + "counts": { + "as-any": 0, + "hono-req-json-generic": 24, + "typed-json-parse": 23, + "local-record-guard": 9 + }, + "reportOnlyCounts": { + "record-string-unknown": 90, + "unknown-double-assertion": 132 + } +} diff --git a/scripts/quality/workspace-quality-coverage.test.ts b/scripts/quality/workspace-quality-coverage.test.ts new file mode 100644 index 0000000000..e67aa9bd64 --- /dev/null +++ b/scripts/quality/workspace-quality-coverage.test.ts @@ -0,0 +1,253 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +type WorkspaceInventory = { + path: string; + manifest: { + name?: string; + scripts?: Record; + }; + counts: Record; + files: string[]; +}; + +type CoverageFailure = { + workspace: string; + reason: string; +}; + +const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.astro'] as const; + +function parseWorkspacePatterns(workspaceYaml: string): string[] { + const patterns: string[] = []; + let inPackages = false; + + for (const line of workspaceYaml.split('\n')) { + if (/^packages:\s*$/.test(line)) { + inPackages = true; + continue; + } + + if (inPackages && /^\S/.test(line)) { + break; + } + + const match = line.match(/^\s*-\s*['"]?([^'"]+)['"]?\s*$/); + if (inPackages && match) { + patterns.push(match[1]); + } + } + + return patterns; +} + +function expandWorkspacePattern(repoRoot: string, pattern: string): string[] { + if (!pattern.endsWith('/*')) { + return [pattern].filter((workspacePath) => + existsSync(path.join(repoRoot, workspacePath, 'package.json')) + ); + } + + const parent = pattern.slice(0, -2); + const absoluteParent = path.join(repoRoot, parent); + if (!existsSync(absoluteParent)) { + return []; + } + + return readdirSync(absoluteParent) + .map((entry) => path.join(parent, entry).replaceAll(path.sep, '/')) + .filter((workspacePath) => { + const absolutePath = path.join(repoRoot, workspacePath); + return ( + statSync(absolutePath).isDirectory() && existsSync(path.join(absolutePath, 'package.json')) + ); + }); +} + +function listTrackedSourceFiles(repoRoot: string): string[] { + try { + return execFileSync( + 'git', + ['ls-files', ...SOURCE_EXTENSIONS.map((extension) => `*${extension}`)], + { + cwd: repoRoot, + encoding: 'utf8', + } + ) + .split('\n') + .filter(Boolean) + .sort(); + } catch { + return []; + } +} + +function listFixtureSourceFiles(repoRoot: string): string[] { + const files: string[] = []; + + function walk(relativeDirectory: string): void { + for (const entry of readdirSync(path.join(repoRoot, relativeDirectory))) { + const relativePath = path.join(relativeDirectory, entry).replaceAll(path.sep, '/'); + const absolutePath = path.join(repoRoot, relativePath); + if (statSync(absolutePath).isDirectory()) { + walk(relativePath); + } else if (SOURCE_EXTENSIONS.some((extension) => relativePath.endsWith(extension))) { + files.push(relativePath); + } + } + } + + walk('.'); + return files.sort(); +} + +function inventoryWorkspaces( + repoRoot: string, + sourceFiles = listTrackedSourceFiles(repoRoot) +): WorkspaceInventory[] { + const patterns = parseWorkspacePatterns( + readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8') + ); + const workspacePaths = patterns + .flatMap((pattern) => expandWorkspacePattern(repoRoot, pattern)) + .sort(); + + return workspacePaths.map((workspacePath) => { + const manifest = JSON.parse( + readFileSync(path.join(repoRoot, workspacePath, 'package.json'), 'utf8') + ); + const files = sourceFiles.filter((file) => file.startsWith(`${workspacePath}/`)); + const counts = Object.fromEntries(SOURCE_EXTENSIONS.map((extension) => [extension, 0])); + for (const file of files) { + counts[path.extname(file)] += 1; + } + + return { + path: workspacePath, + manifest, + counts, + files, + }; + }); +} + +function validateWorkspaceCoverage(workspaces: WorkspaceInventory[]): CoverageFailure[] { + const failures: CoverageFailure[] = []; + + for (const workspace of workspaces) { + if (workspace.files.length === 0) { + continue; + } + + const scripts = workspace.manifest.scripts ?? {}; + if (!scripts.lint?.trim()) { + failures.push({ + workspace: workspace.path, + reason: `missing lint script for ${workspace.files.length} tracked TS/Astro file(s)`, + }); + } + + const typeScriptFileCount = SOURCE_EXTENSIONS.filter( + (extension) => extension !== '.astro' + ).reduce((sum, extension) => sum + workspace.counts[extension], 0); + const astroFileCount = workspace.counts['.astro']; + const validationScript = scripts.typecheck ?? scripts.check ?? ''; + + if ( + astroFileCount > 0 && + !/\*[^'"]*\.astro|\{[^}]*\bastro\b[^}]*\}|eslint\s+['"]?\.(?:['"]?\s|$)/.test( + scripts.lint ?? '' + ) + ) { + failures.push({ + workspace: workspace.path, + reason: `lint script does not include ${astroFileCount} tracked Astro template(s)`, + }); + } + + if ( + typeScriptFileCount > 0 && + !/\btsc\b|\bastro\s+check\b|\bcheck-astro-templates\b/.test(validationScript) + ) { + failures.push({ + workspace: workspace.path, + reason: `missing TypeScript validation for ${typeScriptFileCount} tracked TS-family file(s)`, + }); + } + + if ( + astroFileCount > 0 && + !/\bastro\s+check\b|\bcheck-astro-templates\b/.test(validationScript) + ) { + failures.push({ + workspace: workspace.path, + reason: `Astro templates require astro check; tsc does not validate ${astroFileCount} .astro file(s)`, + }); + } + } + + return failures; +} + +describe('workspace quality coverage contract', () => { + it('requires lint and type/template coverage for every pnpm workspace with tracked TS/Astro files', () => { + const repoRoot = path.resolve(import.meta.dirname, '../..'); + const workspaces = inventoryWorkspaces(repoRoot); + const failures = validateWorkspaceCoverage(workspaces); + + const summary = workspaces + .filter((workspace) => workspace.files.length > 0) + .map((workspace) => `${workspace.path}: ${workspace.files.length}`) + .join(', '); + + expect(failures, `Workspace source inventory: ${summary}`).toEqual([]); + }); + + it('fails clearly when a new TypeScript workspace has no lint script', () => { + const fixtureRoot = path.join( + import.meta.dirname, + 'fixtures/workspace-quality-coverage/missing-lint' + ); + const failures = validateWorkspaceCoverage( + inventoryWorkspaces(fixtureRoot, listFixtureSourceFiles(fixtureRoot)) + ); + + expect(failures).toContainEqual({ + workspace: 'apps/uncovered', + reason: 'missing lint script for 1 tracked TS/Astro file(s)', + }); + }); + + it('requires astro check rather than tsc-only validation for Astro templates', () => { + const fixtureRoot = path.join( + import.meta.dirname, + 'fixtures/workspace-quality-coverage/astro-tsc-only' + ); + const failures = validateWorkspaceCoverage( + inventoryWorkspaces(fixtureRoot, listFixtureSourceFiles(fixtureRoot)) + ); + + expect(failures).toContainEqual({ + workspace: 'apps/www', + reason: 'Astro templates require astro check; tsc does not validate 1 .astro file(s)', + }); + expect(failures).toContainEqual({ + workspace: 'apps/www', + reason: 'lint script does not include 1 tracked Astro template(s)', + }); + }); + + it('accepts a covered TypeScript workspace fixture', () => { + const fixtureRoot = path.join( + import.meta.dirname, + 'fixtures/workspace-quality-coverage/covered' + ); + const failures = validateWorkspaceCoverage( + inventoryWorkspaces(fixtureRoot, listFixtureSourceFiles(fixtureRoot)) + ); + + expect(failures).toEqual([]); + }); +}); diff --git a/tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md b/tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md new file mode 100644 index 0000000000..cd679a405b --- /dev/null +++ b/tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md @@ -0,0 +1,222 @@ +# Deterministic runtime-boundary and lint quality program + +## Problem + +SAM has strong TypeScript settings, runtime-validation helpers, CI, coverage, and targeted quality scripts, but the repository still has deterministic enforcement gaps: + +- five workspace packages do not participate in root lint; +- two workspace packages have no explicit type/template validation path; +- the legacy ESLint configuration has a parser/plugin major mismatch and no durable flat-config custom-rule host; +- known runtime-boundary debt regrew after a 2026-06-25 cleanup because no repository-wide ratchet prevented new occurrences; +- formatting, secret scanning, direct-dependency evidence, and diff-local Go vulnerability checks are not consistently wired into CI; +- Oxlint has not been measured against SAM's actual rule semantics, coverage, or fix behavior. + +The implementation must improve editor feedback and CI determinism without changing application runtime behavior, suppressing existing findings, or making existing debt fail unrelated pull requests. + +## Source and scope + +- SAM idea: `01KZK7TFEX05MVMDKZWKABBNS7` +- Coordinator task: `01KZKN713FBXPX8YNF5JGKQ1XT` +- Audited base: `origin/main` at `8c689a6a7923f76a33d96d6272797090598d6c2d` +- Integration branch: `sam/coordinate-implement-deterministic-runtime-gkq1xt` +- No feature spec is edited: this is repository quality infrastructure outside an active `specs/` context. + +## Preflight + +### Classification + +- `cross-component-change`: root commands, every TypeScript workspace, quality scripts, and CI must share one deterministic contract. +- `public-surface-change`: contributor-facing commands and diagnostics change. +- `docs-sync-change`: contributor and repository rules must describe the new authoritative/advisory roles. +- `security-sensitive-change`: secret and vulnerability scanning must fail closed without publishing findings. +- `infra-change`: GitHub Actions and supply-chain tooling change, but application deployment/runtime infrastructure must remain behaviorally unchanged. + +### Verified assumptions before editing + +- Fresh fetch found the task workspace one commit behind; the integration branch was fast-forwarded and pushed to exact current main before dispatch. +- Current `pnpm lint` passes with zero errors and 2,372 warnings across 2,229 files in the seven workspaces that currently define lint. Rule totals are 1,658 `no-non-null-assertion`, 644 `no-explicit-any`, and 70 React/hooks/a11y warnings. This is the before-parity reference, not debt to clean up here. +- Current `pnpm typecheck` passes, but Turbo has no typecheck task to execute for `apps/www` or `tools/og-image`. +- Current toolchain is TypeScript 5.9.3, ESLint 8.57.1, `@typescript-eslint/eslint-plugin` 7.18.0, and `@typescript-eslint/parser` 8.65.0. Oxlint is absent. +- `prepare: husky`, `lint-staged`, and lint-staged configuration exist, but `.husky/` does not; CI is the actual authoritative path. +- Current shared runtime helpers are `apps/api/src/lib/runtime-validation.ts` and `apps/api/src/schemas/_validator.ts`; established Valibot use remains preferred. Existing bounded Zod subsystems are not migration targets. +- Official documentation checked before tool selection: + - ESLint flat configuration and migration: and + - typescript-eslint supported dependency ranges: + - Oxlint plugins, CLI, config, migration, and alpha JS plugin host: , , and + - Astro template diagnostics: + - Gitleaks scan modes: + - Go vulnerability analysis: + +### Current-main coverage inventory + +Tracked files were counted with `git ls-files` for `.ts`, `.tsx`, `.mts`, `.cts`, and `.astro`, so generated/untracked output is excluded. + +| Workspace | Lint today | Type/template check today | TS | TSX | MTS | CTS | Astro | Total | +| --------------------- | ---------- | ------------------------- | ---: | --: | --: | --: | ----: | ----: | +| `apps/api` | yes | yes | 1151 | 0 | 0 | 0 | 0 | 1151 | +| `apps/tail-worker` | no | yes | 4 | 0 | 0 | 0 | 0 | 4 | +| `apps/web` | yes | yes | 335 | 501 | 0 | 0 | 0 | 836 | +| `apps/www` | no | no | 12 | 0 | 0 | 0 | 32 | 44 | +| `infra` | no | yes | 20 | 0 | 0 | 0 | 0 | 20 | +| `packages/acp-client` | yes | yes | 34 | 41 | 0 | 0 | 0 | 75 | +| `packages/cloud-init` | no | yes | 5 | 0 | 0 | 0 | 0 | 5 | +| `packages/providers` | yes | yes | 60 | 0 | 0 | 0 | 0 | 60 | +| `packages/shared` | yes | yes | 104 | 0 | 0 | 0 | 0 | 104 | +| `packages/terminal` | yes | yes | 12 | 9 | 0 | 0 | 0 | 21 | +| `packages/ui` | yes | yes | 8 | 30 | 0 | 0 | 0 | 38 | +| `tools/og-image` | no | no | 3 | 0 | 0 | 0 | 0 | 3 | + +The five lint gaps now contain **76** tracked TS/Astro files, not the historical 44. The repository has 2,401 tracked TS-family files; the audited non-test/config source scope contains 1,315 files. + +### Current-main boundary inventory + +A ts-morph syntax pass over the 1,315-file non-test/config source scope found: + +| Pattern | Current count | Rollout role | +| --------------------------------------------------- | ------------: | ------------------------------------------------------- | +| `as any` assertions | 1 | remove the runtime occurrence, then block at zero | +| Hono-style `*.req.json()` | 24 | advisory ESLint diagnostic + blocking net-count ratchet | +| typed `JSON.parse(...) as T` excluding `as unknown` | 23 | advisory ESLint diagnostic + blocking net-count ratchet | +| local `isRecord`/`isObject` definitions | 9 | advisory ESLint diagnostic + blocking net-count ratchet | +| `as Record` | 90 | report-only population; never a broad ban | +| nested `as unknown as` | 132 | report-only population; never a blanket ban | +| files importing Valibot | 70 | context only | +| files importing Zod | 5 | bounded existing subsystems; no incidental migration | + +The one real non-test `as any` is `apps/web/src/pages/ToolsCli.tsx:58`. `JSON.parse(...) as unknown` is explicitly safe and excluded from the unsafe-assertion rule and ratchet. + +### Impact/data-flow trace + +1. A developer runs `pnpm check:fast` from `package.json`. +2. Root scripts invoke formatting, the current authoritative lint layer, the ESLint custom/import-sort tail, and `scripts/quality/check-type-boundaries.ts` as explicit leaf commands. +3. Workspace lint/typecheck scripts cover the package files listed above; `apps/www` uses `astro check` so `.astro` templates receive diagnostics rather than being falsely described as `tsc` coverage. +4. `.github/workflows/ci.yml` invokes the same leaf commands and scanner helpers; it does not reimplement their matching logic. +5. ESLint plugin findings point developers to `apps/api/src/lib/runtime-validation.ts`, `apps/api/src/schemas/_validator.ts`, `jsonValidator`, `parseWithSchema`, `readResponseJson`, and row-mapper patterns. +6. Existing boundary debt remains passable through checked-in counts; only a repository-wide net increase exits nonzero with deterministic `file:line` guidance. +7. Gitleaks examines the current tree/PR range without public comments/artifacts containing findings. Direct dependency evidence and diff-local govulncheck apply only when their manifests change. +8. Oxlint runs report-only until parity, scoping, suppression, fix-diff, coverage, and cold-performance evidence satisfy every promotion criterion. Otherwise ESLint stays authoritative and Oxlint stays shadow. + +## Orchestration and ownership + +The durable dependency graph lives in `.workflow-state.md` (gitignored). Coding lanes start only from the pushed integration branch and must not deploy staging or merge to main. + +- Coordinator exclusively owns `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, root ESLint/Oxlint configuration, `turbo.json`, `.github/workflows/ci.yml`, parity/benchmark evidence, integration commits, and the final PR. +- Child lane 1 exclusively owns the new unpublished ESLint plugin workspace implementation, RuleTester fixtures, and rule manifest. +- Child lane 2 exclusively owns named type-boundary ratchet and targeted semantic-check files/tests/baselines under `scripts/quality/`. +- Child lane 3 exclusively owns named supply-chain checker/helper files and their tests; coordinator owns workflow wiring. +- Child lane 4 exclusively owns assigned leaf workspace manifests/configs required for lint/type/template coverage; coordinator owns root catalog and lockfile integration. +- Independent review happens only after integration. Reviewer concerns return to implementation before final validation. + +## Implementation checklist + +### Foundation and coverage + +- [x] Add lint scripts for `apps/www`, `apps/tail-worker`, `packages/cloud-init`, `infra`, and `tools/og-image`, covering all 76 current TS/Astro files without broad repository churn. +- [x] Add explicit type/template validation for `apps/www` through `astro check` and for `tools/og-image` through a scoped TypeScript configuration. +- [x] Add a deterministic inventory/contract test proving every pnpm workspace has intended lint and type/template coverage. +- [x] Enforce `format:check` in CI through the same leaf command used by `check:fast`; current-main debt is ratcheted at 2,390 files rather than made blocking. +- [x] Align supported ESLint/typescript-eslint versions and migrate to ESLint 9 flat config with unchanged rule semantics. +- [x] Capture machine-readable ESLint before/after finding parity for the prior authoritative scope. +- [x] Remove dead Husky/lint-staged dependencies/configuration unless concrete active hook ownership is established; CI remains authoritative. +- [x] Measure caching before adoption. Prettier's built-in cache did not materially reduce the 2,390-file debt scan, so the ratchet uses the pinned-base delta and completes in about eight seconds without a cache. + +### Local SAM ESLint plugin and lifecycle + +- [x] Create an unpublished workspace plugin tested with ESLint 9 `RuleTester`. +- [x] Implement `sam/no-unvalidated-request-json` for precise Hono-style `*.req.json()` calls, with a non-automatic suggestion. +- [x] Implement `sam/no-unsafe-json-parse-assertion` for `TSAsExpression` over `JSON.parse`, excluding only an `unknown` target and narrowly justified fixtures. +- [x] Implement `sam/no-local-record-guard` for known local `isRecord`/`isObject` definition shapes, diagnostic only and no semantic-changing fix. +- [x] Include every current true-positive shape plus comments, strings, multiline calls, aliases/near misses, and at least two negative edge cases per rule. +- [x] Keep DO/D1 row narrowing and blind external-payload narrowing out of the syntax plugin. +- [x] Add `rules.manifest.json` with evidence, owner, matcher version, stage, gate owner, baseline/backlog link, dates, false-positive samples, and expiring exemptions; standard `meta.docs.url` points to the manifest/docs. +- [x] Configure boundary rules as advisory while debt exists; zero inline suppressions were added. + +### Dedicated type-boundary ratchet + +- [x] Add a deterministic repository-wide checker and Vitest suite for `as any`, `*.req.json()`, local record-guard definitions, and typed JSON.parse assertions excluding `as unknown`. +- [x] Check in current counts with owner/backlog/review metadata; existing debt passes and net increases fail with precise `file:line` guidance. +- [x] Prove N→N+1 fails, a file move/split passes, decreases pass without unrelated cleanup, and repeated clean runs are identical. +- [x] Keep `Record` and `as unknown as` populations report-only until discriminating matchers exist. + +### Remaining quality controls + +- [x] Add a portable `.claude/rules/` runtime-boundary rule citing current Valibot helpers and sanctioned env/DO-stub/RPC/guard-then-cast patterns. +- [x] Replace the one runtime `navigator as any` with a bounded local interface after re-auditing that it still existed. +- [x] Add Gitleaks for current-tree and PR-range scanning; keep full-history audit output private operational evidence. +- [x] Add deterministic direct-dependency evidence enforcement for npm and Go manifest diffs, with authoritative registry/homepage link and one-line necessity. +- [x] Add diff-local blocking `govulncheck` when Go module files change. +- [x] Extend a bounded ts-morph checker with only unvalidated DO/D1 row narrowing and blind external-payload narrowing, initially scoped to `apps/api/src`, with positive/negative fixtures. The sampled noise gate is not met, so all 45 diagnostics remain advisory. +- [x] Do not add generic mock-density, PR-size, Semgrep, Knip, whole-repo type-aware lint, or other unproven gates. + +### Oxlint measured adoption + +- [x] Install/configure Oxlint in report-only shadow mode without type-aware mode. +- [x] Compare standard/recommended TypeScript, React/hooks/a11y, API `no-console`/logger exclusion, and `typescript/consistent-type-imports` inline-import behavior against ESLint. +- [x] Capture machine-readable finding parity, safe-fix diff parity, correct ignores/scopes, TS/Astro coverage, suppression count, and clean cold timing in `scripts/quality/lint-adoption-evidence.json`. +- [x] Shadow-run the SAM fixture corpus through Oxlint's alpha JS-plugin host and record 8/8 conformance; it remains non-authoritative. +- [x] Evaluate every promotion gate. Finding and safe-fix parity are not met, so promotion is forbidden despite faster cold runtime and zero new suppressions. +- [x] Keep Oxlint shadow-only because finding regression, safe-fix drift, and aligned-scope mismatch trigger explicit rollback/stay-shadow criteria. +- [x] Do not add `eslint-plugin-oxlint`: promotion gates failed, so ESLint remains the complete authoritative layer and Oxlint remains report-only. + +### Root developer/CI contract + +- [x] Add one obvious `pnpm check:fast` entry point running format check, Oxlint/current lint layer, ESLint custom tail, and the boundary ratchet deterministically. +- [x] Make CI call the same leaf commands, including workspace lint/type/template coverage, quality checker tests, secret/dependency/vulnerability gates, and source-contract/wiring tests. +- [x] Keep pre-existing debt advisory/baselined and reject only net-new debt. +- [x] Keep old authoritative systems enabled until proven parity and document every rollback switch. + +### Integration, review, staging, and delivery + +- [x] Integrate child commits/PRs in progressively ordered commits and re-audit current main before accepting baselines. +- [x] Remove load-sensitive setup from timed API test and hook bodies: collect the heartbeat route and node-agent client once, generate the contract-test RSA key pair during module setup, and reuse a resettable fetch boundary. The 6,799-test API baseline passed, followed by ten consecutive 70-test focused runs under concurrent workspace typecheck load without retries, timeout changes, skips, or relaxed assertions. +- [x] Make the staging state-bucket preflight use the repository-pinned `apps/api` Wrangler binary after the registry-dependent `npx` bootstrap failed deterministically before deployment; cover the command source contract in `deploy-reusable-workflow.test.ts`. +- [ ] Run and archive concise evidence for frozen-lockfile clean install, format, lint/plugin fixtures, all workspace type/template checks, affected JS/TS tests and coverage, quality checker tests, Go tests/race/govulncheck as applicable, build, ESLint parity, Oxlint benchmark, CI wiring, and artifact/suppression cleanliness. +- [ ] Run independent picky architecture/code-quality, security, test, constitution, doc-sync, and task-completion reviews; fix every actionable correctness/security concern. +- [ ] Re-run the local contract after review fixes and ensure CI is green. +- [ ] Immediately before staging, call `list_project_agents`, coordinate a quiet window, re-check active users, and pin one final SHA. +- [ ] Dispatch one Staging Validator using profile `01KQH75F9JGKG0X27GJZ5767B6` with the pinned SHA and consolidated checklist. +- [ ] Complete one consolidated staging sweep, validate zero deployment drift, query authorized Cloudflare state/logs as needed, and leave zero staging VMs/workspaces at rest. +- [ ] Open one cohesive final PR to `main` with evidence, phases, command, ownership, baseline, Oxlint measurements, pinned staging SHA, review outcomes, and rollback notes. +- [ ] Run PR-body preflight and specialist-evidence checkers locally against the live PR body before the final evidence push. +- [ ] Merge only with every hard gate green; otherwise leave an honest draft with old systems authoritative and exact next steps. +- [ ] After merge, match the merged head SHA to the successful production deployment workflow and verify deployed behavior. + +## Acceptance criteria + +- [ ] Runtime behavior and build output remain unchanged apart from the bounded `navigator.userAgentData` typing cleanup. +- [ ] Every pnpm workspace has explicit lint and type/template validation coverage, including Astro templates. +- [ ] ESLint 9 flat config preserves the captured current finding set before any deliberate role split. +- [ ] The three SAM rules have fixture-backed high-precision advisory diagnostics and lifecycle ownership metadata. +- [ ] Existing boundary debt passes; a net-new occurrence fails deterministically with actionable `file:line` guidance; moves/splits/decreases pass. +- [ ] `JSON.parse(...) as unknown` remains allowed; structural assertions are never presented as runtime validation. +- [ ] Gitleaks, direct-dependency evidence, and diff-local govulncheck satisfy the privacy and ownership constraints. +- [ ] Only the two approved semantic checks are added, initially bounded to `apps/api/src` and proven low-noise. +- [ ] `pnpm check:fast` is the obvious local contract and CI invokes its leaf commands rather than duplicating matcher logic. +- [ ] Oxlint is either promoted by complete evidence or remains explicitly safe in shadow mode; TypeScript 5.x and non-type-aware Oxlint are retained. +- [ ] No tracked generated artifacts, unexplained suppressions, exposed secret findings, or broad import-sort churn are introduced. +- [ ] All independent reviewers are PASS/ADDRESSED and consolidated staging passes on the exact final SHA without drift. +- [ ] The PR is merged only if all hard gates pass; otherwise it remains a safe draft with actionable evidence. + +## Rollback plan + +- **Coverage/ESLint foundation:** revert workspace scripts/flat config and restore the captured legacy ESLint config; the legacy path stays present until parity is proven. +- **SAM plugin:** disable the advisory `sam/*` rules or remove the plugin workspace reference; the separate ratchet remains independently reversible. +- **Boundary ratchet:** remove its CI leaf invocation while retaining report output/baseline for diagnosis; no runtime code depends on it. +- **Supply-chain checks:** disable the affected job/leaf command independently; do not publish or baseline secret findings during rollback. +- **Oxlint:** keep or return `lint:oxlint` to report-only and make ESLint authoritative; no TypeScript/toolchain downgrade is needed. +- **CI developer contract:** each leaf command is independently callable and can be removed from `check:fast`/CI without changing application runtime. + +## References + +- `package.json` +- `pnpm-workspace.yaml` +- `.eslintrc.cjs` +- `.github/workflows/ci.yml` +- `scripts/quality/ast-checks.ts` +- `scripts/quality/dependency-governance.test.ts` +- `apps/api/src/lib/runtime-validation.ts` +- `apps/api/src/schemas/_validator.ts` +- `tasks/archive/2026-06-25-replace-isrecord-runtime-validation.md` +- `tasks/archive/2026-03-31-adopt-valibot-api-validation.md` +- `.claude/rules/50-list-read-row-fault-isolation.md` +- `tasks/backlog/2026-07-16-project-data-row-fault-isolation-audit.md` diff --git a/tools/og-image/package.json b/tools/og-image/package.json index 82ae8df61f..4a69ae864b 100644 --- a/tools/og-image/package.json +++ b/tools/og-image/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "generate": "tsx generate.ts", - "generate:default": "tsx generate.ts --template default" + "generate:default": "tsx generate.ts --template default", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "eslint '*.ts' 'templates/**/*.ts' --rule 'simple-import-sort/imports: off' --rule 'simple-import-sort/exports: off'" }, "dependencies": { "@resvg/resvg-js": "2.6.2", diff --git a/turbo.json b/turbo.json index cd7ef7c4c5..fa2ed813f7 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,13 @@ "test:coverage": { "dependsOn": ["build"] }, - "lint": {}, + "lint": { + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/eslint.config.mjs", + "$TURBO_ROOT$/packages/eslint-plugin-sam/**" + ] + }, "typecheck": { "dependsOn": ["^build"] },