diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..968255e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Quantum-L9/LLM-Router CODEOWNERS +# Adapted from Quantum-L9/.github org defaults for this repo's actual layout. + +# Default — platform team owns everything +* @Quantum-L9/platform + +# CI/CD workflows — highest blast-radius +/.github/workflows/ @Quantum-L9/platform + +# Community health files — org governance surfaces +/CONTRIBUTING.md @Quantum-L9/platform +/SECURITY.md @Quantum-L9/platform +/CODE_OF_CONDUCT.md @Quantum-L9/platform +/SUPPORT.md @Quantum-L9/platform +/.github/PULL_REQUEST_TEMPLATE.md @Quantum-L9/platform + +# Package manifest and publish config +/package.json @Quantum-L9/platform diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b551343 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,99 @@ +name: Bug Report +description: Report a reproducible bug in any Quantum-L9 repository +title: "[Bug]: " +labels: ["bug", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to file a bug report. Please fill out all fields completely. + Incomplete reports may be closed without investigation. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: "Describe the bug..." + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Exact steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Run command '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + validations: + required: true + + - type: dropdown + id: repo + attributes: + label: Affected Repository + options: + - l9-ci-core + - Cursor-Governance + - l9-assurance + - .github (org backbone) + - Other (specify in description) + validations: + required: true + + - type: input + id: workflow-name + attributes: + label: Workflow Name (if CI-related) + description: "e.g. pr-pipeline.yml, security.yml" + placeholder: "pr-pipeline.yml" + + - type: input + id: python-version + attributes: + label: Python Version + placeholder: "3.12" + + - type: input + id: node-version + attributes: + label: Node Version (if applicable) + placeholder: "20" + + - type: input + id: os + attributes: + label: Operating System + placeholder: "ubuntu-latest / macOS 14 / Windows 11" + + - type: textarea + id: logs + attributes: + label: Logs / Screenshots + description: Paste relevant log output or attach screenshots. + render: shell + + - type: input + id: related-issues + attributes: + label: Related Issues / PRs + placeholder: "#123, #456" diff --git a/.github/ISSUE_TEMPLATE/ci-failure.yml b/.github/ISSUE_TEMPLATE/ci-failure.yml new file mode 100644 index 0000000..bc3a892 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ci-failure.yml @@ -0,0 +1,86 @@ +name: CI Pipeline Failure +description: Report a CI pipeline failure for triage by the platform team +title: "[CI Failure]: " +labels: ["ci-failure", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Use this template for CI pipeline failures that are not explained by a code bug. + For code bugs that cause test failures, use the Bug Report template instead. + + - type: input + id: workflow-run-url + attributes: + label: Workflow Run URL + description: Paste the full URL of the failed GitHub Actions run. + placeholder: "https://github.com/Quantum-L9/l9-ci-core/actions/runs/XXXXXXXXX" + validations: + required: true + + - type: input + id: failed-job + attributes: + label: Failed Job Name + placeholder: "pr-pipeline / security-scan / scorecard" + validations: + required: true + + - type: dropdown + id: trigger + attributes: + label: Triggered By + options: + - Pull Request + - Push to main + - Scheduled (cron) + - Workflow Dispatch + - Tag push + validations: + required: true + + - type: textarea + id: error-output + attributes: + label: Error Output + description: Paste the relevant error section from the failed step. + render: shell + validations: + required: true + + - type: dropdown + id: bisect-attempted + attributes: + label: Bisect Attempted? + options: + - "No — first occurrence" + - "No — intermittent failure" + - "Yes — narrowed to specific commit (paste SHA below)" + - "Yes — cannot reproduce locally" + validations: + required: true + + - type: input + id: bisect-sha + attributes: + label: Bisect SHA (if applicable) + placeholder: "2b330a5aab90cd7781bef08f14c5e7904b61bc56" + + - type: dropdown + id: affected-kernel + attributes: + label: Affected Kernel + options: + - pr-pipeline.yml + - release-publish.yml + - nightly.yml + - pre-commit-ci.yml + - trio-governance.yml + - security.yml + - scorecard.yml + - sbom.yml + - l9-self-ci.yml (inline only) + - Not a kernel call (repo-local workflow) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a04534e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,69 @@ +name: Feature Request +description: Propose a new feature or enhancement for Quantum-L9 infrastructure +title: "[Feature]: " +labels: ["enhancement", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Before submitting, check existing issues and discussions to avoid duplicates. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem does this feature solve? What gap does it address? + placeholder: "Currently, when I do X, I have to manually Y because..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the feature you'd like to see implemented. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: What other approaches did you consider? Why did you reject them? + + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: Define what "done" looks like. Use checkboxes. + placeholder: | + - [ ] CI gate added to pr-pipeline kernel + - [ ] CONTRIBUTING.md updated + - [ ] TRACEABILITY_MAP.yaml entry added + validations: + required: true + + - type: dropdown + id: affected-repos + attributes: + label: Primarily Affected Repository + multiple: true + options: + - l9-ci-core + - Cursor-Governance + - l9-assurance + - .github (org backbone) + - Multiple repos (org-wide change) + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Suggested Priority + options: + - Critical (blocks current work) + - High (significant improvement) + - Medium (nice to have) + - Low (future consideration) diff --git a/.github/ISSUE_TEMPLATE/gov-violation.yml b/.github/ISSUE_TEMPLATE/gov-violation.yml new file mode 100644 index 0000000..5765ba3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/gov-violation.yml @@ -0,0 +1,84 @@ +name: Governance Violation +description: Report a violation of CANONICAL_LAW.md policy +title: "[Gov Violation]: " +labels: ["governance", "violation", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Use this template to report a detected violation of [CANONICAL_LAW.md](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md). + The platform team will triage and enforce. Do NOT use this for general bugs — use the Bug Report template. + + - type: dropdown + id: violation-type + attributes: + label: Violation Type + options: + - Symlink drift (§2 — symlinks missing or broken) + - Anti-pattern detected (§7 — forbidden patterns present) + - Trio separation breach (§3 — model/service/interface cross-import) + - Policy gate failure (CI gate not enforcing policy) + - Workspace wiring failure (§8 — workspace config broken) + - Kernel ref violation (@main used instead of @v1) + - Other (describe below) + validations: + required: true + + - type: dropdown + id: canonical-law-section + attributes: + label: CANONICAL_LAW.md Section Violated + options: + - "§1 — Unknown/General" + - "§2 — Symlink Contract" + - "§3 — Trio Separation" + - "§4 — Unknown" + - "§5 — Unknown" + - "§6 — Unknown" + - "§7 — Anti-Patterns" + - "§8 — Workspace Wiring" + - "§9 — Unknown" + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Evidence + description: Paste command output, diff, or log that demonstrates the violation. + render: shell + validations: + required: true + + - type: dropdown + id: affected-repos + attributes: + label: Affected Repository + multiple: true + options: + - l9-ci-core + - Cursor-Governance + - l9-assurance + - .github (org backbone) + - Multiple repos + validations: + required: true + + - type: dropdown + id: severity + attributes: + label: Severity + options: + - Critical (active governance bypass / security impact) + - High (CI not enforcing policy) + - Medium (drift detected but not exploited) + - Low (documentation only) + validations: + required: true + + - type: textarea + id: proposed-fix + attributes: + label: Proposed Fix (optional) + description: If you know how to fix this, describe the resolution. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1549eca --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,46 @@ +## Summary + + + +## Type of Change + +- [ ] Bug fix +- [ ] Feature / enhancement +- [ ] Refactor (no behavior change) +- [ ] Documentation +- [ ] CI / governance change +- [ ] Breaking change (see rollback plan below) + +--- + +## Governance Checklist + +- [ ] **Governance setup verified** — ran `setup_workspace_symlinks.sh`, symlinks resolve ([§2](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#2-symlink-contract)) +- [ ] **Symlinks validated** — `ls -la .cursor/rules .cursor/skills .cursor/commands` all resolve +- [ ] **All CI gates green** — no required checks failing or bypassed +- [ ] **Anti-patterns checked** — reviewed [CANONICAL_LAW.md §7](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#7-anti-patterns) — none violated +- [ ] **CODEOWNERS notified** — blast-radius files trigger auto-request; confirmed reviewers assigned +- [ ] **Workspace wiring intact** — [§8](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#8) wiring requirements satisfied +- [ ] **TRACEABILITY_MAP.yaml updated** — if this PR resolves an open unknown, mark as RESOLVED +- [ ] **Kernel ref discipline** — thin callers use `@v1`, never `@main` or bare SHA + +--- + +## Breaking Change + +- [ ] This is a breaking change + +If checked, describe the impact and migration path: + + + +## Rollback Plan + + + + +--- + +## Related Issues + +Closes # diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3230fd5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + # npm dependency updates for this package + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "America/New_York" + reviewers: + - "Quantum-L9/platform" + commit-message: + prefix: "chore(deps)" + labels: + - "dependencies" + - "npm" + open-pull-requests-limit: 10 + + # SHA-pin maintenance for GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "America/New_York" + reviewers: + - "Quantum-L9/platform" + commit-message: + prefix: "chore(deps)" + labels: + - "dependencies" + - "github-actions" + open-pull-requests-limit: 5 diff --git a/.github/governance/execution-profiles.yaml b/.github/governance/execution-profiles.yaml new file mode 100644 index 0000000..2d58873 --- /dev/null +++ b/.github/governance/execution-profiles.yaml @@ -0,0 +1,45 @@ +{ + "schema": "l9.execution-profiles/v1", + "profiles": { + "pr_fast": { + "sdk_profile": "ci_fast", + "strict": true, + "default_mode": "blocking", + "providers": ["semgrep"], + "policy": "", + "allowed_events": ["pull_request", "workflow_dispatch"] + }, + "merge": { + "sdk_profile": "ci_fast", + "strict": true, + "default_mode": "blocking", + "providers": ["semgrep"], + "policy": "", + "allowed_events": ["push", "workflow_dispatch"] + }, + "nightly": { + "sdk_profile": "ci_deep", + "strict": true, + "default_mode": "advisory", + "providers": ["semgrep"], + "policy": "", + "allowed_events": ["schedule", "workflow_dispatch"] + }, + "release": { + "sdk_profile": "ci_deep", + "strict": true, + "default_mode": "blocking", + "providers": ["semgrep"], + "policy": "", + "allowed_events": ["push", "workflow_dispatch"] + }, + "supply_chain": { + "sdk_profile": "ci_deep", + "strict": true, + "default_mode": "blocking", + "providers": ["semgrep"], + "policy": "", + "allowed_events": ["schedule", "workflow_dispatch"] + } + } +} diff --git a/.github/governance/promotion-policy.yaml b/.github/governance/promotion-policy.yaml new file mode 100644 index 0000000..69b5470 --- /dev/null +++ b/.github/governance/promotion-policy.yaml @@ -0,0 +1,16 @@ +{ + "schema": "l9.promotion-policy/v1", + "transitions": { + "disabled": ["shadow"], + "shadow": ["advisory", "disabled"], + "advisory": ["blocking", "shadow"], + "blocking": ["advisory"] + }, + "requirements": { + "minimum_observation_runs": 20, + "minimum_observation_days": 7, + "maximum_contract_failures": 0, + "maximum_artifact_validation_failures": 0, + "approval_required": true + } +} diff --git a/.github/governance/provider-requiredness.yaml b/.github/governance/provider-requiredness.yaml new file mode 100644 index 0000000..a8194e4 --- /dev/null +++ b/.github/governance/provider-requiredness.yaml @@ -0,0 +1,20 @@ +{ + "schema": "l9.provider-requiredness/v1", + "profiles": { + "pr_fast": { + "semgrep": true + }, + "merge": { + "semgrep": true + }, + "nightly": { + "semgrep": false + }, + "release": { + "semgrep": true + }, + "supply_chain": { + "semgrep": true + } + } +} diff --git a/.github/governance/quality-thresholds.yaml b/.github/governance/quality-thresholds.yaml new file mode 100644 index 0000000..d33cbd6 --- /dev/null +++ b/.github/governance/quality-thresholds.yaml @@ -0,0 +1,21 @@ +{ + "schema": "l9.quality-threshold-selection/v1", + "profiles": { + "pr_fast": { + "sdk_policy": "" + }, + "merge": { + "sdk_policy": "" + }, + "nightly": { + "sdk_policy": "" + }, + "release": { + "sdk_policy": "" + }, + "supply_chain": { + "sdk_policy": "" + } + }, + "note": "Core selects an SDK policy file but never evaluates finding thresholds itself. Point sdk_policy at a policy file the pinned SDK understands to raise/lower gates." +} diff --git a/.github/governance/rule-modes.yaml b/.github/governance/rule-modes.yaml new file mode 100644 index 0000000..443e424 --- /dev/null +++ b/.github/governance/rule-modes.yaml @@ -0,0 +1,19 @@ +{ + "schema": "l9.rule-modes/v1", + "defaults": { + "pr_fast": "blocking", + "merge": "blocking", + "nightly": "advisory", + "release": "blocking", + "supply_chain": "blocking" + }, + "provider_overrides": { + "semgrep": {} + }, + "allowed_modes": [ + "blocking", + "advisory", + "shadow", + "disabled" + ] +} diff --git a/.github/governance/waivers.yaml b/.github/governance/waivers.yaml new file mode 100644 index 0000000..dce98fa --- /dev/null +++ b/.github/governance/waivers.yaml @@ -0,0 +1,4 @@ +{ + "schema": "l9.waivers/v1", + "waivers": [] +} diff --git a/.github/workflows/l9-analysis.yml b/.github/workflows/l9-analysis.yml new file mode 100644 index 0000000..f75ff37 --- /dev/null +++ b/.github/workflows/l9-analysis.yml @@ -0,0 +1,190 @@ +# L9 analysis caller — copied from Quantum-L9/l9-ci-core docs/templates/l9-analysis.yml +# Adapted for Node.js/TypeScript: semgrep ruleset dropped p/python (see +# governance README "Python vs Node.js — what changes, what doesn't"). +# +# Topology (this is the wiring that actually works): +# job analyze -> generates the semgrep report and runs the SDK pipeline in ONE +# job so the freshly-generated report is on the same filesystem +# (the reusable normalize workflow re-checks-out github.sha and +# would NOT see an uncommitted report). +# job publish -> calls Core's publish-analysis.yml, which DOWNLOADS the +# uploaded artifact (no report-in-tree needed) and publishes the +# GitHub check per the resolved governance mode. +# +# Prerequisite: .github/governance/*.yaml (the CI instantiation pack the +# resolver reads) — copied alongside this file from l9-ci-core's governance pack. + +name: L9 Analysis + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: l9-analysis-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Pin Core to the immutable release commit (or replace with the v2 tag once published). + L9_CORE_REF: "54a2f2fc8d060674d544fab14388bb5eff6b8e78" + # Match the event: pr_fast for pull_request, merge for push, nightly/release/ + # supply_chain as appropriate. Must be allowed_events in execution-profiles.yaml. + L9_PROFILE: "pr_fast" + L9_MATRIX_ID: "pr-semgrep" + +jobs: + analyze: + name: Analyze (semgrep -> SDK) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + mode: ${{ steps.gov.outputs.mode }} + enabled: ${{ steps.gov.outputs.enabled }} + artifact-name: ${{ steps.names.outputs.artifact-name }} + governance-digest: ${{ steps.gov.outputs.governance-digest }} + steps: + - name: Checkout immutable event revision + env: + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init . + git remote add origin \ + "https://x-access-token:${TOKEN}@github.com/${REPOSITORY}.git" + git -c protocol.version=2 fetch --depth=1 origin "${REVISION}" + git checkout --detach FETCH_HEAD + git remote set-url origin "https://github.com/${REPOSITORY}.git" + + - id: gov + name: Resolve governance (Core) + uses: Quantum-L9/l9-ci-core/.github/actions/resolve-governance@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + with: + profile: ${{ env.L9_PROFILE }} + provider: semgrep + event-name: ${{ github.event_name }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} + governance-root: .github/governance + + - id: names + name: Resolve artifact name + env: + MATRIX_ID: ${{ env.L9_MATRIX_ID }} + run: | + set -euo pipefail + echo "artifact-name=l9-semgrep-${MATRIX_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + - name: Run semgrep (produce raw report) + if: steps.gov.outputs.enabled == 'true' + run: | + set -euo pipefail + python -m pip install --upgrade pip semgrep + mkdir -p "artifacts/raw/semgrep/${L9_MATRIX_ID}" + # Node.js repo ruleset — JS/TS only (no p/python; this repo is TypeScript). + semgrep scan \ + --config p/javascript \ + --config p/typescript \ + --json \ + --output "artifacts/raw/semgrep/${L9_MATRIX_ID}/report.json" \ + --error --quiet || true + env: + L9_MATRIX_ID: ${{ env.L9_MATRIX_ID }} + + - id: sdk + name: Provision immutable SDK + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/provision-sdk@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + + - name: Normalize provider report + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/invoke-sdk@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + with: + executable: ${{ steps.sdk.outputs.executable }} + operation: semgrep-normalize + input: artifacts/raw/semgrep/${{ env.L9_MATRIX_ID }}/report.json + output: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + root: . + snapshot-id: ${{ github.sha }} + revision: ${{ github.sha }} + strict: ${{ steps.gov.outputs.strict }} + required: ${{ steps.gov.outputs.required-provider }} + policy: ${{ steps.gov.outputs.sdk-policy }} + + - name: Validate canonical bundle + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/validate-bundle@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + with: + executable: ${{ steps.sdk.outputs.executable }} + bundle: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + + - name: Project agent-review payload + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/invoke-sdk@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + with: + executable: ${{ steps.sdk.outputs.executable }} + operation: bundle-project-agent-payload + input: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + output: .l9/runtime/${{ env.L9_MATRIX_ID }}/agent-review-payload.json + strict: ${{ steps.gov.outputs.strict }} + + - id: route + name: Route artifacts + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/route-artifacts@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + with: + provider: semgrep + matrix-id: ${{ env.L9_MATRIX_ID }} + raw-report: artifacts/raw/semgrep/${{ env.L9_MATRIX_ID }}/report.json + bundle: .l9/runtime/${{ env.L9_MATRIX_ID }}/finding-bundle.json + agent-payload: .l9/runtime/${{ env.L9_MATRIX_ID }}/agent-review-payload.json + destination-root: artifacts + + - name: Build artifact manifest + if: steps.gov.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/actions/build-artifact-manifest@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + with: + provider: semgrep + matrix-id: ${{ env.L9_MATRIX_ID }} + sdk-revision: ${{ steps.sdk.outputs.sdk-revision }} + bundle: ${{ steps.route.outputs.bundle }} + agent-payload: ${{ steps.route.outputs.agent-payload }} + raw-directory: ${{ steps.route.outputs.raw-directory }} + output: artifacts/metadata/${{ env.L9_MATRIX_ID }}/artifact-manifest.json + + - name: Upload analysis artifact set + if: steps.gov.outputs.enabled == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.names.outputs.artifact-name }} + path: | + artifacts/raw/semgrep/${{ env.L9_MATRIX_ID }}/ + artifacts/l9/${{ env.L9_MATRIX_ID }}/ + artifacts/metadata/${{ env.L9_MATRIX_ID }}/ + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish analysis (Core) + needs: analyze + if: needs.analyze.outputs.enabled == 'true' + uses: Quantum-L9/l9-ci-core/.github/workflows/publish-analysis.yml@54a2f2fc8d060674d544fab14388bb5eff6b8e78 + permissions: + actions: read + checks: write + contents: read + with: + artifact-name: ${{ needs.analyze.outputs.artifact-name }} + profile: pr_fast + mode: ${{ needs.analyze.outputs.mode }} + provider: semgrep + matrix-id: pr-semgrep + governance-digest: ${{ needs.analyze.outputs.governance-digest }} + repository-revision: ${{ github.sha }} + workflow-result: ${{ needs.analyze.result }} diff --git a/.github/workflows/l9-governance.yml b/.github/workflows/l9-governance.yml new file mode 100644 index 0000000..1c40c12 --- /dev/null +++ b/.github/workflows/l9-governance.yml @@ -0,0 +1,13 @@ +# Quantum-L9 org starter — Three-tier separation enforcement (model/service/interface). Calls l9-ci-core kernel; do not add logic here. +name: "L9 Governance Trio" +on: + pull_request: + push: + branches: [main] + +jobs: + l9_governance: + uses: Quantum-L9/l9-ci-core/.github/workflows/trio-governance.yml@v1 + secrets: inherit + with: + src-directory: "src" diff --git a/.github/workflows/l9-lint-test-node.yml b/.github/workflows/l9-lint-test-node.yml new file mode 100644 index 0000000..5efa2e4 --- /dev/null +++ b/.github/workflows/l9-lint-test-node.yml @@ -0,0 +1,156 @@ +# L9 consumer lint + test workflow for Node.js / TypeScript +# +# Copied from Quantum-L9/l9-ci-core docs/templates/l9-lint-test-node.yml. +# Node counterpart to l9-lint-test.yml (Python). Provides the TypeScript/Node +# hygiene half that v1's pr-pipeline never covered. +# +# Three independent gates (each surfaces as its own required check): +# lint -> eslint . lint / style / correctness (your eslint config) +# typecheck -> tsc --noEmit type-check the whole tree against the type +# system (honors tsconfig, e.g. strict: true). +# NO compiled output; it proves the types are +# sound, it does not run the code. +# test -> vitest run one-shot, non-interactive run of *.test.ts. +# `run` is mandatory in CI — bare `vitest` is +# watch mode and would hang the job forever. +# +# v2 conventions: immutable event-revision checkout, contents: read only (no +# write scopes). Node comes preinstalled on ubuntu-latest; see the optional +# setup-node block to pin a specific version. Package manager auto-detected +# from the lockfile (npm / pnpm / yarn). +name: L9 Lint and Test (Node) + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: l9-lint-test-node-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# ── Reusable anchors kept inline per job (templates must stay copy-paste-able). +# Each job: immutable checkout -> install deps -> run one gate. + +jobs: + lint: + name: ESLint + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout immutable event revision + env: + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init . + git remote add origin \ + "https://x-access-token:${TOKEN}@github.com/${REPOSITORY}.git" + git -c protocol.version=2 fetch --depth=1 origin "${REVISION}" + git checkout --detach FETCH_HEAD + git remote set-url origin "https://github.com/${REPOSITORY}.git" + # OPTIONAL — pin Node. ubuntu-latest ships Node 20+, used as-is. To pin, + # uncomment and set a full 40-char SHA you trust for actions/setup-node: + # - uses: actions/setup-node@ # v4.x + # with: + # node-version-file: package.json # or .nvmrc, or node-version: "20" + - name: Install dependencies + run: | + set -euo pipefail + corepack enable || true + if [ -f pnpm-lock.yaml ]; then + corepack prepare pnpm@latest --activate && pnpm install --frozen-lockfile + elif [ -f yarn.lock ]; then + corepack prepare yarn@stable --activate && yarn install --immutable + elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install + fi + - name: ESLint + run: npx --no-install eslint . || npx eslint . + + typecheck: + name: tsc --noEmit + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout immutable event revision + env: + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init . + git remote add origin \ + "https://x-access-token:${TOKEN}@github.com/${REPOSITORY}.git" + git -c protocol.version=2 fetch --depth=1 origin "${REVISION}" + git checkout --detach FETCH_HEAD + git remote set-url origin "https://github.com/${REPOSITORY}.git" + - name: Install dependencies + run: | + set -euo pipefail + corepack enable || true + if [ -f pnpm-lock.yaml ]; then + corepack prepare pnpm@latest --activate && pnpm install --frozen-lockfile + elif [ -f yarn.lock ]; then + corepack prepare yarn@stable --activate && yarn install --immutable + elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install + fi + - name: Type-check (tsc --noEmit) + run: npx --no-install tsc --noEmit || npx tsc --noEmit + + test: + name: Vitest + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout immutable event revision + env: + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init . + git remote add origin \ + "https://x-access-token:${TOKEN}@github.com/${REPOSITORY}.git" + git -c protocol.version=2 fetch --depth=1 origin "${REVISION}" + git checkout --detach FETCH_HEAD + git remote set-url origin "https://github.com/${REPOSITORY}.git" + - name: Install dependencies + run: | + set -euo pipefail + corepack enable || true + if [ -f pnpm-lock.yaml ]; then + corepack prepare pnpm@latest --activate && pnpm install --frozen-lockfile + elif [ -f yarn.lock ]; then + corepack prepare yarn@stable --activate && yarn install --immutable + elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install + fi + - name: Vitest (one-shot, non-interactive) + env: + CI: "true" + run: npx --no-install vitest run || npx vitest run + # For coverage, install @vitest/coverage-v8 and use: + # npx vitest run --coverage diff --git a/.github/workflows/l9-nightly.yml b/.github/workflows/l9-nightly.yml new file mode 100644 index 0000000..cb70235 --- /dev/null +++ b/.github/workflows/l9-nightly.yml @@ -0,0 +1,14 @@ +# Quantum-L9 org starter — Nightly scheduled validation and dependency freshness check. Calls l9-ci-core kernel; do not add logic here. +name: "L9 Nightly" +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + l9_nightly: + uses: Quantum-L9/l9-ci-core/.github/workflows/nightly.yml@v1 + secrets: inherit + with: + python-version: "3.12" + run-extended-tests: true diff --git a/.github/workflows/l9-node-ts-monorepo.yml b/.github/workflows/l9-node-ts-monorepo.yml new file mode 100644 index 0000000..3da0575 --- /dev/null +++ b/.github/workflows/l9-node-ts-monorepo.yml @@ -0,0 +1,75 @@ +# Quantum-L9 org starter — Node/TypeScript monorepo pipeline. +# Inline jobs for install/typecheck/test/publish-dry-run. +# Security and Scorecard delegate to l9-ci-core kernels; do not add logic to those jobs. +name: "L9 Node/TS Monorepo Pipeline" +on: + pull_request: + branches: [main, 'release/**'] + push: + branches: [main] + +jobs: + install-and-typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + cache: "npm" + - name: Install dependencies + run: npm ci + - name: Type-check + run: npm run typecheck + + test-by-segment: + needs: install-and-typecheck + runs-on: ubuntu-latest + strategy: + matrix: + segment: [unit, integration] + fail-fast: false + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + cache: "npm" + - name: Install dependencies + run: npm ci + - name: Run Jest (${{ matrix.segment }}) + run: npx jest --testPathPattern="${{ matrix.segment }}" --passWithNoTests + + publish-dry-run: + needs: test-by-segment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + cache: "npm" + - name: Install dependencies + run: npm ci + - name: Publish dry-run (all public packages) + run: | + for pkg_dir in packages/*/; do + pkg_json="$pkg_dir/package.json" + if [ ! -f "$pkg_json" ]; then continue; fi + private=$(node -e "const p=require('./$pkg_json'); console.log(p.private || false)") + if [ "$private" = "false" ] || [ "$private" = "undefined" ]; then + echo "--- Dry-run: $pkg_dir ---" + (cd "$pkg_dir" && npm publish --dry-run) + fi + done + + security: + uses: Quantum-L9/l9-ci-core/.github/workflows/security.yml@v1 + secrets: inherit + with: + python-version: "3.12" + run-npm-audit: true + + scorecard: + uses: Quantum-L9/l9-ci-core/.github/workflows/scorecard.yml@v1 + secrets: inherit diff --git a/.github/workflows/l9-pr-pipeline.yml b/.github/workflows/l9-pr-pipeline.yml new file mode 100644 index 0000000..c6ae847 --- /dev/null +++ b/.github/workflows/l9-pr-pipeline.yml @@ -0,0 +1,16 @@ +# Quantum-L9 org starter — Full PR validation pipeline (lint, type-check, tests, security). Calls l9-ci-core kernel; do not add logic here. +name: "L9 PR Pipeline" +on: + pull_request: + branches: [main, 'release/**'] + push: + branches: [main] + +jobs: + l9_pr_pipeline: + uses: Quantum-L9/l9-ci-core/.github/workflows/pr-pipeline.yml@v1 + secrets: inherit + with: + python-version: "3.12" + run-security: true + working-directory: "." diff --git a/.github/workflows/l9-pre-commit.yml b/.github/workflows/l9-pre-commit.yml new file mode 100644 index 0000000..68aab51 --- /dev/null +++ b/.github/workflows/l9-pre-commit.yml @@ -0,0 +1,13 @@ +# Quantum-L9 org starter — Pre-commit hook enforcement in CI (ruff, mypy, hooks). Calls l9-ci-core kernel; do not add logic here. +name: "L9 Pre-Commit CI" +on: + pull_request: + push: + branches: [main] + +jobs: + l9_pre_commit: + uses: Quantum-L9/l9-ci-core/.github/workflows/pre-commit-ci.yml@v1 + secrets: inherit + with: + python-version: "3.12" diff --git a/.github/workflows/l9-release.yml b/.github/workflows/l9-release.yml new file mode 100644 index 0000000..579d78d --- /dev/null +++ b/.github/workflows/l9-release.yml @@ -0,0 +1,13 @@ +# Quantum-L9 org starter — Versioned release build and PyPI publish. Calls l9-ci-core kernel; do not add logic here. +name: "L9 Release" +on: + push: + tags: ['v*.*.*'] + +jobs: + l9_release: + uses: Quantum-L9/l9-ci-core/.github/workflows/release-publish.yml@v1 + secrets: inherit + with: + python-version: "3.12" + publish-to-pypi: true diff --git a/.github/workflows/l9-sbom.yml b/.github/workflows/l9-sbom.yml new file mode 100644 index 0000000..d3742b8 --- /dev/null +++ b/.github/workflows/l9-sbom.yml @@ -0,0 +1,11 @@ +# Quantum-L9 org starter — SBOM generation (SPDX-JSON via Syft). Calls l9-ci-core kernel; do not add logic here. +name: "L9 SBOM Generator" +on: + push: + tags: ['v*.*.*'] + workflow_dispatch: + +jobs: + l9_sbom: + uses: Quantum-L9/l9-ci-core/.github/workflows/sbom.yml@v1 + secrets: inherit diff --git a/.github/workflows/l9-scorecard.yml b/.github/workflows/l9-scorecard.yml new file mode 100644 index 0000000..b7c60bb --- /dev/null +++ b/.github/workflows/l9-scorecard.yml @@ -0,0 +1,14 @@ +# Quantum-L9 org starter — OpenSSF Scorecard analysis. Calls l9-ci-core kernel; do not add logic here. +name: "L9 OpenSSF Scorecard" +on: + schedule: + - cron: '0 6 * * 1' + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + l9_scorecard: + uses: Quantum-L9/l9-ci-core/.github/workflows/scorecard.yml@v1 + secrets: inherit diff --git a/.github/workflows/l9-security.yml b/.github/workflows/l9-security.yml new file mode 100644 index 0000000..f064a48 --- /dev/null +++ b/.github/workflows/l9-security.yml @@ -0,0 +1,16 @@ +# Quantum-L9 org starter — Security scan pipeline (gitleaks, Bandit, Semgrep, pip-audit). Calls l9-ci-core kernel; do not add logic here. +name: "L9 Security Scan" +on: + pull_request: + push: + branches: [main] + schedule: + - cron: '0 6 * * 1' + +jobs: + l9_security: + uses: Quantum-L9/l9-ci-core/.github/workflows/security.yml@v1 + secrets: inherit + with: + python-version: "3.12" + run-npm-audit: false diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3f22d01 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,54 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the Quantum-L9 community +a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, +ethnicity, sex characteristics, gender identity and expression, level of experience, education, +socioeconomic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior: +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +The Quantum-L9 platform team (`@Quantum-L9/platform`) is responsible for clarifying and enforcing this Code of Conduct. +They will take appropriate and fair corrective action in response to any behavior deemed inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces and also applies when an individual is officially +representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the platform team +via GitHub Security Advisories (for confidential reports) or by contacting `@Quantum-L9/platform` directly. + +All complaints will be reviewed and investigated promptly and fairly. + +## Enforcement Guidelines + +**1. Correction** — Private written warning, clarity on violation, request for public apology. +**2. Warning** — Warning with consequences for continued behavior; no interaction with involved parties for a specified period. +**3. Temporary Ban** — Temporary ban from community interaction. +**4. Permanent Ban** — Permanent ban for sustained, severe, or repeated violations. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..be1fd66 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing to Quantum-L9 + +## Governance Setup Checklist {#governance-setup} + +Before opening any pull request, verify each item: + +- [ ] Cloned `Cursor-Governance` into your local workspace root +- [ ] Ran `setup_workspace_symlinks.sh` (see [§2 symlink contract](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#2-symlink-contract)) +- [ ] Validated symlinks resolve correctly: `ls -la .cursor/rules .cursor/skills .cursor/commands` +- [ ] Read [CANONICAL_LAW.md §8](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#8) for workspace wiring requirements +- [ ] Reviewed [CANONICAL_LAW.md §7 Anti-Patterns](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#7-anti-patterns) — never violate these +- [ ] All CI gates green (no bypassing required status checks) +- [ ] CODEOWNERS notified for blast-radius files + +--- + +## Quick Setup (3 Steps) + +```bash +# Step 1: Clone Cursor-Governance alongside your target repo +git clone https://github.com/Quantum-L9/Cursor-Governance.git + +# Step 2: Run workspace symlink wiring +cd Cursor-Governance +bash scripts/setup_workspace_symlinks.sh + +# Step 3: Validate symlinks +ls -la .cursor/rules .cursor/skills .cursor/commands +# Expected: all three resolve without error +``` + +Per [CANONICAL_LAW.md §2](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#2-symlink-contract): +the workspace root must have `.cursor/` symlinks resolving to `Cursor-Governance/rules/`, `skills/`, and `commands/`. + +--- + +## CI Gate Requirements + +All pull requests must pass: + +| Gate | Tool | Kernel | +|---|---|---| +| Lint + type-check | ruff, mypy (Python) / tsc (TypeScript) | `pr-pipeline.yml@v1` | +| Unit tests | pytest (Python) / Jest (TypeScript) | `pr-pipeline.yml@v1` | +| Secret scan | gitleaks | `security.yml@v1` | +| SAST | Bandit + Semgrep (Python) | `security.yml@v1` | +| Dependency audit | pip-audit / npm audit | `security.yml@v1` | +| Pre-commit hooks | pre-commit framework | `pre-commit-ci.yml@v1` | +| Governance trio | Three-tier separation | `trio-governance.yml@v1` | + +> **Anti-patterns** ([§7](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md#7-anti-patterns)): +> Never duplicate logic across kernels. Never add business logic to thin callers. +> Never reference `@main` from thin callers — always use `@v1`. + +--- + +## Branch Naming & Commit Conventions + +- Branches: `feat/`, `fix/`, `chore/`, `docs/` +- Commits: Conventional Commits format — `feat(scope): message` +- PRs targeting `main` require 2 CODEOWNERS approvals for blast-radius paths + +--- + +## Kernel Authoring (l9-ci-core contributors only) + +- Kernels must use `on: workflow_call` only +- `l9-self-ci.yml` must remain `on: pull_request/push` — **never convert to workflow_call** (circular dependency) +- `@v1` moving tag discipline: force-update `v1` for backward-compatible changes; cut `v2` for breaking changes +- See [workflow-interface-registry.yml](https://github.com/Quantum-L9/.github/blob/main/workflow-interface-registry.yml) for the full kernel API contract diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e2cd9d4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Security Policy + +## Scope + +This policy applies to all repositories in the **Quantum-L9** GitHub organization. + +## Reporting a Vulnerability + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +Report vulnerabilities privately via [GitHub Security Advisories](https://github.com/Quantum-L9/.github/security/advisories/new). + +Include: +- Affected repository and version/SHA +- Vulnerability type and CVSS score estimate (see guidance below) +- Reproduction steps (minimal reproducer preferred) +- Potential impact assessment +- Any proposed mitigations + +## Response SLA + +| Severity | Acknowledge | Patch Target | +|---|---|---| +| Critical (CVSS 9.0–10.0) | 24 hours | 7 days | +| High (CVSS 7.0–8.9) | 48 hours | 14 days | +| Medium (CVSS 4.0–6.9) | 48 hours | 30 days | +| Low (CVSS 0.1–3.9) | 5 business days | Next release cycle | + +## CVSS Scoring Guidance for Reporters + +Use [CVSS v3.1 Calculator](https://www.first.org/cvss/calculator/3.1) to estimate severity. +Key vectors: Attack Vector, Attack Complexity, Privileges Required, User Interaction, Scope, CIA Impact. + +## Security Packages + +The [`l9-assurance`](https://github.com/Quantum-L9/l9-assurance) monorepo provides: +- [`l9-agent-security-testkit`](https://github.com/Quantum-L9/l9-assurance/tree/main/packages/l9-agent-security-testkit) — agent-layer security test utilities +- [`l9-security-testkit`](https://github.com/Quantum-L9/l9-assurance/tree/main/packages/l9-security-testkit) — general security testing framework + +## Automated Security Controls + +All repositories use: +- **gitleaks** — secret scanning on every commit +- **Bandit + Semgrep** — Python SAST +- **pip-audit / npm audit** — dependency vulnerability scanning +- **Dependabot** — automated dependency updates with SHA pinning via `ratchet` +- **OpenSSF Scorecard** — supply-chain security posture scoring + +## Disclosure Policy + +Quantum-L9 follows coordinated disclosure. We request 90 days to remediate before public disclosure. +After the patch is released, we will publish a GitHub Security Advisory crediting the reporter (unless anonymity is requested). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..aa6e613 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,36 @@ +# Support + +## Getting Help + +### Primary: GitHub Issues + +Open a GitHub Issue in the relevant repository using the appropriate template: + +| Issue Type | Template | +|---|---| +| Bug report | [Bug Report](https://github.com/Quantum-L9/.github/issues/new?template=bug_report.yml) | +| Feature request | [Feature Request](https://github.com/Quantum-L9/.github/issues/new?template=feature_request.yml) | +| Governance violation | [Gov Violation](https://github.com/Quantum-L9/.github/issues/new?template=gov-violation.yml) | +| CI pipeline failure | [CI Failure](https://github.com/Quantum-L9/.github/issues/new?template=ci-failure.yml) | + +### Secondary: GitHub Discussions + +For questions, architectural discussions, and community input: +[github.com/Quantum-L9/.github/discussions](https://github.com/Quantum-L9/.github/discussions) + +## Out of Scope + +The following are **not supported** through Quantum-L9 channels: +- General AI/ML questions unrelated to Quantum-L9 infrastructure +- Debugging third-party tools (GitHub Actions runners, PyPI, npm registry) +- Questions already answered in [CANONICAL_LAW.md](https://github.com/Quantum-L9/Cursor-Governance/blob/main/CANONICAL_LAW.md) +- Requests to bypass CI gates or CODEOWNERS requirements + +## Response Expectations + +| Channel | Expected Response Time | +|---|---| +| GitHub Issues (bugs, governance) | 2 business days | +| GitHub Issues (features) | 1 week | +| GitHub Discussions | Best effort | +| Security vulnerabilities | See [SECURITY.md](https://github.com/Quantum-L9/.github/blob/main/SECURITY.md) | diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..e42c9ab --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +// @ts-check +import tseslint from 'typescript-eslint'; + +// ESLint v9 flat config. This repo is ESM (`"type": "module"`), TypeScript-only +// (src/ + tests/), with no runtime bundler config to lint. +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'src/docs/**'], + }, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + rules: { + // Router config objects intentionally cast a resolved model string back + // onto a typed field when applying a budget downgrade (see src/index.ts). + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, +); diff --git a/package-lock.json b/package-lock.json index d3501a3..ee905fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/node": "^22.0.0", "eslint": "^9.0.0", "typescript": "^5.5.0", + "typescript-eslint": "^8.64.0", "vitest": "^2.0.0" }, "engines": { @@ -1020,6 +1021,288 @@ "form-data": "^4.0.4" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1760,6 +2043,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2454,6 +2755,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -2635,6 +2949,19 @@ "node": ">=10" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2756,6 +3083,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -2792,6 +3136,19 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2819,6 +3176,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 950ab21..42ad136 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "typescript": "^5.5.0", "vitest": "^2.0.0", "@types/node": "^22.0.0", - "eslint": "^9.0.0" + "eslint": "^9.0.0", + "typescript-eslint": "^8.64.0" }, "engines": { "node": ">=20.0.0" diff --git a/src/index.ts b/src/index.ts index d6ba1d6..a470e1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,6 @@ import { TaskDescriptor, TaskType, - TaskComplexity, LLMResponse, Provider, BudgetConfig, @@ -21,7 +20,7 @@ import { } from './types.js'; import { resolvePerplexityConfig } from './matrices/perplexity-matrix.js'; import { resolveGeneralConfig, getFallbackChain } from './matrices/general-matrix.js'; -import { BudgetTracker, ThrottleLevel } from './budget/index.js'; +import { BudgetTracker } from './budget/index.js'; import { PerplexityClient } from './providers/perplexity.js'; import { OpenRouterClient } from './providers/openrouter.js'; import { diff --git a/src/types.ts b/src/types.ts index 1e07a1b..66c9059 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,10 +47,16 @@ export enum GeneralModel { O1 = 'openai/o1', O3 = 'openai/o3', - // Vision tier — visual QA, screenshot analysis + // Vision tier — visual QA, screenshot analysis. These intentionally alias + // the base-tier model IDs above: OpenRouter serves the same underlying + // multimodal model for both the text and vision use case, so the "vision" + // member exists only to make call sites self-documenting, not to select a + // distinct model ID (see matrices/general-matrix.ts cost-model comment). + /* eslint-disable @typescript-eslint/no-duplicate-enum-values -- intentional alias to base model id, see comment above */ GPT4O_VISION = 'openai/gpt-4o', CLAUDE_SONNET_VISION = 'anthropic/claude-sonnet-4', GEMINI_FLASH_VISION = 'google/gemini-2.5-flash', + /* eslint-enable @typescript-eslint/no-duplicate-enum-values */ } // ═══════════════════════════════════════════════════════════════ diff --git a/src/vision/index.ts b/src/vision/index.ts index 78dabdd..b2382cd 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -26,7 +26,6 @@ import { TaskComplexity, TaskType, VisionConfig, - LLMResponse, } from '../types.js'; // ═══════════════════════════════════════════════════════════════ diff --git a/tests/budget.test.ts b/tests/budget.test.ts new file mode 100644 index 0000000..8cba9b8 --- /dev/null +++ b/tests/budget.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { BudgetTracker, ThrottleLevel } from '../src/budget/index.js'; +import { TaskComplexity, TaskType } from '../src/types.js'; + +function task(complexity: TaskComplexity, type: TaskType = TaskType.CONTENT_GENERATION) { + return { type, complexity, clientId: 'acme' }; +} + +describe('BudgetTracker', () => { + let tracker: BudgetTracker; + + beforeEach(() => { + tracker = new BudgetTracker(); + tracker.initClient('acme'); + }); + + it('throws when querying a client that was never initialized', () => { + expect(() => tracker.getClientBudgetReport('ghost')).toThrow( + /not initialized/, + ); + }); + + it('starts with no throttle and full remaining budget', () => { + const report = tracker.getClientBudgetReport('acme'); + expect(report.throttleLevel).toBe('none'); + expect(report.remainingMonthly).toBe(200); + expect(report.remainingWeekly).toBe(100); + }); + + it('always allows CRITICAL tasks regardless of spend', () => { + tracker.recordSpend('acme', 500); // blow way past every ceiling + const decision = tracker.evaluateTask('acme', task(TaskComplexity.CRITICAL), 10); + expect(decision.allowTask).toBe(true); + expect(decision.level).toBe(ThrottleLevel.NONE); + expect(decision.forceDowngrade).toBe(false); + }); + + it('soft-throttles once weekly target is exceeded', () => { + tracker.recordSpend('acme', 55); // > weeklyTarget (50), < weeklyHardCeiling (100) + const report = tracker.getClientBudgetReport('acme'); + expect(report.throttleLevel).toBe('soft'); + + const decision = tracker.evaluateTask('acme', task(TaskComplexity.MEDIUM), 1); + expect(decision.level).toBe(ThrottleLevel.SOFT); + expect(decision.allowTask).toBe(true); + expect(decision.forceDowngrade).toBe(true); + expect(decision.maxModelTier).toBe('strategic'); + }); + + it('hard-throttles once the weekly hard ceiling is hit and downgrades MEDIUM to fast tier', () => { + tracker.recordSpend('acme', 100); // == weeklyHardCeiling + const report = tracker.getClientBudgetReport('acme'); + expect(report.throttleLevel).toBe('hard'); + + const decision = tracker.evaluateTask('acme', task(TaskComplexity.MEDIUM), 1); + expect(decision.level).toBe(ThrottleLevel.HARD); + expect(decision.forceDowngrade).toBe(true); + expect(decision.maxModelTier).toBe('fast'); + }); + + it('defers LOW/TRIVIAL tasks under hard throttle unless the cost is negligible', () => { + tracker.recordSpend('acme', 100); + + const deferred = tracker.evaluateTask('acme', task(TaskComplexity.LOW), 1); + expect(deferred.allowTask).toBe(false); + + const negligible = tracker.evaluateTask('acme', task(TaskComplexity.LOW), 0.001); + expect(negligible.allowTask).toBe(true); + expect(negligible.forceDowngrade).toBe(true); + }); + + it('hard-throttles once the monthly budget is exhausted', () => { + tracker.recordSpend('acme', 200); // == monthlyBudgetPerClient + const report = tracker.getClientBudgetReport('acme'); + expect(report.throttleLevel).toBe('hard'); + expect(report.remainingMonthly).toBe(0); + }); + + it('hard-throttles once the global monthly ceiling is hit even for a fresh client', () => { + tracker.initClient('globex'); + tracker.recordSpend('acme', 1900); // just under global ceiling by itself + tracker.recordSpend('globex', 150); // pushes global spend over 2000 + + const report = tracker.getClientBudgetReport('globex'); + expect(report.throttleLevel).toBe('hard'); + }); + + it('resetWeekly clears weekly spend, surge allowance, and throttle', () => { + tracker.recordSpend('acme', 100); + tracker.resetWeekly('acme'); + const report = tracker.getClientBudgetReport('acme'); + expect(report.weekSpend).toBe(0); + expect(report.surgeAllowance).toBe(false); + expect(report.throttleLevel).toBe('none'); + expect(report.remainingWeekly).toBe(100); + }); + + it('resetMonthly clears all counters back to the full budget', () => { + tracker.recordSpend('acme', 150); + tracker.resetMonthly('acme'); + const report = tracker.getClientBudgetReport('acme'); + expect(report.monthSpend).toBe(0); + expect(report.weekSpend).toBe(0); + expect(report.todaySpend).toBe(0); + expect(report.remainingMonthly).toBe(200); + }); + + it('resetDaily only clears todaySpend, leaving week/month spend intact', () => { + tracker.recordSpend('acme', 10); + tracker.resetDaily('acme'); + const report = tracker.getClientBudgetReport('acme'); + expect(report.todaySpend).toBe(0); + expect(report.weekSpend).toBe(10); + expect(report.monthSpend).toBe(10); + }); + + it('checkSurgeAllowance grants surge on/after Thursday when under the surge threshold', () => { + tracker.recordSpend('acme', 10); // 10 / 50 target = 0.2, well under 0.6 threshold + expect(tracker.checkSurgeAllowance('acme', 4)).toBe(true); // Thursday + // Once granted, surgeAllowance stays true even if asked again on a later day. + expect(tracker.checkSurgeAllowance('acme', 5)).toBe(true); + }); + + it('checkSurgeAllowance withholds surge before Thursday or above the threshold', () => { + expect(tracker.checkSurgeAllowance('acme', 2)).toBe(false); // Tuesday + tracker.recordSpend('acme', 40); // 40 / 50 = 0.8, above 0.6 threshold + expect(tracker.checkSurgeAllowance('acme', 4)).toBe(false); + }); + + it('getAllBudgetReports and getGlobalSpend reflect recorded spend across clients', () => { + tracker.initClient('globex'); + tracker.recordSpend('acme', 20); + tracker.recordSpend('globex', 5); + + const all = tracker.getAllBudgetReports(); + expect(all).toHaveLength(2); + + const global = tracker.getGlobalSpend(); + expect(global.monthSpend).toBe(25); + expect(global.ceiling).toBe(2000); + expect(global.utilization).toBeCloseTo(25 / 2000); + }); + + it('per-client overrides in initClient take precedence over the shared config', () => { + tracker.initClient('tiny', { monthlyBudgetPerClient: 10, weeklyHardCeiling: 5 }); + const report = tracker.getClientBudgetReport('tiny'); + expect(report.monthlyBudget).toBe(10); + expect(report.weeklyHardCeiling).toBe(5); + }); +}); diff --git a/tests/general-matrix.test.ts b/tests/general-matrix.test.ts new file mode 100644 index 0000000..93a25a7 --- /dev/null +++ b/tests/general-matrix.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { resolveGeneralConfig, getFallbackChain, estimateGeneralCost } from '../src/matrices/general-matrix.js'; +import { GeneralModel, Provider, TaskComplexity, TaskType, type TaskDescriptor } from '../src/types.js'; + +function task(overrides: Partial = {}): TaskDescriptor { + return { + type: TaskType.CONTENT_GENERATION, + complexity: TaskComplexity.MEDIUM, + ...overrides, + }; +} + +describe('resolveGeneralConfig', () => { + it('routes CLASSIFICATION/TRIVIAL to the cheapest fast-tier model with JSON output', () => { + const config = resolveGeneralConfig(task({ type: TaskType.CLASSIFICATION, complexity: TaskComplexity.TRIVIAL })); + expect(config.model).toBe(GeneralModel.GPT4O_MINI); + expect(config.provider).toBe(Provider.OPENROUTER); + expect(config.responseFormat).toBe('json'); + expect(config.temperature).toBeCloseTo(0.1); + expect(config.maxTokens).toBe(256); + }); + + it('routes CONTENT_GENERATION/CRITICAL to Claude Opus with text output', () => { + const config = resolveGeneralConfig(task({ type: TaskType.CONTENT_GENERATION, complexity: TaskComplexity.CRITICAL })); + expect(config.model).toBe(GeneralModel.CLAUDE_OPUS); + expect(config.responseFormat).toBe('text'); + expect(config.temperature).toBeCloseTo(0.7); + }); + + it('escalates CONTENT_GENERATION maxTokens for HIGH+ complexity', () => { + // KNOWN BUG (pre-existing, out of scope for this PR): resolveMaxTokens + // compares `task.complexity >= TaskComplexity.HIGH`, but TaskComplexity + // is a string enum ('trivial'|'low'|'medium'|'high'|'critical'), so this + // is a lexicographic string comparison, not an ordinal one. 'medium' > + // 'high' alphabetically, so MEDIUM incorrectly satisfies the ">= HIGH" + // check here, and CRITICAL ('critical' < 'high') incorrectly fails it + // elsewhere. This test documents the *actual* current behavior; see + // PR remediation notes for the flagged defect and proposed ordinal fix. + const medium = resolveGeneralConfig(task({ type: TaskType.CONTENT_GENERATION, complexity: TaskComplexity.MEDIUM })); + const high = resolveGeneralConfig(task({ type: TaskType.CONTENT_GENERATION, complexity: TaskComplexity.HIGH })); + expect(medium.maxTokens).toBe(4096); + expect(high.maxTokens).toBe(4096); + }); + + it('honors an explicit expectedOutputTokens override', () => { + const config = resolveGeneralConfig(task({ expectedOutputTokens: 777 })); + expect(config.maxTokens).toBe(777); + }); + + it('falls back to GPT4O_MINI with a documented reason for an unmapped combination', () => { + // Every real TaskType has a mapping in TASK_MODEL_MAP, so cast an invalid + // value through `as` to exercise the defensive fallback branch. + const config = resolveGeneralConfig(task({ type: 'not_a_real_task_type' as TaskType })); + expect(config.model).toBe(GeneralModel.GPT4O_MINI); + expect(config.resolutionReason).toMatch(/Fallback: No mapping/); + }); + + it('produces a deterministic, positive estimated cost', () => { + const config = resolveGeneralConfig(task()); + expect(config.estimatedCostPerCall).toBeGreaterThan(0); + expect(config.estimatedCostPerCall).toBe(estimateGeneralCost(config.model, config.maxTokens)); + }); +}); + +describe('estimateGeneralCost', () => { + it('scales linearly with maxTokens', () => { + const small = estimateGeneralCost(GeneralModel.GPT4O_MINI, 1000); + const large = estimateGeneralCost(GeneralModel.GPT4O_MINI, 2000); + expect(large).toBeCloseTo(small * 2, 5); + }); + + it('charges more for a pricier model at the same token budget', () => { + const mini = estimateGeneralCost(GeneralModel.GPT4O_MINI, 2048); + const opus = estimateGeneralCost(GeneralModel.CLAUDE_OPUS, 2048); + expect(opus).toBeGreaterThan(mini); + }); +}); + +describe('getFallbackChain', () => { + it('returns the two configured fallbacks for a fast-tier model', () => { + const chain = getFallbackChain(GeneralModel.GPT4O_MINI); + expect(chain).toEqual([GeneralModel.GEMINI_FLASH, GeneralModel.CLAUDE_HAIKU]); + }); + + it('never includes the model itself in its own fallback chain', () => { + for (const model of Object.values(GeneralModel)) { + const chain = getFallbackChain(model); + expect(chain).not.toContain(model); + } + }); + + it('falls back to GPT4O_MINI for a model with no configured chain', () => { + const chain = getFallbackChain('not-a-real-model' as GeneralModel); + expect(chain).toEqual([GeneralModel.GPT4O_MINI]); + }); +}); diff --git a/tests/perplexity-matrix.test.ts b/tests/perplexity-matrix.test.ts new file mode 100644 index 0000000..c7c24c1 --- /dev/null +++ b/tests/perplexity-matrix.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { resolvePerplexityConfig, estimatePerplexityCost } from '../src/matrices/perplexity-matrix.js'; +import { + SonarModel, + SearchContextSize, + RecencyFilter, + MessageStrategy, + TaskComplexity, + TaskType, + type TaskDescriptor, +} from '../src/types.js'; + +function task(overrides: Partial = {}): TaskDescriptor { + return { + type: TaskType.COMPETITOR_RESEARCH, + complexity: TaskComplexity.MEDIUM, + ...overrides, + }; +} + +describe('resolvePerplexityConfig', () => { + it('resolves COMPETITOR_RESEARCH/TRIVIAL to the base sonar model with recency=week', () => { + const config = resolvePerplexityConfig(task({ complexity: TaskComplexity.TRIVIAL })); + expect(config.model).toBe(SonarModel.SONAR); + expect(config.recencyFilter).toBe(RecencyFilter.WEEK); + expect(config.disableSearch).toBe(false); // COMPETITOR_RESEARCH is a search task + }); + + it('escalates to deep research + high context + 5 variations at CRITICAL complexity', () => { + const config = resolvePerplexityConfig(task({ complexity: TaskComplexity.CRITICAL })); + expect(config.model).toBe(SonarModel.SONAR_DEEP_RESEARCH); + expect(config.searchContextSize).toBe(SearchContextSize.HIGH); + expect(config.variations).toBe(5); + }); + + it('gives HIGH complexity tasks 3 variations and assistant-context message strategy', () => { + const config = resolvePerplexityConfig(task({ complexity: TaskComplexity.HIGH })); + expect(config.variations).toBe(3); + expect(config.messageStrategy).toBe(MessageStrategy.SYSTEM_USER_ASSISTANT); + }); + + it('uses assistant-context messages at TRIVIAL/LOW/MEDIUM complexity (string-comparison quirk)', () => { + // KNOWN BUG (pre-existing, out of scope for this PR): resolveMessageStrategy + // compares `task.complexity >= TaskComplexity.HIGH` as a plain string + // comparison, not an ordinal complexity comparison. Alphabetically, + // 'trivial' > 'low' > 'medium' > 'high' > 'critical', so every level + // EXCEPT critical satisfies ">= HIGH" here — the opposite of intent. + // This test documents the *actual* current behavior; see PR remediation + // notes for the flagged defect and proposed ordinal fix. + for (const complexity of [TaskComplexity.TRIVIAL, TaskComplexity.LOW, TaskComplexity.MEDIUM]) { + const config = resolvePerplexityConfig(task({ complexity })); + expect(config.messageStrategy).toBe(MessageStrategy.SYSTEM_USER_ASSISTANT); + } + }); + + it('uses plain system/user messages at CRITICAL complexity for a non-reasoning task', () => { + const config = resolvePerplexityConfig(task({ complexity: TaskComplexity.CRITICAL })); + expect(config.messageStrategy).toBe(MessageStrategy.SYSTEM_USER); + }); + + it('respects an explicit recency override even for tasks with a default', () => { + const config = resolvePerplexityConfig(task({ recency: RecencyFilter.HOUR })); + expect(config.recencyFilter).toBe(RecencyFilter.HOUR); + }); + + it('defaults CITATION_CHECK to LOW search context and MONTH recency', () => { + const config = resolvePerplexityConfig(task({ type: TaskType.CITATION_CHECK, complexity: TaskComplexity.LOW })); + expect(config.searchContextSize).toBe(SearchContextSize.LOW); + expect(config.recencyFilter).toBe(RecencyFilter.MONTH); + }); + + it('sets disableSearch=true for non-search tasks without an explicit requiresSearch flag', () => { + const config = resolvePerplexityConfig(task({ type: TaskType.CLASSIFICATION, complexity: TaskComplexity.LOW })); + expect(config.disableSearch).toBe(true); + }); + + it('sets disableSearch=false when requiresSearch is explicitly set, even for a non-search task type', () => { + const config = resolvePerplexityConfig( + task({ type: TaskType.CLASSIFICATION, complexity: TaskComplexity.LOW, requiresSearch: true }), + ); + expect(config.disableSearch).toBe(false); + }); + + it('sets reasoningEffort only for reasoning-tier models, scaled by complexity', () => { + const highReasoning = resolvePerplexityConfig( + task({ type: TaskType.STRATEGIC_REASONING, complexity: TaskComplexity.HIGH }), + ); + expect(highReasoning.model).toBe(SonarModel.SONAR_REASONING_PRO); + expect(highReasoning.reasoningEffort).toBe('medium'); + + const criticalReasoning = resolvePerplexityConfig( + task({ type: TaskType.CITATION_CHECK, complexity: TaskComplexity.CRITICAL }), + ); + expect(criticalReasoning.model).toBe(SonarModel.SONAR_REASONING_PRO); + expect(criticalReasoning.reasoningEffort).toBe('high'); + + const nonReasoning = resolvePerplexityConfig(task({ complexity: TaskComplexity.MEDIUM })); + expect(nonReasoning.reasoningEffort).toBeUndefined(); + }); + + it('propagates an empty domainFilter by default and echoes an explicit one', () => { + const withoutFilter = resolvePerplexityConfig(task()); + expect(withoutFilter.domainFilter).toEqual([]); + + const withFilter = resolvePerplexityConfig(task({ domainFilter: ['example.com'] })); + expect(withFilter.domainFilter).toEqual(['example.com']); + }); + + it('produces a positive estimated cost consistent with estimatePerplexityCost', () => { + const config = resolvePerplexityConfig(task()); + expect(config.estimatedCostPerCall).toBeGreaterThan(0); + expect(config.estimatedCostPerCall).toBe(estimatePerplexityCost(config)); + }); +}); + +describe('estimatePerplexityCost', () => { + it('scales with the number of variations', () => { + const base = resolvePerplexityConfig(task({ complexity: TaskComplexity.MEDIUM })); + const oneVariation = estimatePerplexityCost({ ...base, variations: 1 }); + const threeVariations = estimatePerplexityCost({ ...base, variations: 3 }); + expect(threeVariations).toBeCloseTo(oneVariation * 3, 5); + }); + + it('charges more for HIGH context than LOW context at the same token budget', () => { + const base = resolvePerplexityConfig(task()); + const low = estimatePerplexityCost({ ...base, searchContextSize: SearchContextSize.LOW }); + const high = estimatePerplexityCost({ ...base, searchContextSize: SearchContextSize.HIGH }); + expect(high).toBeGreaterThan(low); + }); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts new file mode 100644 index 0000000..318be03 --- /dev/null +++ b/tests/router.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { L9LLMRouter, BudgetExhaustedError } from '../src/index.js'; +import { Provider, TaskComplexity, TaskType, type TaskDescriptor } from '../src/types.js'; + +function router(): L9LLMRouter { + const r = new L9LLMRouter({ + perplexityApiKey: 'test-perplexity-key', + openrouterApiKey: 'test-openrouter-key', + appName: 'l9-router-tests', + }); + r.initClient('acme'); + return r; +} + +function task(overrides: Partial = {}): TaskDescriptor { + return { + type: TaskType.CONTENT_GENERATION, + complexity: TaskComplexity.MEDIUM, + clientId: 'acme', + ...overrides, + }; +} + +describe('L9LLMRouter.route', () => { + let r: L9LLMRouter; + + beforeEach(() => { + r = router(); + }); + + it('routes search task types to Perplexity', () => { + const decision = r.route(task({ type: TaskType.COMPETITOR_RESEARCH })); + expect(decision.provider).toBe(Provider.PERPLEXITY); + expect(decision.taskType).toBe(TaskType.COMPETITOR_RESEARCH); + expect(decision.estimatedCost).toBeGreaterThan(0); + }); + + it('routes vision task types to OpenRouter with a vision model', () => { + const decision = r.route(task({ type: TaskType.VISUAL_QA })); + expect(decision.provider).toBe(Provider.OPENROUTER); + expect(decision.taskType).toBe(TaskType.VISUAL_QA); + }); + + it('routes non-search, non-vision task types to OpenRouter with the general matrix', () => { + const decision = r.route(task({ type: TaskType.CODE_GENERATION })); + expect(decision.provider).toBe(Provider.OPENROUTER); + expect(decision.taskType).toBe(TaskType.CODE_GENERATION); + }); + + it('defaults clientId to "default" when the task has none', () => { + const decision = r.route({ type: TaskType.CLASSIFICATION, complexity: TaskComplexity.LOW }); + expect(decision.clientId).toBe('default'); + }); + + it('assigns a unique taskId and ISO timestamp to every decision', () => { + const a = r.route(task()); + const b = r.route(task()); + expect(a.taskId).not.toBe(b.taskId); + expect(() => new Date(a.timestamp).toISOString()).not.toThrow(); + }); +}); + +describe('L9LLMRouter budget + client management delegation', () => { + it('initClient + getClientBudgetReport round-trip through the BudgetTracker', () => { + const r = router(); + const report = r.getClientBudgetReport('acme'); + expect(report.throttleLevel).toBe('none'); + }); + + it('resetDaily/Weekly/Monthly delegate to the BudgetTracker without throwing', () => { + const r = router(); + expect(() => r.resetDaily('acme')).not.toThrow(); + expect(() => r.resetWeekly('acme')).not.toThrow(); + expect(() => r.resetMonthly('acme')).not.toThrow(); + }); + + it('getAllBudgetReports and getGlobalSpend reflect the initialized client', () => { + const r = router(); + expect(r.getAllBudgetReports()).toHaveLength(1); + expect(r.getGlobalSpend().monthSpend).toBe(0); + }); +}); + +describe('L9LLMRouter call log + vision helpers', () => { + it('getCallLog/getCallLogByClient start empty before any execute() call', () => { + const r = router(); + expect(r.getCallLog()).toEqual([]); + expect(r.getCallLogByClient('acme')).toEqual([]); + }); + + it('planVisualQA delegates to generateFullSiteQAPlan', () => { + const r = router(); + const plan = r.planVisualQA({ + pages: ['/a'], + viewports: [r.getViewports().desktop_1920], + conversionAudit: false, + }); + expect(plan).toHaveLength(1); + }); + + it('getViewports exposes the standard viewport presets', () => { + const r = router(); + expect(Object.keys(r.getViewports())).toContain('mobile_iphone'); + }); +}); + +describe('BudgetExhaustedError', () => { + it('carries the originating task and routing decision', () => { + const r = router(); + const decision = r.route(task()); + const error = new BudgetExhaustedError('deferred', task(), decision); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('BudgetExhaustedError'); + expect(error.decision).toBe(decision); + }); +}); diff --git a/tests/vision.test.ts b/tests/vision.test.ts new file mode 100644 index 0000000..9a4909e --- /dev/null +++ b/tests/vision.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveVisionConfig, + buildLayoutValidationTask, + buildCompetitorComparisonTask, + buildConversionAuditTask, + generateFullSiteQAPlan, + VIEWPORTS, +} from '../src/vision/index.js'; +import { GeneralModel, TaskComplexity, TaskType } from '../src/types.js'; + +describe('resolveVisionConfig', () => { + it('uses the cheapest vision model for a single-image LOW complexity check', () => { + const config = resolveVisionConfig(TaskType.VISUAL_QA, TaskComplexity.LOW, 1); + expect(config.model).toBe(GeneralModel.GEMINI_FLASH_VISION); + expect(config.detail).toBe('low'); + }); + + it('uses GPT-4o high detail for MEDIUM complexity single-image checks', () => { + const config = resolveVisionConfig(TaskType.VISUAL_QA, TaskComplexity.MEDIUM, 1); + expect(config.model).toBe(GeneralModel.GPT4O_VISION); + expect(config.detail).toBe('high'); + }); + + it('routes multi-image comparisons at CRITICAL complexity to Claude', () => { + const config = resolveVisionConfig(TaskType.SCREENSHOT_ANALYSIS, TaskComplexity.CRITICAL, 2); + expect(config.model).toBe(GeneralModel.CLAUDE_SONNET_VISION); + expect(config.resolutionReason).toMatch(/Multi-image comparison/); + }); + + it('produces a positive estimated cost for every resolved config', () => { + for (const complexity of Object.values(TaskComplexity)) { + const config = resolveVisionConfig(TaskType.VISUAL_QA, complexity, 1); + expect(config.estimatedCostPerCall).toBeGreaterThan(0); + } + }); + + // KNOWN BUG (pre-existing, out of scope for this PR): resolveVisionConfig + // compares TaskComplexity with `<=`/`>=`, but TaskComplexity is a string + // enum ('trivial'|'low'|'medium'|'high'|'critical'), so these are + // lexicographic string comparisons rather than ordinal ones. Alphabetically + // 'critical' < 'high' < 'low' < 'medium' < 'trivial', which inverts the + // intended cost/quality ordering: HIGH and CRITICAL complexity single-image + // tasks fall into the same "cheapest model" branch as LOW, while TRIVIAL + // and MEDIUM land on the more expensive GPT-4o/high-detail branch. This + // also defeats the LAYOUT_VALIDATION override (intended to always use + // GPT-4o) for HIGH/CRITICAL complexity. These tests document the *actual* + // current behavior; see PR remediation notes for the flagged defect and + // proposed ordinal-comparison fix. + it('documents the string-comparison quirk: HIGH/CRITICAL single-image checks fall through to the cheapest model', () => { + for (const complexity of [TaskComplexity.HIGH, TaskComplexity.CRITICAL]) { + const visualQa = resolveVisionConfig(TaskType.VISUAL_QA, complexity, 1); + expect(visualQa.model).toBe(GeneralModel.GEMINI_FLASH_VISION); + expect(visualQa.detail).toBe('low'); + + // LAYOUT_VALIDATION is intended to always force GPT-4o, but the same + // string-comparison bug short-circuits it for HIGH/CRITICAL too. + const layout = resolveVisionConfig(TaskType.LAYOUT_VALIDATION, complexity, 1); + expect(layout.model).toBe(GeneralModel.GEMINI_FLASH_VISION); + expect(layout.detail).toBe('low'); + } + }); + + it('documents the string-comparison quirk: TRIVIAL/MEDIUM single-image checks land on the expensive model', () => { + for (const complexity of [TaskComplexity.TRIVIAL, TaskComplexity.MEDIUM]) { + const config = resolveVisionConfig(TaskType.VISUAL_QA, complexity, 1); + expect(config.model).toBe(GeneralModel.GPT4O_VISION); + expect(config.detail).toBe('high'); + } + }); +}); + +describe('task builders', () => { + it('buildLayoutValidationTask attaches the layout prompt and a single screenshot', () => { + const task = buildLayoutValidationTask('https://example.com/shot.png', VIEWPORTS.desktop_1920); + expect(task.images).toEqual(['https://example.com/shot.png']); + expect(task.viewport).toBe(VIEWPORTS.desktop_1920); + expect(task.prompt).toMatch(/senior web designer/); + }); + + it('buildCompetitorComparisonTask attaches both screenshots in order', () => { + const task = buildCompetitorComparisonTask('https://ours.com/a.png', 'https://competitor.com/b.png', VIEWPORTS.mobile_iphone); + expect(task.images).toEqual(['https://ours.com/a.png', 'https://competitor.com/b.png']); + expect(task.prompt).toMatch(/comparing two website screenshots/); + }); + + it('buildConversionAuditTask attaches the conversion prompt and a single screenshot', () => { + const task = buildConversionAuditTask('https://example.com/landing.png', VIEWPORTS.tablet_ipad); + expect(task.images).toEqual(['https://example.com/landing.png']); + expect(task.prompt).toMatch(/conversion rate optimization/); + }); +}); + +describe('generateFullSiteQAPlan', () => { + it('generates one layout validation task per page × viewport combination', () => { + const plan = generateFullSiteQAPlan({ + pages: ['https://example.com/', 'https://example.com/pricing'], + viewports: [VIEWPORTS.desktop_1920, VIEWPORTS.mobile_iphone], + conversionAudit: false, + }); + const layoutTasks = plan.filter((t) => t.prompt.match(/senior web designer/)); + expect(layoutTasks).toHaveLength(4); + }); + + it('adds competitor comparison tasks for at most the first 3 pages when a competitor URL is set', () => { + const plan = generateFullSiteQAPlan({ + pages: ['/a', '/b', '/c', '/d'], + viewports: [VIEWPORTS.desktop_1440], + competitorUrl: 'https://competitor.com', + conversionAudit: false, + }); + const comparisonTasks = plan.filter((t) => t.images.includes('https://competitor.com')); + expect(comparisonTasks).toHaveLength(3); + }); + + it('adds conversion audit tasks for at most the first 2 pages when enabled', () => { + const plan = generateFullSiteQAPlan({ + pages: ['/a', '/b', '/c'], + viewports: [VIEWPORTS.desktop_1440], + conversionAudit: true, + }); + const auditTasks = plan.filter((t) => t.prompt.match(/conversion rate optimization/)); + expect(auditTasks).toHaveLength(2); + }); + + it('omits competitor and conversion tasks when neither is requested', () => { + const plan = generateFullSiteQAPlan({ + pages: ['/a'], + viewports: [VIEWPORTS.desktop_1440], + conversionAudit: false, + }); + expect(plan).toHaveLength(1); + }); +});